diff --git a/meanas/eigensolvers.py b/meanas/eigensolvers.py index b00894e..3a37693 100644 --- a/meanas/eigensolvers.py +++ b/meanas/eigensolvers.py @@ -11,7 +11,7 @@ import scipy.sparse.linalg as spalg from scipy.optimize import linear_sum_assignment -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass(frozen=True, eq=False) class TargetedEigensolution: """Paired right and transpose-left eigenvectors near a spectral target. @@ -24,8 +24,11 @@ class TargetedEigensolution: """ eigenvalues: NDArray[numpy.complex128] - right_eigenvectors: NDArray[numpy.complex128] - left_eigenvectors: NDArray[numpy.complex128] + right_eigenvectors: NDArray[numpy.complex128] = dataclasses.field(repr=False) + left_eigenvectors: NDArray[numpy.complex128] = dataclasses.field(repr=False) + + +type TargetedEigensolver = Callable[..., TargetedEigensolution] def scipy_eigs_near( @@ -144,6 +147,8 @@ def power_iteration( operator: sparse.spmatrix, guess_vector: NDArray[numpy.complex128] | None = None, iterations: int = 20, + *, + rng: numpy.random.Generator | int | None = None, ) -> tuple[complex, NDArray[numpy.complex128]]: """ Use power iteration to estimate the dominant eigenvector of a matrix. @@ -152,15 +157,18 @@ def power_iteration( operator: Matrix to analyze. guess_vector: Starting point for the eigenvector. Default is a randomly chosen vector. iterations: Number of iterations to perform. Default 20. + rng: Generator or seed used only when ``guess_vector`` is omitted. Returns: (Largest-magnitude eigenvalue, Corresponding eigenvector estimate) """ - rng = numpy.random.default_rng() if guess_vector is None: + rng = numpy.random.default_rng(rng) v = rng.random(operator.shape[0]) + 1j * rng.random(operator.shape[0]) else: - v = guess_vector + v = numpy.array(guess_vector, dtype=complex, copy=True) + if v.shape != (operator.shape[0],) or not numpy.isfinite(v).all() or not numpy.any(v): + raise ValueError('guess_vector must be finite, nonzero, and match the operator dimension') for _ in range(iterations): v = operator @ v @@ -234,6 +242,9 @@ def signed_eigensolve( operator: sparse.spmatrix | spalg.LinearOperator, how_many: int, negative: bool = False, + *, + rng: numpy.random.Generator | int | None = None, + v0: ArrayLike | None = None, ) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]: """ Find the largest-magnitude positive-only (or negative-only) eigenvalues and @@ -244,13 +255,24 @@ def signed_eigensolve( how_many: How many eigenvalues to find. negative: Whether to find negative-only eigenvalues. Default False (positive only). + rng: Optional generator or seed for reproducible initialization. + v0: Finite nonzero starting vector, taking precedence over ``rng``. Returns: (sorted list of eigenvalues, 2D ndarray of corresponding eigenvectors) `eigenvectors[:, k]` corresponds to the k-th eigenvalue """ # Use power iteration to estimate the dominant eigenvector - lm_eigval, _ = power_iteration(operator) + if v0 is None: + generator = numpy.random.default_rng(rng) + start = generator.random(operator.shape[0]) + 1j * generator.random(operator.shape[0]) + else: + start = numpy.array(v0, dtype=complex, copy=True) + lm_eigval, _ = power_iteration(operator, guess_vector=start) + if not numpy.issubdtype(operator.dtype, numpy.complexfloating): + start = start.real + if not numpy.any(start): + raise ValueError('v0 must have a nonzero real part for a real operator') ''' Shift by the absolute value of the largest eigenvalue, then find a few of the @@ -269,7 +291,10 @@ def signed_eigensolve( shifted_operator = operator + spalg.LinearOperator(shape=operator.shape, matvec=lambda v: shift * v) - shifted_eigenvalues, eigenvectors = spalg.eigs(shifted_operator, which='LM', k=how_many, ncv=2 * how_many + 50) + shifted_eigenvalues, eigenvectors = spalg.eigs( + shifted_operator, which='LM', k=how_many, + ncv=min(operator.shape[0], 2 * how_many + 50), v0=start, + ) eigenvalues = shifted_eigenvalues - shift k = eigenvalues.argsort() diff --git a/meanas/fdfd/__init__.py b/meanas/fdfd/__init__.py index 23139d2..e94adc3 100644 --- a/meanas/fdfd/__init__.py +++ b/meanas/fdfd/__init__.py @@ -96,7 +96,6 @@ from . import ( functional as functional, scpml as scpml, waveguide_2d as waveguide_2d, - waveguide_2d_complex as waveguide_2d_complex, waveguide_3d as waveguide_3d, ) # from . import farfield, bloch TODO diff --git a/meanas/fdfd/eme.py b/meanas/fdfd/eme.py index 016b439..87b6225 100644 --- a/meanas/fdfd/eme.py +++ b/meanas/fdfd/eme.py @@ -9,13 +9,13 @@ expected to supply compatible `(E, H)` modal field pairs from The returned matrices follow the usual port ordering: - `get_tr(...)` returns `(T, R)` for left-incident modes. -- `get_abcd(...)` returns the 2-port block transfer matrix built from the two - directional `T/R` solves. +- `get_abcd(...)` returns the 2-port block transfer matrix built from the + interface continuity solve. - `get_s(...)` returns the full block scattering matrix - `[[R12, T12], [T21, R21]]`. + `[[R_L, T_R_to_L], [T_L_to_R, R_R]]`. - `get_taper_abcd(...)` and `get_taper_s(...)` build the same transfer / - scattering objects for sampled continuously varying sections using local-mode - CMT. + scattering objects for sampled tapers using midpoint interfaces and stable + Redheffer cascading. This module is intentionally a thin library layer rather than an integrated simulation suite. It provides the overlap algebra that downstream users can @@ -23,6 +23,7 @@ compose into larger workflows. """ from collections.abc import Sequence +from typing import Literal import dataclasses import operator import numpy @@ -31,14 +32,13 @@ from scipy import linalg from scipy import sparse from ..fdmath import dx_lists2_t, vcfdfield2 -from .waveguide_2d import inner_product -from .waveguide_2d_complex import ComplexModeBasis +from .waveguide_2d import ModeBasis, inner_product, lorentz_overlap type wavenumber_seq = Sequence[complex] | NDArray[numpy.complexfloating] | NDArray[numpy.floating] type mode_seq = Sequence[Sequence[vcfdfield2]] -@dataclasses.dataclass(frozen=True) +@dataclasses.dataclass(frozen=True, eq=False) class ModeSection: """ Local modal basis at one longitudinal sample of a continuously varying guide. @@ -63,12 +63,12 @@ class ModeSection: """ z: float - modes: mode_seq + modes: mode_seq = dataclasses.field(repr=False) wavenumbers: wavenumber_seq - backward_modes: mode_seq | None = None + backward_modes: mode_seq | None = dataclasses.field(default=None, repr=False) backward_wavenumbers: wavenumber_seq | None = None - dual_modes: mode_seq | None = None - dual_backward_modes: mode_seq | None = None + dual_modes: mode_seq | None = dataclasses.field(default=None, repr=False) + dual_backward_modes: mode_seq | None = dataclasses.field(default=None, repr=False) wavenumber_matrix: NDArray[numpy.complex128] | None = None backward_wavenumber_matrix: NDArray[numpy.complex128] | None = None @@ -155,52 +155,26 @@ def _as_mode_arrays( ) -> list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]: """Copy a modal sequence into complex NumPy array views.""" return [ - (numpy.asarray(e_field, dtype=complex), numpy.asarray(h_field, dtype=complex)) + (numpy.array(e_field, dtype=complex, copy=True), numpy.array(h_field, dtype=complex, copy=True)) for e_field, h_field in ehs ] -def _lorentz_overlap( - mode_a: tuple[vcfdfield2, vcfdfield2], - mode_b: tuple[vcfdfield2, vcfdfield2], - dxes: dx_lists2_t, - ) -> complex: - """Evaluate the symmetric unconjugated Lorentz overlap of two modes.""" - e_a, h_a = mode_a - e_b, h_b = mode_b - return 0.5 * ( - inner_product(e_a, h_b, dxes=dxes, conj_h=False) - + inner_product(e_b, h_a, dxes=dxes, conj_h=False) - ) - - -def _lorentz_derivative_overlap( - mode_a: tuple[vcfdfield2, vcfdfield2], - derivative_b: tuple[vcfdfield2, vcfdfield2], - dxes: dx_lists2_t, - ) -> complex: - """Project a modal derivative through the symmetric Lorentz form.""" - e_a, h_a = mode_a - de_b, dh_b = derivative_b - return 0.5 * ( - inner_product(e_a, dh_b, dxes=dxes, conj_h=False) - + inner_product(de_b, h_a, dxes=dxes, conj_h=False) - ) - - def _phase_align_modes( previous: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]], current: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]], dxes: dx_lists2_t, previous_dual: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]] | None = None, + *, + measure: Literal['physical', 'stretched'] = 'stretched', ) -> tuple[list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]], NDArray[numpy.complex128]]: """Align current modal phases and return their diagonal basis transform.""" aligned = [] scales = [] test_modes = previous if previous_dual is None else previous_dual for index, (previous_mode, current_mode, test_mode) in enumerate(zip(previous, current, test_modes, strict=True)): - overlap = _lorentz_overlap(test_mode, current_mode, dxes) - self_overlap = _lorentz_overlap(test_mode, previous_mode, dxes) + overlap = lorentz_overlap(test_mode, current_mode, dxes, measure=measure) + self_overlap = lorentz_overlap(test_mode, previous_mode, dxes, measure=measure) if overlap == 0: raise ValueError(f'cannot phase-track mode {index}: adjacent section overlap is zero') if self_overlap == 0: @@ -314,12 +288,15 @@ def _section_branches( def _validate_taper_sections( sections: Sequence[ModeSection], dxes: dx_lists2_t, + *, + measure: Literal['physical', 'stretched'] = 'stretched', ) -> tuple[ NDArray[numpy.float64], list[list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]], list[list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]], list[NDArray[numpy.complex128]], int, + list[NDArray[numpy.complex128]], ]: """Validate, phase-align, and assemble all taper branch data.""" if len(sections) < 2: @@ -351,10 +328,12 @@ def _validate_taper_sections( if not (numpy.diff(z_array) > 0).all(): raise ValueError('taper section z coordinates must be strictly increasing') + transforms = [numpy.eye(2 * expected_count, dtype=complex) for _ in sections] for index in range(1, len(branch_modes)): branch_modes[index], transform = _phase_align_modes( - branch_modes[index - 1], branch_modes[index], dxes, branch_dual_modes[index - 1], + branch_modes[index - 1], branch_modes[index], dxes, branch_dual_modes[index - 1], measure=measure, ) + transforms[index] = transform inverse = numpy.linalg.inv(transform) branch_dual_modes[index] = [ (e_field * inverse[row, row], h_field * inverse[row, row]) @@ -363,54 +342,198 @@ def _validate_taper_sections( branch_wavenumbers[index] = inverse @ branch_wavenumbers[index] @ transform assert expected_count is not None - return z_array, branch_modes, branch_dual_modes, branch_wavenumbers, expected_count + return z_array, branch_modes, branch_dual_modes, branch_wavenumbers, expected_count, transforms -def _taper_interval_generator( - left_modes: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]], - left_dual_modes: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]], - right_modes: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]], - left_wavenumbers: NDArray[numpy.complex128], - right_wavenumbers: NDArray[numpy.complex128], - dz: float, +def _interface_s( + left: mode_seq, + right: mode_seq, + left_dual: mode_seq, + right_dual: mode_seq, dxes: dx_lists2_t, + *, + formulation: Literal['mixed', 'galerkin'] = 'mixed', + measure: Literal['physical', 'stretched'] = 'stretched', ) -> NDArray[numpy.complex128]: - """Build the local coupled-mode generator for one sampled interval.""" - mode_count = len(left_modes) - gram = numpy.zeros((mode_count, mode_count), dtype=complex) - derivative_overlap = numpy.zeros((mode_count, mode_count), dtype=complex) + """Solve projected tangential continuity for both incidences together. - for row, left_row_mode in enumerate(left_dual_modes): - for col, left_col_mode in enumerate(left_modes): - gram[row, col] = _lorentz_overlap(left_row_mode, left_col_mode, dxes) - for col, (left_col_mode, right_col_mode) in enumerate(zip(left_modes, right_modes, strict=True)): - derivative = ( - (right_col_mode[0] - left_col_mode[0]) / dz, - (right_col_mode[1] - left_col_mode[1]) / dz, - ) - derivative_overlap[row, col] = _lorentz_derivative_overlap(left_row_mode, derivative, dxes) + Branches are ordered [forward, backward]. Mixed testing uses left electric + duals on H continuity and right magnetic duals on E continuity. In contrast, + Galerkin tests both equations against the left dual basis. + """ + nl, nr = len(left) // 2, len(right) // 2 + if formulation not in ('mixed', 'galerkin'): + raise ValueError("formulation must be 'mixed' or 'galerkin'") + if formulation == 'galerkin' and nl != nr: + raise ValueError('Galerkin interfaces require equal port mode counts') + electric_tests = left_dual[:nl] + magnetic_tests = right_dual[:nr] if formulation == 'mixed' else left_dual[:nl] - coupling = numpy.linalg.pinv(gram) @ derivative_overlap - propagation = -1j * 0.5 * (left_wavenumbers + right_wavenumbers) - return propagation - coupling + def h_projection(modes: mode_seq) -> NDArray[numpy.complex128]: + return numpy.asarray([ + [inner_product(test[0], mode[1], dxes, measure=measure) for mode in modes] + for test in electric_tests + ]) + + def e_projection(modes: mode_seq) -> NDArray[numpy.complex128]: + return numpy.asarray([ + [inner_product(mode[0], test[1], dxes, measure=measure) for mode in modes] + for test in magnetic_tests + ]) + + hl, hr = h_projection(left), h_projection(right) + el, er = e_projection(left), e_projection(right) + outgoing = numpy.block([[hl[:, nl:], -hr[:, :nr]], + [el[:, nl:], -er[:, :nr]]]) + incoming = numpy.block([[-hl[:, :nl], hr[:, nr:]], + [-el[:, :nl], er[:, nr:]]]) + return _solve(outgoing, incoming, 'projected interface continuity') -def _abcd_to_s( - abcd: NDArray[numpy.complex128], - n_modes: int, +def _solve(matrix: NDArray, rhs: NDArray, context: str) -> NDArray[numpy.complex128]: + """Solve without rank truncation or hidden regularization.""" + if not numpy.isfinite(matrix).all() or not numpy.isfinite(rhs).all(): + raise ValueError(f'{context} requires finite matrices') + try: + result = numpy.linalg.solve(matrix, rhs) + except numpy.linalg.LinAlgError as error: + raise numpy.linalg.LinAlgError(f'{context}: singular system') from error + if not numpy.isfinite(result).all(): + raise numpy.linalg.LinAlgError(f'{context}: result is not representable') + return result + + +def _two_port_matrix(matrix: NDArray | sparse.sparray, n_modes: int | None) -> tuple[NDArray, int]: + array = numpy.asarray(matrix.toarray() if sparse.issparse(matrix) else matrix, dtype=complex) + if array.ndim != 2 or array.shape[0] != array.shape[1] or array.shape[0] % 2: + raise ValueError('transfer conversion requires a square matrix with equal port mode counts') + n = array.shape[0] // 2 + if n == 0 or (n_modes is not None and n_modes != n): + raise ValueError('n_modes must match the nonempty equal-sized matrix blocks') + if not numpy.isfinite(array).all(): + raise ValueError('matrix must contain only finite values') + return array, n + + +def abcd_to_s(abcd: NDArray | sparse.sparray, n_modes: int | None = None) -> NDArray[numpy.complex128]: + """Convert [f_R, b_R] = ABCD @ [f_L, b_L] to standard port scattering. + + Requires equal port counts and an invertible D block. Singular systems + raise ``LinAlgError`` rather than silently using a pseudoinverse. + """ + matrix, n = _two_port_matrix(abcd, n_modes) + a, b, c, d = matrix[:n, :n], matrix[:n, n:], matrix[n:, :n], matrix[n:, n:] + solved = _solve(d, numpy.concatenate((c, numpy.eye(n)), axis=1), 'ABCD to S conversion') + dc, di = solved[:, :n], solved[:, n:] + result = numpy.block([[-dc, di], [a - b @ dc, b @ di]]) + if not numpy.isfinite(result).all(): + raise numpy.linalg.LinAlgError('ABCD to S conversion: result is not representable') + return result + + +def s_to_abcd(ss: NDArray, n_modes: int | None = None) -> NDArray[numpy.complex128]: + """Convert standard port scattering to [f_R, b_R] = ABCD @ [f_L, b_L]. + + Requires equal port counts and invertible right-to-left transmission. + Strongly attenuating devices can have finite S but unrepresentable ABCD. + """ + matrix, n = _two_port_matrix(ss, n_modes) + rl, trl, tlr, rr = matrix[:n, :n], matrix[:n, n:], matrix[n:, :n], matrix[n:, n:] + solved = _solve(trl, numpy.concatenate((rl, numpy.eye(n)), axis=1), 'S to ABCD conversion') + tr, ti = solved[:, :n], solved[:, n:] + result = numpy.block([[tlr - rr @ tr, rr @ ti], [-tr, ti]]) + if not numpy.isfinite(result).all(): + raise numpy.linalg.LinAlgError('S to ABCD conversion: result is not representable') + return result + + +def cascade_s(left: NDArray, right: NDArray, *, internal_modes: int) -> NDArray[numpy.complex128]: + """Cascade spatially ordered two-port S matrices using the Redheffer product. + + ``internal_modes`` is the common right port of ``left`` and left port of + ``right``. External port counts may differ. Matrices use outgoing rows and + incoming columns in [left, right] order. No transfer matrix is constructed. + """ + try: + k = operator.index(internal_modes) + except TypeError as error: + raise ValueError('internal_modes must be a positive integer') from error + if isinstance(internal_modes, bool | numpy.bool_) or k <= 0: + raise ValueError('internal_modes must be a positive integer') + left, right = numpy.asarray(left, dtype=complex), numpy.asarray(right, dtype=complex) + for matrix in (left, right): + if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1] or matrix.shape[0] <= k: + raise ValueError('each S matrix must be square with a nonempty external port') + if not numpy.isfinite(matrix).all(): + raise ValueError('S matrices must contain only finite values') + n = left.shape[0] - k + ral, tarl, talr, rar = left[:n, :n], left[:n, n:], left[n:, :n], left[n:, n:] + rbl, tbrl, tblr, rbr = right[:k, :k], right[:k, k:], right[k:, :k], right[k:, k:] + rhs = numpy.concatenate((talr, rar @ tbrl), axis=1) + solved = _solve(numpy.eye(k) - rar @ rbl, rhs, 'Redheffer internal feedback') + x, y = solved[:, :n], solved[:, n:] + result = numpy.block([[ral + tarl @ rbl @ x, tarl @ (tbrl + rbl @ y)], + [tblr @ x, rbr + tblr @ y]]) + if not numpy.isfinite(result).all(): + raise numpy.linalg.LinAlgError('Redheffer composition: result is not representable') + return result + + +def _postprocess_s(ss: NDArray, force_nogain: bool, force_reciprocal: bool) -> NDArray[numpy.complex128]: + if force_nogain: + u, singular, vh = numpy.linalg.svd(ss) + ss = (u * numpy.minimum(singular, 1)) @ vh + if force_reciprocal: + ss = 0.5 * (ss + ss.T) + return ss + + +def get_s( + ehLs: mode_seq, + wavenumbers_L: wavenumber_seq, + ehRs: mode_seq, + wavenumbers_R: wavenumber_seq, + dxes: dx_lists2_t, + *, + formulation: Literal['mixed', 'galerkin'] = 'mixed', + measure: Literal['physical', 'stretched'] = 'stretched', + force_nogain: bool = False, + force_reciprocal: bool = False, + dual_ehLs: mode_seq | None = None, + dual_ehRs: mode_seq | None = None, ) -> NDArray[numpy.complex128]: - """Convert a block transfer matrix to the corresponding scattering matrix.""" - A = abcd[:n_modes, :n_modes] - B = abcd[:n_modes, n_modes:] - C = abcd[n_modes:, :n_modes] - D = abcd[n_modes:, n_modes:] - D_inv = numpy.linalg.pinv(D) - r12 = -D_inv @ C - t21 = D_inv - t12 = A - B @ D_inv @ C - r21 = B @ D_inv - return numpy.block([[r12, t12], - [t21, r21]]) + """Solve an interface in standard [left, right] scattering order. + + ``S = [[R_L, T_R_to_L], [T_L_to_R, R_R]]`` maps incoming to outgoing + amplitudes. Mixed testing is the default and supports unequal port counts. + Galerkin testing requires equal counts. Both use full projection matrices. + + ``dxes`` and ``measure`` must match the supplied modal duals; omitted duals + use the primal fields. Wavenumbers label the modes and are not used in the + interface continuity equations. Forward modes imply backward (E, -H). + + ``force_nogain`` caps Euclidean singular values; ``force_reciprocal`` + symmetrizes S. These optional numerical projections are not general power + or reciprocity tests for open, evanescent, or non-power-normalized bases. + No such projection is applied by default. + """ + shape, _ = _validate_port_modes('ehLs', ehLs, wavenumbers_L) + right_shape, _ = _validate_port_modes('ehRs', ehRs, wavenumbers_R) + if shape != right_shape: + raise ValueError('left and right modal fields must share the same E/H shapes') + _as_wavenumber_array('wavenumbers_L', wavenumbers_L) + _as_wavenumber_array('wavenumbers_R', wavenumbers_R) + dl = _validate_dual_modes('dual_ehLs', dual_ehLs, shape, wavenumbers_L) + dr = _validate_dual_modes('dual_ehRs', dual_ehRs, shape, wavenumbers_R) + left, right = _as_mode_arrays(ehLs), _as_mode_arrays(ehRs) + left_dual = left if dl is None else _as_mode_arrays(dl) + right_dual = right if dr is None else _as_mode_arrays(dr) + ss = _interface_s( + [*left, *((e, -h) for e, h in left)], + [*right, *((e, -h) for e, h in right)], + left_dual, right_dual, dxes, formulation=formulation, measure=measure, + ) + return _postprocess_s(ss, force_nogain, force_reciprocal) def get_tr( @@ -419,61 +542,23 @@ def get_tr( ehRs: mode_seq, wavenumbers_R: wavenumber_seq, dxes: dx_lists2_t, + *, dual_ehLs: mode_seq | None = None, + dual_ehRs: mode_seq | None = None, + formulation: Literal['mixed', 'galerkin'] = 'mixed', + measure: Literal['physical', 'stretched'] = 'stretched', ) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]: + """Return (T_L_to_R, R_L) from the shared two-sided continuity solve. + + Unlike the old implementation, transmission singular values are never + modified. Galerkin truncation can therefore expose apparent gain; mixed + testing is the default. Dual modes and the overlap measure follow get_s. """ - Compute left-incident transmission and reflection matrices. - - Args: - ehLs: Left-port modes as `(E, H)` field pairs. - wavenumbers_L: Propagation constants for `ehLs`. - ehRs: Right-port modes as `(E, H)` field pairs. - wavenumbers_R: Propagation constants for `ehRs`. - dxes: Two-dimensional Yee-cell edge lengths for the shared port plane. - dual_ehLs: Optional left-port dual / adjoint projection modes. If - omitted, `ehLs` are used as their own projection basis. - - Returns: - `(T12, R12)` where columns index left-incident modes and rows index - outgoing right-going / left-going modes respectively. - - Raises: - ValueError: If the port mode lists are empty, malformed, or defined on - incompatible field shapes. - """ - left_e_shape, left_h_shape = _validate_port_modes('ehLs', ehLs, wavenumbers_L) - right_e_shape, right_h_shape = _validate_port_modes('ehRs', ehRs, wavenumbers_R) - if left_e_shape != right_e_shape or left_h_shape != right_h_shape: - raise ValueError('left and right modal fields must share the same E/H shapes') - dual_projection_ehLs = _validate_dual_modes('dual_ehLs', dual_ehLs, left_e_shape, wavenumbers_L) - projection_ehLs = ehLs if dual_projection_ehLs is None else dual_projection_ehLs - - nL = len(wavenumbers_L) - nR = len(wavenumbers_R) - A12 = numpy.zeros((nL, nR), dtype=complex) - A21 = numpy.zeros((nL, nR), dtype=complex) - B11 = numpy.zeros((nL,), dtype=complex) - for ll in range(nL): - eL, hL = ehLs[ll] - eP, hP = projection_ehLs[ll] - B11[ll] = inner_product(eL, hP, dxes=dxes, conj_h=False) - for rr in range(nR): - eR, hR = ehRs[rr] - A12[ll, rr] = inner_product(eP, hR, dxes=dxes, conj_h=False) # TODO optimize loop? - A21[ll, rr] = inner_product(eR, hP, dxes=dxes, conj_h=False) - - # tt0 = 2 * numpy.linalg.pinv(A21 + numpy.conj(A12)) - tt0, _resid, _rank, _sing = numpy.linalg.lstsq(A21 + A12, numpy.diag(2 * B11), rcond=None) - - U, st, V = numpy.linalg.svd(tt0) - gain = st > 1 - st[gain] = 1 / st[gain] - tt = U @ numpy.diag(st) @ V - - # rr = 0.5 * (A21 - numpy.conj(A12)) @ tt - rr = numpy.diag(0.5 / B11) @ (A21 - A12) @ tt - - return tt, rr + ss = get_s(ehLs, wavenumbers_L, ehRs, wavenumbers_R, dxes, + dual_ehLs=dual_ehLs, dual_ehRs=dual_ehRs, + formulation=formulation, measure=measure) + n = len(ehLs) + return ss[n:, :n], ss[:n, :n] def get_abcd( @@ -481,175 +566,201 @@ def get_abcd( wavenumbers_L: wavenumber_seq, ehRs: mode_seq, wavenumbers_R: wavenumber_seq, + dxes: dx_lists2_t, + *, dual_ehLs: mode_seq | None = None, dual_ehRs: mode_seq | None = None, - **kwargs, + formulation: Literal['mixed', 'galerkin'] = 'mixed', + measure: Literal['physical', 'stretched'] = 'stretched', ) -> sparse.sparray: + """Return [f_R, b_R] = ABCD @ [f_L, b_L] for an equal-count interface. + + Prefer get_s for unequal ports or strongly attenuating devices. """ - Build the 2-port block transfer matrix for an interface. + if len(ehLs) != len(ehRs): + raise ValueError('transfer conversion requires equal port mode counts') + ss = get_s(ehLs, wavenumbers_L, ehRs, wavenumbers_R, dxes, + dual_ehLs=dual_ehLs, dual_ehRs=dual_ehRs, + formulation=formulation, measure=measure) + return sparse.csr_array(s_to_abcd(ss)) - The blocks are assembled from the forward and reverse `get_tr(...)` - solutions using the standard - `[[A, B], [C, D]] = [[T12 - R21 T21^-1 R12, R21 T21^-1], [-T21^-1 R12, T21^-1]]` +@dataclasses.dataclass(frozen=True, eq=False) +class PreparedTaper: + """Read-only field-dependent data for repeated frozen-basis evaluation. - convention. + Arrays are copied and made read-only, including when constructed directly. + Beta matrices are stored in aligned bases. Evaluation overrides are in the + original section bases and receive the recorded similarity transforms. + Changing modal fields, grids, duals, or formulation requires preparation + again. Reusing these fields across wavelengths is an approximation. """ - t12, r12 = get_tr(ehLs, wavenumbers_L, ehRs, wavenumbers_R, dual_ehLs=dual_ehLs, **kwargs) - t21, r21 = get_tr(ehRs, wavenumbers_R, ehLs, wavenumbers_L, dual_ehLs=dual_ehRs, **kwargs) - t21i = numpy.linalg.pinv(t21) - A = t12 - r21 @ t21i @ r12 - B = r21 @ t21i - C = -t21i @ r12 - D = t21i - return sparse.block_array( - [ - [sparse.csr_array(A), sparse.csr_array(B)], - [sparse.csr_array(C), sparse.csr_array(D)], - ], - format='csr', - ) + + z_coords: NDArray[numpy.float64] + interfaces: NDArray[numpy.complex128] = dataclasses.field(repr=False) + wavenumber_matrices: NDArray[numpy.complex128] = dataclasses.field(repr=False) + backward_wavenumber_matrices: NDArray[numpy.complex128] = dataclasses.field(repr=False) + basis_transforms: NDArray[numpy.complex128] = dataclasses.field(repr=False) + reciprocal_branches: tuple[bool, ...] + + def __post_init__(self) -> None: + for field in dataclasses.fields(self): + if field.name == 'reciprocal_branches': + object.__setattr__(self, field.name, tuple(self.reciprocal_branches)) + continue + value = numpy.array(getattr(self, field.name), dtype=float if field.name == 'z_coords' else complex, copy=True) + if not numpy.isfinite(value).all(): + raise ValueError(f'{field.name} must contain only finite values') + value.flags.writeable = False + object.__setattr__(self, field.name, value) + z, beta = self.z_coords, self.wavenumber_matrices + if z.ndim != 1 or z.size < 2 or not (numpy.diff(z) > 0).all(): + raise ValueError('z_coords must contain at least two strictly increasing finite coordinates') + if beta.ndim != 3 or beta.shape[0] != z.size or beta.shape[1] != beta.shape[2] or beta.shape[1] == 0: + raise ValueError('wavenumber_matrices must have shape (section, mode, mode)') + n = beta.shape[1] + if self.backward_wavenumber_matrices.shape != beta.shape: + raise ValueError('backward matrix shape must match forward matrices') + if self.interfaces.shape != (z.size - 1, 2 * n, 2 * n): + raise ValueError('interface shape must match section and mode counts') + if self.basis_transforms.shape != (z.size, 2 * n, 2 * n): + raise ValueError('basis transform shape must match section and mode counts') + if len(self.reciprocal_branches) != z.size: + raise ValueError('reciprocal branch flags must match section count') + + @property + def n_modes(self) -> int: + """Number of modes in either external port.""" + return self.wavenumber_matrices.shape[1] -def get_s( - ehLs: mode_seq, - wavenumbers_L: wavenumber_seq, - ehRs: mode_seq, - wavenumbers_R: wavenumber_seq, - force_nogain: bool = False, - force_reciprocal: bool = False, - dual_ehLs: mode_seq | None = None, - dual_ehRs: mode_seq | None = None, - **kwargs, - ) -> NDArray[numpy.complex128]: - """ - Build the full block scattering matrix for a two-sided interface. - - The returned matrix is ordered as `[[R12, T12], [T21, R21]]`, where the - first block-row/column corresponds to the left port and the second to the - right port. - - Args: - force_nogain: If `True`, clamp singular values of the assembled - scattering matrix to at most one. - force_reciprocal: If `True`, symmetrize the assembled matrix as - `0.5 * (S + S.T)`. - dual_ehLs: Optional left-port dual / adjoint projection modes. - dual_ehRs: Optional right-port dual / adjoint projection modes. - """ - t12, r12 = get_tr(ehLs, wavenumbers_L, ehRs, wavenumbers_R, dual_ehLs=dual_ehLs, **kwargs) - t21, r21 = get_tr(ehRs, wavenumbers_R, ehLs, wavenumbers_L, dual_ehLs=dual_ehRs, **kwargs) - - ss = numpy.block([[r12, t12], - [t21, r21]]) - - if force_nogain: - # force S @ S.H diagonal - U, sing, Vh = numpy.linalg.svd(ss) - ss = U @ numpy.diag(numpy.minimum(sing, 1.0)) @ Vh - - if force_reciprocal: - ss = 0.5 * (ss + ss.T) - - return ss - - -def get_taper_abcd( +def prepare_taper( sections: Sequence[ModeSection], dxes: dx_lists2_t, *, - rtol: float = 1e-7, - atol: float = 1e-9, - max_step: float | None = None, - ) -> sparse.sparray: + formulation: Literal['mixed', 'galerkin'] = 'mixed', + measure: Literal['physical', 'stretched'] = 'stretched', + ) -> PreparedTaper: + """Validate, align, and cache midpoint interfaces for sampled sections. + + Section mode counts must agree. Fields and duals must already use a + consistent normalization and measure. No eigensolver is invoked. """ - Build a bidirectional transfer matrix for a continuously varying taper. + z, modes, duals, beta, n, transforms = _validate_taper_sections(sections, dxes, measure=measure) + interfaces = [ + _interface_s(modes[i], modes[i + 1], duals[i], duals[i + 1], dxes, + formulation=formulation, measure=measure) + for i in range(len(sections) - 1) + ] + return PreparedTaper( + z, numpy.asarray(interfaces), numpy.asarray([b[:n, :n] for b in beta]), + numpy.asarray([b[n:, n:] for b in beta]), numpy.asarray(transforms), + tuple(section.backward_modes is None for section in sections), + ) - The taper is represented by local modal bases sampled at increasing `z` - coordinates. Adjacent modal phases are tracked with the same non-conjugated - Lorentz/Poynting overlap used by the abrupt-interface helpers, then each - interval is propagated with a finite-difference local-mode CMT generator. - If a `ModeSection` supplies dual / adjoint modes, those modes are used for - the CMT projection. This supports leaky or radiative mode sets whose natural - projection basis is biorthogonal rather than self-projected. - Args: - sections: Local modal samples ordered by increasing `z`. - dxes: Two-dimensional Yee-cell edge lengths for the shared port plane. - rtol: Relative tolerance reserved for future adaptive CMT integrators. - Must be positive. - atol: Absolute tolerance reserved for future adaptive CMT integrators. - Must be positive. - max_step: Optional maximum matrix-exponential step inside each sampled - interval. This does not change the piecewise-constant interval - generator, but can improve conditioning for long intervals. +def _propagation_s(forward: NDArray, backward: NDArray, length: float) -> NDArray[numpy.complex128]: + n = forward.shape[0] + zero = numpy.zeros((n, n), dtype=complex) + # Backward propagation is evaluated toward decreasing z, avoiding the + # exponentially growing transfer branch of passive leaky modes. + result = numpy.block([[zero, linalg.expm(1j * backward * length)], + [linalg.expm(-1j * forward * length), zero]]) + if not numpy.isfinite(result).all(): + raise numpy.linalg.LinAlgError('propagation S matrix is not representable') + return result - Returns: - Sparse block transfer matrix ordered as `[forward, backward]`. + +def evaluate_taper_s( + prepared: PreparedTaper, + *, + z_coords: Sequence[float] | None = None, + wavenumber_matrices: NDArray | None = None, + backward_wavenumber_matrices: NDArray | None = None, + force_nogain: bool = False, + force_reciprocal: bool = False, + ) -> NDArray[numpy.complex128]: + """Evaluate cached interfaces with optional lengths and beta overrides. + + Overrides have shape (section, mode, mode) in the original section bases. + Forward overrides synthesize negative backward beta only for reciprocal + branches; explicit branches require backward overrides as well. This call + performs no field overlaps or mode solves. It is suitable for frozen-basis + wavelength approximations and exact length sweeps within the sampled model. """ - if rtol <= 0: - raise ValueError('rtol must be positive') - if atol <= 0: - raise ValueError('atol must be positive') - if max_step is not None and max_step <= 0: - raise ValueError('max_step must be positive') + z = prepared.z_coords if z_coords is None else numpy.asarray(z_coords, dtype=float) + if z.shape != prepared.z_coords.shape or not numpy.isfinite(z).all() or not (numpy.diff(z) > 0).all(): + raise ValueError('z_coords must match section count and be finite and strictly increasing') + n = prepared.n_modes - z_coords, branch_modes, branch_dual_modes, branch_wavenumbers, n_modes = _validate_taper_sections(sections, dxes) - branch_count = 2 * n_modes - transfer = numpy.eye(branch_count, dtype=complex) + def override(value: NDArray | None, default: NDArray, start: int) -> NDArray: + if value is None: + return default + value = numpy.asarray(value, dtype=complex) + if value.shape != default.shape or not numpy.isfinite(value).all(): + raise ValueError('beta overrides must be finite with shape (section, mode, mode)') + transforms = prepared.basis_transforms[:, start:start + n, start:start + n] + return numpy.asarray([_solve(t, b @ t, 'beta basis transform') for t, b in zip(transforms, value, strict=True)]) - for index, dz in enumerate(numpy.diff(z_coords)): - generator = _taper_interval_generator( - branch_modes[index], - branch_dual_modes[index], - branch_modes[index + 1], - branch_wavenumbers[index], - branch_wavenumbers[index + 1], - float(dz), - dxes, - ) - step_count = 1 if max_step is None else max(1, int(numpy.ceil(dz / max_step))) - interval_transfer = linalg.expm(generator * (dz / step_count)) - for _step in range(step_count): - transfer = interval_transfer @ transfer - - return sparse.csr_array(transfer) + forward = override(wavenumber_matrices, prepared.wavenumber_matrices, 0) + if wavenumber_matrices is not None and backward_wavenumber_matrices is None: + if not all(prepared.reciprocal_branches): + raise ValueError('forward overrides require backward overrides for explicit backward branches') + # Forward and backward phase tracking may choose different gauges. + backward_wavenumber_matrices = -numpy.asarray(wavenumber_matrices) + backward = override(backward_wavenumber_matrices, prepared.backward_wavenumber_matrices, n) + total = _propagation_s(forward[0], backward[0], 0) + for index, length in enumerate(numpy.diff(z)): + for ss in ( + _propagation_s(forward[index], backward[index], length / 2), + prepared.interfaces[index], + _propagation_s(forward[index + 1], backward[index + 1], length / 2), + ): + total = cascade_s(total, ss, internal_modes=n) + return _postprocess_s(total, force_nogain, force_reciprocal) def get_taper_s( sections: Sequence[ModeSection], dxes: dx_lists2_t, *, + formulation: Literal['mixed', 'galerkin'] = 'mixed', + measure: Literal['physical', 'stretched'] = 'stretched', force_nogain: bool = False, force_reciprocal: bool = False, - **kwargs, ) -> NDArray[numpy.complex128]: + """Sampled-taper S matrix using midpoint interfaces and Redheffer cascading. + + Each interval propagates half its length in either adjacent basis. Accuracy + is controlled by section sampling and basis/PML convergence, not ODE + tolerances. Standard outgoing/incoming [left, right] ordering is used. """ - Build the full block scattering matrix for a continuously varying taper. + prepared = prepare_taper(sections, dxes, formulation=formulation, measure=measure) + return evaluate_taper_s(prepared, force_nogain=force_nogain, force_reciprocal=force_reciprocal) - The returned matrix uses the same ordering as `get_s(...)`: - `[[R12, T12], [T21, R21]]`. + +def get_taper_abcd( + sections: Sequence[ModeSection], + dxes: dx_lists2_t, + *, + formulation: Literal['mixed', 'galerkin'] = 'mixed', + measure: Literal['physical', 'stretched'] = 'stretched', + ) -> sparse.sparray: + """Convert sampled-taper S to ABCD, where representable. + + Prefer get_taper_s for long lossy devices. Zero transmission or an + unrepresentable transfer matrix raises LinAlgError. The removed rtol, atol, + and max_step arguments never refined the former sampled CMT model. """ - _z_coords, _branch_modes, _branch_dual_modes, _branch_wavenumbers, n_modes = _validate_taper_sections(sections, dxes) - abcd = get_taper_abcd(sections, dxes, **kwargs).toarray() - ss = _abcd_to_s(abcd, n_modes) - - if force_nogain: - U, sing, Vh = numpy.linalg.svd(ss) - ss = U @ numpy.diag(numpy.minimum(sing, 1.0)) @ Vh - - if force_reciprocal: - ss = 0.5 * (ss + ss.T) - - return ss + return sparse.csr_array(s_to_abcd(get_taper_s(sections, dxes, formulation=formulation, measure=measure))) def mode_sections_from_bases( z_coords: Sequence[float], - bases: Sequence[ComplexModeBasis], + bases: Sequence[ModeBasis], *, mode_indices: Sequence[int] | None = None, - backward_bases: Sequence[ComplexModeBasis] | None = None, + backward_bases: Sequence[ModeBasis] | None = None, coupling_rtol: float = 1e-10, ) -> tuple[ModeSection, ...]: """Convert tracked complex-mode bases into EME taper sections. @@ -728,8 +839,14 @@ def mode_sections_from_bases( if numpy.any(indices >= len(basis)): raise ValueError(f'mode_indices are out of range for bases[{section_index}]') validate_invariant_subset(basis.wavenumber_matrix, len(basis), f'bases[{section_index}]') - modes = [basis.modes[index] for index in indices] - dual_modes = [basis.dual_modes[index] for index in indices] + modes = [ + (basis.electric_fields[index, 0], basis.magnetic_fields[index, 0]) + for index in indices + ] + dual_modes = [ + (basis.electric_fields[index, 1], basis.magnetic_fields[index, 1]) + for index in indices + ] wavenumbers = basis.wavenumbers[indices] wavenumber_matrix = basis.wavenumber_matrix[numpy.ix_(indices, indices)] if backward_bases is None: @@ -754,10 +871,16 @@ def mode_sections_from_bases( z=float(z_coord), modes=modes, wavenumbers=wavenumbers, - backward_modes=[backward_basis.modes[index] for index in indices], + backward_modes=[ + (backward_basis.electric_fields[index, 0], backward_basis.magnetic_fields[index, 0]) + for index in indices + ], backward_wavenumbers=backward_basis.wavenumbers[indices], dual_modes=dual_modes, - dual_backward_modes=[backward_basis.dual_modes[index] for index in indices], + dual_backward_modes=[ + (backward_basis.electric_fields[index, 1], backward_basis.magnetic_fields[index, 1]) + for index in indices + ], wavenumber_matrix=wavenumber_matrix, backward_wavenumber_matrix=backward_basis.wavenumber_matrix[numpy.ix_(indices, indices)], )) diff --git a/meanas/fdfd/scpml.py b/meanas/fdfd/scpml.py index 7030876..acbcbdd 100644 --- a/meanas/fdfd/scpml.py +++ b/meanas/fdfd/scpml.py @@ -38,132 +38,146 @@ def prepare_s_function( return s_factor +def _stretch_factors( + distance: NDArray, + correction: float, + s_function: s_function_t, + kappa_function: s_function_t | None, + max_imag_ratio: float | None, + ) -> NDArray[numpy.complex128]: + """Evaluate the final dimensionless coordinate stretch.""" + kappa = numpy.asarray(1.0 if kappa_function is None else kappa_function(distance)) + sigma = numpy.asarray(s_function(distance)) / correction + if numpy.iscomplexobj(kappa) or not numpy.isfinite(kappa).all() or (kappa < 1).any(): + raise ValueError('kappa_function must return finite real stretches at least one') + if numpy.iscomplexobj(sigma) or not numpy.isfinite(sigma).all(): + raise ValueError('s_function must return finite real values') + if max_imag_ratio is not None: + sigma = numpy.clip(sigma, -max_imag_ratio * kappa, max_imag_ratio * kappa) + return kappa + 1j * sigma + + +def _validate_profile(omega: float, epsilon_effective: float, max_imag_ratio: float | None) -> None: + if not numpy.isfinite(omega) or not numpy.isreal(omega) or omega <= 0: + raise ValueError('omega must be finite, real, and positive') + if not numpy.isfinite(epsilon_effective) or epsilon_effective <= 0: + raise ValueError('epsilon_effective must be finite and positive') + if max_imag_ratio is not None and (not numpy.isfinite(max_imag_ratio) or max_imag_ratio <= 0): + raise ValueError('max_imag_ratio must be finite and positive') + + def uniform_grid_scpml( shape: Sequence[int], thicknesses: Sequence[int], omega: float, epsilon_effective: float = 1.0, s_function: s_function_t | None = None, - ) -> list[list[NDArray[numpy.float64]]]: - """ - Create dx arrays for a uniform grid with a cell width of 1 and a pml. + *, + kappa_function: s_function_t | None = None, + max_imag_ratio: float | None = None, + ) -> list[list[NDArray]]: + """Create unit-cell Yee widths with SCPML on both faces of each axis. - If you want something more fine-grained, check out `stretch_with_scpml(...)`. + ``shape`` and ``thicknesses`` contain three axis sizes and per-face PML + thicknesses; zero disables an axis. ``s_function`` specifies the imaginary + grading, defaulting to prepare_s_function(). ``kappa_function`` optionally + returns dimensionless real stretch values >= 1 at normalized depths. - Args: - shape: Shape of the grid, including the PMLs (which are 2*thicknesses thick) - thicknesses: `[th_x, th_y, th_z]` - Thickness of the PML in each direction. - Both polarities are added. - Each th_ of pml is applied twice, once on each edge of the grid along the given axis. - `th_*` may be zero, in which case no pml is added. - omega: Angular frequency for the simulation - epsilon_effective: Effective epsilon of the PML. Match this to the material - at the edge of your grid. - Default 1. - s_function: created by `prepare_s_function(...)`, allowing customization of pml parameters. - Default uses `prepare_s_function()` with no parameters. - - Returns: - Complex cell widths (dx_lists_mut) as discussed in `meanas.fdmath.types`. + ``max_imag_ratio`` optionally bounds abs(Im(s))/Re(s) after frequency + scaling. A value of 1 is a useful mode-solver starting point, not a proof + against spectral artifacts. Converge domain size, thickness, and strength; + reducing absorption can increase boundary reflections. The general + scattering profile remains uncapped by default. """ + _validate_profile(omega, epsilon_effective, max_imag_ratio) + shape, thicknesses = tuple(shape), tuple(thicknesses) + if len(shape) != 3 or len(thicknesses) != 3: + raise ValueError('shape and thicknesses must contain three entries') + if any(not isinstance(n, int | numpy.integer) or isinstance(n, bool) or n <= 0 for n in shape): + raise ValueError('shape entries must be positive integers') + if any(not isinstance(t, int | numpy.integer) or isinstance(t, bool) or t < 0 or 2 * t > n + for n, t in zip(shape, thicknesses, strict=True)): + raise ValueError('PML thicknesses must be nonnegative integers fitting both faces') if s_function is None: s_function = prepare_s_function() - - shape = tuple(shape) - thicknesses = tuple(thicknesses) - - # Normalized distance to nearest boundary - def ll(u: NDArray[numpy.float64], n: int, t: int) -> NDArray[numpy.float64]: - return ((t - u).clip(0) + (u - (n - t)).clip(0)) / t - - dx_a = [numpy.array(numpy.inf)] * 3 - dx_b = [numpy.array(numpy.inf)] * 3 - - # divide by this to adjust for epsilon_effective and omega - s_correction = numpy.sqrt(epsilon_effective) * numpy.real(omega) - - for k, th in enumerate(thicknesses): - s = shape[k] - if th > 0: - sr = numpy.arange(s, dtype=numpy.float64) - dx_a[k] = 1 + 1j * s_function(ll(sr, s, th)) / s_correction - dx_b[k] = 1 + 1j * s_function(ll(sr + 0.5, s, th)) / s_correction - else: - dx_a[k] = numpy.ones((s,)) - dx_b[k] = numpy.ones((s,)) - return [dx_a, dx_b] + result = [[numpy.ones(n) for n in shape] for _ in range(2)] + correction = numpy.sqrt(epsilon_effective) * numpy.real(omega) + for axis, (n, thickness) in enumerate(zip(shape, thicknesses, strict=True)): + if not thickness: + continue + for family, offset in enumerate((0.0, 0.5)): + position = numpy.arange(n, dtype=float) + offset + depth = ((thickness - position).clip(0) + (position - (n - thickness)).clip(0)) / thickness + factors = _stretch_factors(depth, correction, s_function, kappa_function, max_imag_ratio) + result[family][axis] = numpy.where(depth > 0, factors, 1.0) + return result def stretch_with_scpml( - dxes: list[list[NDArray[numpy.float64]]], + dxes: list[list[NDArray]], axis: int, polarity: int, omega: float, epsilon_effective: float = 1.0, thickness: int = 10, s_function: s_function_t | None = None, - ) -> list[list[NDArray[numpy.float64]]]: - """ - Stretch dxes to contain a stretched-coordinate PML (SCPML) in one direction along one axis. + *, + kappa_function: s_function_t | None = None, + max_imag_ratio: float | None = None, + copy: bool = True, + ) -> list[list[NDArray]]: + """Add SCPML to one face, returning independent widths by default. - Args: - dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` - axis: axis to stretch (0=x, 1=y, 2=z) - polarity: direction to stretch (-1 for -ve, +1 for +ve) - omega: Angular frequency for the simulation - epsilon_effective: Effective epsilon of the PML. Match this to the material at the - edge of your grid. Default 1. - thickness: number of cells to use for pml (default 10) - s_function: Created by `prepare_s_function(...)`, allowing customization - of pml parameters. Default uses `prepare_s_function()` with no parameters. + Args: + dxes: Yee widths [dx_e, dx_h]; two or three spatial axes are supported. + axis: Axis normal to the absorbing face. + polarity: -1 selects the low-index face; +1 selects the high-index face. + omega: Positive real angular frequency. + epsilon_effective: Positive effective permittivity at the boundary. + thickness: Number of cells on this face; zero is a no-op. + s_function: Imaginary grading at normalized depths, from prepare_s_function. + kappa_function: Optional dimensionless real stretch profile, >= 1. + max_imag_ratio: Optional final abs(Im(s))/Re(s) bound. A value of 1 is + a mode-solver starting configuration; check PML/domain convergence. + copy: Return copied widths by default. False explicitly mutates dxes. - Returns: - Complex cell widths (dx_lists_mut) as discussed in `meanas.fdmath.types`. - Multiple calls to this function may be necessary if multiple absorpbing boundaries are needed. + Coordinates and physical PML thickness are obtained from the real input + electric widths. Multiple nonoverlapping faces can be added successively. + The imaginary sign follows the exp(+i omega t) convention used by meanas. """ + _validate_profile(omega, epsilon_effective, max_imag_ratio) + if len(dxes) != 2 or len(dxes[0]) != len(dxes[1]) or axis not in range(len(dxes[0])): + raise ValueError('axis must select a spatial axis in both Yee width families') + if polarity not in (-1, 1): + raise ValueError('polarity must be -1 or +1') + if not isinstance(thickness, int | numpy.integer) or isinstance(thickness, bool): + raise TypeError('thickness must be an integer') + n = len(dxes[0][axis]) + if thickness < 0 or thickness > n or len(dxes[1][axis]) != n: + raise ValueError('thickness must fit the matching Yee width arrays') + widths = [[values.copy() for values in family] for family in dxes] if copy else dxes + if thickness == 0: + return widths if s_function is None: s_function = prepare_s_function() - - dx_ai = dxes[0][axis].astype(complex) - dx_bi = dxes[1][axis].astype(complex) - - if thickness == 0: - dxes[0][axis] = dx_ai - dxes[1][axis] = dx_bi - return dxes - - pos = numpy.hstack((0, dx_ai.cumsum())) - pos_a = (pos[:-1] + pos[1:]) / 2 - pos_b = pos[:-1] - - # divide by this to adjust for epsilon_effective and omega - s_correction = numpy.sqrt(epsilon_effective) * numpy.real(omega) - - if polarity > 0: - # front pml - bound = pos[thickness] - d = bound - pos[0] - - def l_d(x: NDArray[numpy.float64]) -> NDArray[numpy.float64]: - return (bound - x) / (bound - pos[0]) - - slc = slice(thickness) - + da, db = (numpy.array(family[axis], dtype=complex, copy=True) for family in widths) + if not numpy.isfinite(da).all() or not numpy.isfinite(db).all() or (da.real <= 0).any() or (db.real <= 0).any(): + raise ValueError('cell widths must be finite with positive real parts') + position = numpy.concatenate(([0.0], da.real.cumsum())) + electric = (position[:-1] + position[1:]) / 2 + magnetic = position[:-1] + if polarity == -1: + selected = slice(0, thickness) + bound = position[thickness] + length = bound - position[0] + depths = [(bound - coordinates[selected]) / length for coordinates in (electric, magnetic)] else: - # back pml - bound = pos[-thickness - 1] - d = pos[-1] - bound - - def l_d(x: NDArray[numpy.float64]) -> NDArray[numpy.float64]: - return (x - bound) / (pos[-1] - bound) - - slc = slice(-thickness, None) - - dx_ai[slc] *= 1 + 1j * s_function(l_d(pos_a[slc])) / d / s_correction - dx_bi[slc] *= 1 + 1j * s_function(l_d(pos_b[slc])) / d / s_correction - - dxes[0][axis] = dx_ai - dxes[1][axis] = dx_bi - - return dxes + selected = slice(-thickness, None) + bound = position[-thickness - 1] + length = position[-1] - bound + depths = [(coordinates[selected] - bound) / length for coordinates in (electric, magnetic)] + correction = length * numpy.sqrt(epsilon_effective) * numpy.real(omega) + da[selected] *= _stretch_factors(depths[0], correction, s_function, kappa_function, max_imag_ratio) + db[selected] *= _stretch_factors(depths[1], correction, s_function, kappa_function, max_imag_ratio) + widths[0][axis], widths[1][axis] = da, db + return widths diff --git a/meanas/fdfd/waveguide_2d.py b/meanas/fdfd/waveguide_2d.py index e67160e..c3c9053 100644 --- a/meanas/fdfd/waveguide_2d.py +++ b/meanas/fdfd/waveguide_2d.py @@ -175,16 +175,26 @@ if the result is introduced into a space with a discretized z-axis. """ -from typing import Any +from typing import Any, Literal from collections.abc import Sequence +import dataclasses import numpy from numpy.typing import NDArray from numpy.linalg import norm from scipy import sparse +from scipy import linalg +from scipy.optimize import linear_sum_assignment +from scipy.sparse import linalg as splinalg from ..fdmath.operators import deriv_forward, deriv_back, cross from ..fdmath import vec, unvec, dx_lists2_t, vcfdfield2_t, vcfdslice_t, vcfdfield2, vfdslice, vcfdslice -from ..eigensolvers import signed_eigensolve, rayleigh_quotient_iteration +from ..eigensolvers import ( + TargetedEigensolution, + TargetedEigensolver, + rayleigh_quotient_iteration, + scipy_eigs_near, + signed_eigensolve, + ) __author__ = 'Jan Petykiewicz' @@ -336,6 +346,8 @@ def normalized_fields_e( epsilon: vfdslice, mu: vfdslice | None = None, prop_phase: float = 0, + *, + normalization: Literal['power', 'lorentz'] = 'power', ) -> tuple[vcfdslice_t, vcfdslice_t]: r""" Given a vector `e_xy` containing the vectorized E_x and E_y fields, @@ -351,6 +363,9 @@ def normalized_fields_e( mu: Vectorized magnetic permeability grid (default 1 everywhere) prop_phase: Phase shift `(dz * corrected_wavenumber)` over 1 cell in propagation direction. Default 0 (continuous propagation direction, i.e. dz->0). + normalization: Unit physical forward power, or unit unconjugated + stretched-coordinate self-reaction (``lorentz``). Self-orthogonal + open modes require paired primal/dual normalization instead. Returns: `(e, h)`, where each field is vectorized, normalized, @@ -359,10 +374,10 @@ def normalized_fields_e( 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 + and `exy2h(...)`, then applies the selected normalization using `_normalized_fields(...)`. - The normalization target is + The default power normalization target is $$ \Re\left[\mathrm{inner\_product}(e, h, \mathrm{conj\_h}=True)\right] = 1, @@ -374,8 +389,8 @@ def normalized_fields_e( e = exy2e(wavenumber=wavenumber, dxes=dxes, epsilon=epsilon) @ e_xy h = exy2h(wavenumber=wavenumber, omega=omega, dxes=dxes, epsilon=epsilon, mu=mu) @ e_xy e_norm, h_norm = _normalized_fields( - e=e, h=h, dxes=dxes, epsilon=epsilon, prop_phase=prop_phase, - ) + e=e, h=h, dxes=dxes, epsilon=epsilon, prop_phase=prop_phase, normalization=normalization, + ) return e_norm, h_norm @@ -387,6 +402,8 @@ def normalized_fields_h( epsilon: vfdslice, mu: vfdslice | None = None, prop_phase: float = 0, + *, + normalization: Literal['power', 'lorentz'] = 'power', ) -> tuple[vcfdslice_t, vcfdslice_t]: r""" Given a vector `h_xy` containing the vectorized H_x and H_y fields, @@ -402,6 +419,9 @@ def normalized_fields_h( mu: Vectorized magnetic permeability grid (default 1 everywhere) prop_phase: Phase shift `(dz * corrected_wavenumber)` over 1 cell in propagation direction. Default 0 (continuous propagation direction, i.e. dz->0). + normalization: Unit physical forward power, or unit unconjugated + stretched-coordinate self-reaction (``lorentz``). Self-orthogonal + open modes require paired primal/dual normalization instead. Returns: `(e, h)`, where each field is vectorized, normalized, @@ -417,7 +437,7 @@ def normalized_fields_h( e = hxy2e(wavenumber=wavenumber, omega=omega, dxes=dxes, epsilon=epsilon, mu=mu) @ h_xy h = hxy2h(wavenumber=wavenumber, dxes=dxes, mu=mu) @ h_xy e_norm, h_norm = _normalized_fields( - e=e, h=h, dxes=dxes, epsilon=epsilon, prop_phase=prop_phase, + e=e, h=h, dxes=dxes, epsilon=epsilon, prop_phase=prop_phase, normalization=normalization, ) return e_norm, h_norm @@ -428,48 +448,45 @@ def _normalized_fields( dxes: dx_lists2_t, epsilon: vfdslice, prop_phase: float = 0, + *, + normalization: Literal['power', 'lorentz'] = 'power', ) -> tuple[vcfdslice_t, vcfdslice_t]: - r""" - Normalize a reconstructed waveguide mode to unit forward power. + """Normalize physical forward power or unconjugated complex self-reaction. - 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. + The power convention fixes a dominant-energy phase and uses a quadrant + sign heuristic. Lorentz normalization applies that phase first, then a + complex square-root rescaling; only the remaining sign freedom preserves + B_mm = 1. Self-orthogonal modes need a paired primal/dual construction. """ shape = [s.size for s in dxes[0]] - # Find time-averaged Sz and normalize to it - Sz_tavg = inner_product(e, h, dxes=dxes, prop_phase=prop_phase, conj_h=True).real - assert Sz_tavg > 0, f'Found a mode propagating in the wrong direction! {Sz_tavg=}' + if normalization not in ('power', 'lorentz'): + raise ValueError("normalization must be 'power' or 'lorentz'") + if not numpy.isfinite(e).all() or not numpy.isfinite(h).all() or not numpy.any(e): + raise ValueError('normalization requires finite, nonzero fields') - energy = numpy.real(epsilon * e.conj() * e) + energy = numpy.abs(epsilon * e.conj() * e) + norm_angle = -numpy.angle(e[energy.argmax()]) + weighted = unvec(e * energy * numpy.exp(1j * norm_angle), shape) + quadrant = weighted[:, :max(shape[0] // 2, 1), :max(shape[1] // 2, 1)].real.sum() + sign = -1 if quadrant < 0 else 1 + phase = sign * numpy.exp(1j * norm_angle) - norm_amplitude = 1 / numpy.sqrt(Sz_tavg) - norm_angle = -numpy.angle(e[energy.argmax()]) # Will randomly add a negative sign when mode is symmetric - - # Try to break symmetry to assign a consistent sign [experimental TODO] - E_weighted = unvec(e * energy * numpy.exp(1j * norm_angle), shape) - sign = numpy.sign(E_weighted[:, - :max(shape[0] // 2, 1), - :max(shape[1] // 2, 1)].real.sum()) - assert sign != 0 - - norm_factor = sign * norm_amplitude * numpy.exp(1j * norm_angle) - - e *= norm_factor - h *= norm_factor + if normalization == 'power': + power = inner_product(e, h, dxes, prop_phase=prop_phase, conj_h=True).real + if not numpy.isfinite(power) or power <= 0: + raise ValueError(f'normalization requires positive forward power, got {power}') + factor = phase / numpy.sqrt(power) + else: + reaction = inner_product(e, h, dxes, prop_phase=prop_phase, measure='stretched') + # Fix the arbitrary phase before reaction normalization. Afterwards only + # a sign change preserves B_mm = 1. + scale = numpy.linalg.norm(e) * numpy.linalg.norm(h) + if not numpy.isfinite(reaction) or abs(reaction) <= numpy.finfo(float).eps * scale: + raise ValueError('zero self-reaction; use a paired primal/dual normalization') + factor = phase / numpy.sqrt(complex(reaction * phase ** 2)) + e = e * factor + h = h * factor return vcfdslice_t(e), vcfdslice_t(h) @@ -888,6 +905,9 @@ def solve_modes( epsilon: vfdslice, mu: vfdslice | None = None, mode_margin: int = 2, + *, + rng: numpy.random.Generator | int | None = None, + v0: NDArray | None = None, ) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]: """ Given a 2D region, attempts to solve for the eigenmode with the specified mode numbers. @@ -901,6 +921,8 @@ def solve_modes( mode_margin: The eigensolver will actually solve for `(max(mode_number) + mode_margin)` modes, but only return the target mode. Increasing this value can improve the solver's ability to find the correct mode. Default 2. + rng: Optional random generator or seed for reproducible initialization. + v0: Optional starting vector; takes precedence over ``rng``. Returns: e_xys: NDArray of vfdfield_t specifying fields. First dimension is mode number. @@ -914,7 +936,7 @@ def solve_modes( mu_real = None if mu is None else numpy.real(mu) A_r = operator_e(numpy.real(omega), dxes_real, numpy.real(epsilon), mu_real) - eigvals, eigvecs = signed_eigensolve(A_r, max(mode_numbers) + mode_margin) + eigvals, eigvecs = signed_eigensolve(A_r, max(mode_numbers) + mode_margin, rng=rng, v0=v0) keep_inds = -(numpy.array(mode_numbers) + 1) e_xys = eigvecs[:, keep_inds].T eigvals = eigvals[keep_inds] @@ -966,6 +988,8 @@ def inner_product( prop_phase: float = 0, conj_h: bool = False, trapezoid: bool = False, + *, + measure: Literal['physical', 'stretched'] = 'physical', ) -> complex: r""" Compute the discrete waveguide overlap / Poynting inner product. @@ -993,6 +1017,8 @@ def inner_product( `True` for the usual time-averaged power normalization. trapezoid: Whether to use trapezoidal quadrature instead of the default rectangular Yee-cell sum. + measure: ``physical`` uses real cell widths for Poynting power; + ``stretched`` retains complex widths for coordinate reaction. Returns: Complex overlap / longitudinal power integral. @@ -1010,12 +1036,1051 @@ def inner_product( H2 = numpy.conj(H2) # Find time-averaged Sz and normalize to it - dxes_real = [[numpy.real(dxyz) for dxyz in dxeh] for dxeh in dxes] + if measure not in ('physical', 'stretched'): + raise ValueError("measure must be 'physical' or 'stretched'") + widths = [[numpy.real(dxyz) if measure == 'physical' else numpy.asarray(dxyz) + for dxyz in dxeh] for dxeh in dxes] if trapezoid: - Sz_a = numpy.trapezoid(numpy.trapezoid(E1[0] * H2[1], numpy.cumsum(dxes_real[0][1])), numpy.cumsum(dxes_real[1][0])) - Sz_b = numpy.trapezoid(numpy.trapezoid(E1[1] * H2[0], numpy.cumsum(dxes_real[0][0])), numpy.cumsum(dxes_real[1][1])) + Sz_a = numpy.trapezoid(numpy.trapezoid(E1[0] * H2[1], numpy.cumsum(widths[1][1])), numpy.cumsum(widths[0][0])) + Sz_b = numpy.trapezoid(numpy.trapezoid(E1[1] * H2[0], numpy.cumsum(widths[0][1])), numpy.cumsum(widths[1][0])) else: - Sz_a = E1[0] * H2[1] * dxes_real[1][0][:, None] * dxes_real[0][1][None, :] - Sz_b = E1[1] * H2[0] * dxes_real[0][0][:, None] * dxes_real[1][1][None, :] + # Ex * Hy occupies an electric-x by magnetic-y area; Ey * Hx uses + # the complementary magnetic-x by electric-y area. + Sz_a = E1[0] * H2[1] * widths[0][0][:, None] * widths[1][1][None, :] + Sz_b = E1[1] * H2[0] * widths[1][0][:, None] * widths[0][1][None, :] Sz = 0.5 * (Sz_a.sum() - Sz_b.sum()) return Sz + +@dataclasses.dataclass(frozen=True, eq=False) +class ModeDiagnostics: + """Per-mode numerical and physical diagnostics. + + ``biorthogonality_condition`` is the condition number of the right/left + Gram matrix after independently equilibrating its vector columns. + + Attributes: + right_residuals: Relative residual of each primal mode or basis column. + left_residuals: Relative residual of each transpose-left mode or column. + powers: Conjugated Poynting power of each primal modal field. + pml_participation: Fraction of transverse state weight inside the PML. + target_indices: Index of the nearest requested target for each spectral + value. + target_distances: Absolute distance from each spectral value to that + target. + biorthogonality_error: Maximum error in the Lorentz dual Gram matrix. + biorthogonality_condition: Equilibrated right/left Gram condition + number. + """ + + right_residuals: NDArray[numpy.float64] + left_residuals: NDArray[numpy.float64] + powers: NDArray[numpy.complex128] + pml_participation: NDArray[numpy.float64] + target_indices: NDArray[numpy.int_] + target_distances: NDArray[numpy.float64] + biorthogonality_error: float + biorthogonality_condition: float + + +@dataclasses.dataclass(frozen=True, eq=False) +class ModeBasis: + """Modal fields and propagation data returned by targeted 2D solvers. + + ``wavenumbers`` records the eigenspectrum. ``wavenumber_matrix`` is the + actual beta operator in the current field basis; it becomes non-diagonal + when tracking rotates a nearly degenerate subspace. Field arrays have shape + ``(mode, primal_or_dual, field)``; index 0 of the second axis is the primal + field and index 1 is its Lorentz dual. + + Attributes: + wavenumbers: Eigenvalue spectrum associated with the basis. + wavenumber_matrix: Representation of the beta operator in the current + possibly mixed basis. + electric_fields: Primal and Lorentz-dual vectorized electric fields. + magnetic_fields: Primal and Lorentz-dual vectorized magnetic fields. + """ + + wavenumbers: NDArray[numpy.complex128] + wavenumber_matrix: NDArray[numpy.complex128] + electric_fields: NDArray[numpy.complex128] = dataclasses.field(repr=False) + magnetic_fields: NDArray[numpy.complex128] = dataclasses.field(repr=False) + + def __len__(self) -> int: + """Return the number of modal basis vectors.""" + return self.wavenumbers.size + + +def operator_beta( + omega: complex, + dxes: dx_lists2_t, + epsilon: vfdslice, + mu: vfdslice | None = None, + ) -> sparse.sparray: + r"""Build the first-order transverse Maxwell operator ``M psi = beta psi``. + + The state ordering is ``[E_x, E_y, H_x, H_y]``. Longitudinal fields are + eliminated, but both transverse field families are retained so the sign of + ``beta`` remains explicit. Algebraically, ``M @ M`` has the existing + electric and magnetic ``beta**2`` waveguide operators on its diagonal. + + Args: + omega: Nonzero angular frequency. + dxes: Two-dimensional Yee-grid spacings, which may include complex PML + stretching. + epsilon: Vectorized three-component relative permittivity. + mu: Optional vectorized three-component relative permeability. Unit + permeability is used when omitted. + + Returns: + Sparse first-order signed-beta operator on transverse E/H states. + + Raises: + ValueError: If the material vectors, grid shape, or frequency are + incompatible. + """ + epsilon = numpy.asarray(epsilon) + if mu is None: + mu = numpy.ones_like(epsilon) + mu = numpy.asarray(mu) + if epsilon.ndim != 1 or mu.ndim != 1 or epsilon.shape != mu.shape or epsilon.size % 3: + raise ValueError('epsilon and mu must be equal-length vectorized three-component fields') + if omega == 0: + raise ValueError('omega must be nonzero') + + n_points = epsilon.size // 3 + if n_points != dxes[0][0].size * dxes[0][1].size: + raise ValueError('material and grid shapes do not match') + + dfx, dfy = deriv_forward(dxes[0]) + dbx, dby = deriv_back(dxes[1]) + zero = sparse.csr_array((n_points, n_points)) + identity = sparse.eye_array(n_points) + rotation = sparse.block_array([[zero, -identity], [identity, zero]], format='csr') + + eps_x, eps_y, eps_z = numpy.split(epsilon, 3) + mu_x, mu_y, mu_z = numpy.split(mu, 3) + eps_xy = sparse.diags_array(numpy.concatenate((eps_x, eps_y))) + mu_xy = sparse.diags_array(numpy.concatenate((mu_x, mu_y))) + eps_z_inv = sparse.diags_array(1 / eps_z) + mu_z_inv = sparse.diags_array(1 / mu_z) + + grad_forward = sparse.vstack((dfx, dfy), format='csr') + grad_backward = sparse.vstack((dbx, dby), format='csr') + rotated_div_backward = sparse.hstack((-dby, dbx), format='csr') + rotated_div_forward = sparse.hstack((-dfy, dfx), format='csr') + + beta_e_from_h = ( + -omega * rotation @ mu_xy + + grad_forward @ eps_z_inv @ rotated_div_backward / omega + ) + beta_h_from_e = ( + omega * rotation @ eps_xy + - grad_backward @ mu_z_inv @ rotated_div_forward / omega + ) + empty = sparse.csr_array((2 * n_points, 2 * n_points)) + return sparse.block_array( + [[empty, beta_e_from_h], [beta_h_from_e, empty]], + format = 'csr', + ) + + +def lorentz_metric( + dxes: dx_lists2_t, + *, + measure: Literal['physical', 'stretched'] = 'stretched', + ) -> sparse.sparray: + """Return the state metric matching the EME symmetric Lorentz overlap. + + The staggered transverse areas are ``dx_e * dy_h`` for ``Ex * Hy`` + and ``dx_h * dy_e`` for ``Ey * Hx``. These assignments make the + reciprocal operator self-transpose under the stretched-coordinate metric. + + Args: + dxes: Two-dimensional Yee-grid spacings. + measure: Retain complex widths (``stretched``, default), or use their + real parts (``physical``). Must match the dual-basis measure. + + Returns: + Sparse bilinear metric on states ordered as ``[Ex, Ey, Hx, Hy]``. + """ + nx = dxes[0][0].size + ny = dxes[0][1].size + if measure not in ('physical', 'stretched'): + raise ValueError("measure must be 'physical' or 'stretched'") + widths = [[numpy.real(values) if measure == 'physical' else numpy.asarray(values) + for values in family] for family in dxes] + weight_ex_hy = numpy.outer(widths[0][0], widths[1][1]).reshape(nx * ny) + weight_ey_hx = numpy.outer(widths[1][0], widths[0][1]).reshape(nx * ny) + diagonal_a = sparse.diags_array(weight_ex_hy) + diagonal_b = sparse.diags_array(weight_ey_hx) + empty_n = sparse.csr_array((nx * ny, nx * ny)) + # inner_product(E, H) = E.T @ q @ H; it already includes a factor 1/2. + q_matrix = 0.5 * sparse.block_array( + [[empty_n, diagonal_a], [-diagonal_b, empty_n]], + format = 'csr', + ) + empty_2n = sparse.csr_array(q_matrix.shape) + # lorentz_overlap adds the two reaction products and multiplies by 1/2. + return 0.5 * sparse.block_array( + [[empty_2n, q_matrix], [q_matrix.T, empty_2n]], + format = 'csr', + ) + + +def infer_physical_mask(dxes: dx_lists2_t) -> NDArray[numpy.bool_]: + """Infer the non-PML aperture from imaginary grid stretching. + + Args: + dxes: Two-dimensional Yee-grid spacings used by the mode solve. + + Returns: + A transverse boolean mask that excludes every row or column containing + complex stretching in either electric or magnetic spacing family. + """ + pml_x = numpy.zeros(dxes[0][0].size, dtype=bool) + pml_y = numpy.zeros(dxes[0][1].size, dtype=bool) + for family in dxes: + pml_x |= numpy.abs(numpy.imag(family[0])) > 0 + pml_y |= numpy.abs(numpy.imag(family[1])) > 0 + return ~(pml_x[:, None] | pml_y[None, :]) + + +def _state_weights(dxes: dx_lists2_t, physical_mask: NDArray[numpy.bool_]) -> NDArray[numpy.float64]: + """Build cell-area weights for all four transverse state components.""" + dx = 0.5 * (numpy.real(dxes[0][0]) + numpy.real(dxes[1][0])) + dy = 0.5 * (numpy.real(dxes[0][1]) + numpy.real(dxes[1][1])) + weights = numpy.outer(dx, dy) * physical_mask + return numpy.tile(weights.ravel(), 4) + + +def _pml_participation( + states: NDArray[numpy.complex128], + dxes: dx_lists2_t, + physical_mask: NDArray[numpy.bool_], + ) -> NDArray[numpy.float64]: + """Return the fraction of each state supported outside the physical mask.""" + grid_shape = physical_mask.shape + full_weights = _state_weights(dxes, numpy.ones(grid_shape, dtype=bool)) + pml_weights = full_weights * numpy.tile(~physical_mask.ravel(), 4) + participation = numpy.sum(pml_weights[:, None] * numpy.abs(states) ** 2, axis=0) / numpy.sum( + full_weights[:, None] * numpy.abs(states) ** 2, + axis = 0, + ) + return numpy.asarray(participation, dtype=float) + + +def _normalize_columns( + matrix: NDArray[numpy.complex128], + ) -> tuple[NDArray[numpy.complex128], NDArray[numpy.float64]]: + """Normalize matrix columns and return their original Euclidean norms.""" + norms = numpy.linalg.norm(matrix, axis=0) + normalized = numpy.divide(matrix, norms, out=numpy.zeros_like(matrix), where=norms != 0) + return normalized, numpy.asarray(norms, dtype=float) + + +def _select_unique_modes( + solutions: Sequence[TargetedEigensolution], + targets: NDArray[numpy.complex128], + modes_per_target: int, + *, + eigenvalue_rtol: float, + subspace_tol: float, + state_weights: NDArray[numpy.float64], + ) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128], NDArray[numpy.complex128], NDArray[numpy.int_]]: + """Merge targeted candidates into independent, cross-pairable eigenspaces.""" + sqrt_weights = numpy.sqrt(state_weights) + + def rank_and_pivots(vectors: list[NDArray[numpy.complex128]]) -> tuple[int, NDArray[numpy.int_]]: + """Estimate weighted subspace rank and QR pivot order.""" + if not vectors: + return 0, numpy.empty(0, dtype=int) + matrix = sqrt_weights[:, None] * numpy.column_stack(vectors) + matrix, _norms = _normalize_columns(matrix) + _q, triangular, pivots = linalg.qr(matrix, mode='economic', pivoting=True) + diagonal = numpy.abs(numpy.diag(triangular)) + if diagonal.size == 0 or diagonal[0] == 0: + return 0, numpy.asarray(pivots, dtype=int) + rank = int(numpy.count_nonzero(diagonal > subspace_tol * diagonal[0])) + return rank, numpy.asarray(pivots, dtype=int) + + def normalized_cross_gram( + right_vectors: list[NDArray[numpy.complex128]], + left_vectors: list[NDArray[numpy.complex128]], + ) -> NDArray[numpy.complex128]: + """Form the bilinear cross Gram matrix after column equilibration.""" + right = numpy.column_stack(right_vectors) + left = numpy.column_stack(left_vectors) + right, _right_norms = _normalize_columns(right) + left, _left_norms = _normalize_columns(left) + return left.T @ right + + def matrix_rank(matrix: NDArray[numpy.complex128]) -> int: + """Estimate matrix rank using the configured subspace tolerance.""" + singular_values = linalg.svdvals(matrix) + if singular_values.size == 0: + return 0 + cutoff = subspace_tol * max(1.0, float(singular_values[0])) + return int(numpy.count_nonzero(singular_values > cutoff)) + + initial_records: list[tuple[complex, NDArray[numpy.complex128], NDArray[numpy.complex128]]] = [] + remaining_records: list[tuple[complex, NDArray[numpy.complex128], NDArray[numpy.complex128]]] = [] + for target_index, (target, solution) in enumerate(zip(targets, solutions, strict=True)): + local_right: list[NDArray[numpy.complex128]] = [] + local_left: list[NDArray[numpy.complex128]] = [] + order = numpy.argsort(numpy.abs(solution.eigenvalues - target)) + stop_position: int | None = None + for position, candidate_index in enumerate(order): + value = complex(solution.eigenvalues[candidate_index]) + right_vector = solution.right_eigenvectors[:, candidate_index] + left_vector = solution.left_eigenvectors[:, candidate_index] + initial_records.append((value, right_vector, left_vector)) + local_right.append(right_vector) + local_left.append(left_vector) + right_rank, _right_pivots = rank_and_pivots(local_right) + left_rank, _left_pivots = rank_and_pivots(local_left) + if right_rank >= modes_per_target and left_rank >= modes_per_target: + stop_position = position + break + if stop_position is None: + raise RuntimeError(f'target {target_index} did not produce enough independent right and left modes') + for candidate_index in order[stop_position + 1:]: + remaining_records.append(( + complex(solution.eigenvalues[candidate_index]), + solution.right_eigenvectors[:, candidate_index], + solution.left_eigenvectors[:, candidate_index], + )) + + clusters: list[list[int]] = [] + for candidate_index, (value, _right, _left) in enumerate(initial_records): + for cluster in clusters: + anchor = initial_records[cluster[0]][0] + if abs(value - anchor) <= eigenvalue_rtol * max(1.0, abs(value), abs(anchor)): + cluster.append(candidate_index) + break + else: + clusters.append([candidate_index]) + + chosen_values: list[complex] = [] + chosen_right: list[NDArray[numpy.complex128]] = [] + chosen_left: list[NDArray[numpy.complex128]] = [] + for cluster in clusters: + anchor = initial_records[cluster[0]][0] + right_values = [initial_records[index][0] for index in cluster] + left_values = [initial_records[index][0] for index in cluster] + right_vectors = [initial_records[index][1] for index in cluster] + left_vectors = [initial_records[index][2] for index in cluster] + right_rank, _right_pivots = rank_and_pivots(right_vectors) + left_rank, _left_pivots = rank_and_pivots(left_vectors) + required_rank = max(right_rank, left_rank) + cross_gram = normalized_cross_gram(right_vectors, left_vectors) + cross_rank = matrix_rank(cross_gram) + for value, right_vector, left_vector in remaining_records: + if cross_rank >= required_rank: + break + if abs(value - anchor) > eigenvalue_rtol * max(1.0, abs(value), abs(anchor)): + continue + right_vectors.append(right_vector) + left_vectors.append(left_vector) + right_values.append(value) + left_values.append(value) + cross_gram = normalized_cross_gram(right_vectors, left_vectors) + cross_rank = matrix_rank(cross_gram) + if cross_rank < required_rank: + raise RuntimeError( + f'eigenvalue cluster near {anchor!r} has incompatible right/transpose-left ' + f'subspaces (required cross rank {required_rank}, available {cross_rank})', + ) + + _q, _triangular, right_pivots = linalg.qr(cross_gram, mode='economic', pivoting=True) + right_selection = numpy.sort(numpy.asarray(right_pivots[:required_rank], dtype=int)) + _q, _triangular, left_pivots = linalg.qr( + cross_gram[:, right_selection].T, + mode='economic', + pivoting=True, + ) + left_selection = numpy.asarray(left_pivots[:required_rank], dtype=int) + selected_cross_gram = cross_gram[numpy.ix_(left_selection, right_selection)] + selected_cross_rank = matrix_rank(selected_cross_gram) + if selected_cross_rank < required_rank: + raise RuntimeError( + f'eigenvalue cluster near {anchor!r} has incompatible right/transpose-left ' + f'subspaces (required cross rank {required_rank}, selected {selected_cross_rank})', + ) + selected_right_values = numpy.asarray(right_values)[right_selection] + selected_left_values = numpy.asarray(left_values)[left_selection] + rows, columns = linear_sum_assignment(numpy.abs(selected_right_values[:, None] - selected_left_values[None, :])) + paired_left = numpy.asarray(left_selection)[columns[numpy.argsort(rows)]] + chosen_values.extend(numpy.asarray(right_values)[right_selection]) + chosen_right.extend(right_vectors[int(index)] for index in right_selection) + chosen_left.extend(left_vectors[int(index)] for index in paired_left) + + values = numpy.asarray(chosen_values, dtype=complex) + target_indices = numpy.argmin(numpy.abs(values[:, None] - targets[None, :]), axis=1) + return ( + values, + numpy.column_stack(chosen_right), + numpy.column_stack(chosen_left), + numpy.asarray(target_indices, dtype=int), + ) + + +def solve_modes_near_beta( + omega: complex, + solve_dxes: dx_lists2_t, + epsilon: vfdslice, + *, + target_wavenumbers: Sequence[complex], + modes_per_target: int, + mu: vfdslice | None = None, + overlap_dxes: dx_lists2_t | None = None, + physical_mask: NDArray[numpy.bool_] | None = None, + candidate_margin: int = 4, + eigensolver: TargetedEigensolver = scipy_eigs_near, + eigensolver_opts: dict[str, Any] | None = None, + residual_tol: float = 1e-8, + duplicate_eigenvalue_rtol: float = 1e-8, + duplicate_subspace_tol: float = 1e-7, + ) -> tuple[ModeBasis, ModeDiagnostics]: + """Solve targeted modes through the first-order signed-beta operator. + + Each target requests an independent candidate solve. Overlapping results + are merged by eigenvalue cluster and weighted subspace rank, after which + transpose-left eigenvectors are converted into a Lorentz-biorthogonal dual + basis. Returned fields use the full three-component vectorization, while + the underlying eigenproblem is ordered as ``[Ex, Ey, Hx, Hy]``. + + Args: + omega: Nonzero angular frequency. + solve_dxes: Yee-grid spacings used to build the possibly PML-stretched + eigenproblem. + epsilon: Vectorized three-component relative permittivity. + target_wavenumbers: Finite signed-beta targets for shift-invert solves. + modes_per_target: Independent candidates requested around each target. + mu: Optional vectorized three-component relative permeability. + overlap_dxes: Grid used for physical overlaps and normalization. By + default, the real parts of ``solve_dxes`` are used. + physical_mask: Optional transverse mask selecting the non-PML aperture. + By default, cells with imaginary grid stretching are excluded. + candidate_margin: Extra eigenpairs requested from each targeted solve + to support deduplication and degenerate-subspace repair. + eigensolver: Backend returning paired right and transpose-left vectors. + eigensolver_opts: Keyword arguments forwarded to ``eigensolver``. + residual_tol: Maximum accepted right, left, and dual-basis residual. + duplicate_eigenvalue_rtol: Relative tolerance for eigenvalue clustering. + duplicate_subspace_tol: Relative numerical-rank tolerance used while + merging repeated or degenerate candidates. + + Returns: + Modal basis and its numerical and physical diagnostics. + + Raises: + ValueError: If inputs or backend result shapes contain invalid values. + RuntimeError: If too few independent modes are found, right/left spaces + cannot be paired, or requested residual tolerances are not met. + """ + if modes_per_target < 1: + raise ValueError('modes_per_target must be positive') + if candidate_margin < 0: + raise ValueError('candidate_margin must be nonnegative') + if not numpy.isfinite(residual_tol) or residual_tol <= 0: + raise ValueError('residual_tol must be finite and positive') + if not numpy.isfinite(duplicate_eigenvalue_rtol) or duplicate_eigenvalue_rtol <= 0: + raise ValueError('duplicate_eigenvalue_rtol must be finite and positive') + if not numpy.isfinite(duplicate_subspace_tol) or duplicate_subspace_tol <= 0: + raise ValueError('duplicate_subspace_tol must be finite and positive') + targets = numpy.asarray(target_wavenumbers, dtype=complex) + if targets.ndim != 1 or targets.size == 0 or not numpy.isfinite(targets).all(): + raise ValueError('target_wavenumbers must be a finite nonempty one-dimensional sequence') + if overlap_dxes is None: + overlap_dxes = [[numpy.real(values) for values in family] for family in solve_dxes] + if physical_mask is None: + physical_mask = infer_physical_mask(solve_dxes) + physical_mask = numpy.asarray(physical_mask, dtype=bool) + grid_shape = (solve_dxes[0][0].size, solve_dxes[0][1].size) + if physical_mask.shape != grid_shape: + raise ValueError('physical_mask must match the transverse grid shape') + + operator = operator_beta(omega, solve_dxes, epsilon, mu) + options = {} if eigensolver_opts is None else dict(eigensolver_opts) + solutions = [ + eigensolver(operator, complex(target), modes_per_target + candidate_margin, **options) + for target in targets + ] + for target_index, solution in enumerate(solutions): + count = numpy.asarray(solution.eigenvalues).size + if numpy.asarray(solution.eigenvalues).shape != (count,): + raise ValueError(f'eigensolver result {target_index} eigenvalues must be one-dimensional') + expected_shape = (operator.shape[0], count) + if numpy.asarray(solution.right_eigenvectors).shape != expected_shape or numpy.asarray(solution.left_eigenvectors).shape != expected_shape: + raise ValueError(f'eigensolver result {target_index} eigenvector shapes are invalid') + if count < modes_per_target: + raise RuntimeError(f'eigensolver result {target_index} returned too few candidates') + if not ( + numpy.isfinite(solution.eigenvalues).all() + and numpy.isfinite(solution.right_eigenvectors).all() + and numpy.isfinite(solution.left_eigenvectors).all() + ): + raise ValueError(f'eigensolver result {target_index} contains non-finite values') + merge_weights = _state_weights(overlap_dxes, numpy.ones(grid_shape, dtype=bool)) + values, right, left, _target_indices = _select_unique_modes( + solutions, + targets, + modes_per_target, + eigenvalue_rtol = duplicate_eigenvalue_rtol, + subspace_tol = duplicate_subspace_tol, + state_weights = merge_weights, + ) + + right, right_norms = _normalize_columns(right) + left, left_norms = _normalize_columns(left) + if numpy.any(right_norms == 0) or numpy.any(left_norms == 0): + raise RuntimeError('selected right/transpose-left eigenbasis contains a zero vector') + gram = left.T @ right + condition = float(numpy.linalg.cond(gram)) + if not numpy.isfinite(condition) or condition > 1e12: + raise RuntimeError(f'left/right eigenbasis is singular or ill-conditioned ({condition=:.3e})') + left = linalg.solve(gram, left.T).T + + weights = _state_weights(overlap_dxes, physical_mask) + for index in range(values.size): + vector = right[:, index] + norm_value = numpy.sqrt(numpy.sum(weights * numpy.abs(vector) ** 2)) + if norm_value == 0: + raise RuntimeError(f'mode {index} has no support in the physical aperture') + weighted_vector = numpy.where(weights > 0, vector, 0) + phase_sample = weighted_vector[numpy.argmax(numpy.abs(weighted_vector))] + scale = numpy.exp(-1j * numpy.angle(phase_sample)) / norm_value + right[:, index] *= scale + left[:, index] /= scale + + metric = lorentz_metric(overlap_dxes).tocsc() + dual_states = numpy.asarray(splinalg.spsolve(metric.T, left)) + if dual_states.ndim == 1: + dual_states = dual_states[:, None] + biorthogonal_gram = dual_states.T @ metric @ right + biorthogonality_error = float(numpy.max(numpy.abs(biorthogonal_gram - numpy.eye(values.size)))) + if biorthogonality_error > residual_tol: + raise RuntimeError('failed to construct a biorthogonal Lorentz dual basis') + + n_points = grid_shape[0] * grid_shape[1] + primal_electric = numpy.empty((values.size, 3 * n_points), dtype=complex) + primal_magnetic = numpy.empty_like(primal_electric) + dual_electric = numpy.zeros_like(primal_electric) + dual_magnetic = numpy.zeros_like(primal_electric) + epsilon_array = numpy.asarray(epsilon) + mu_array = None if mu is None else numpy.asarray(mu) + for index, beta in enumerate(values): + e_xy = right[:2 * n_points, index] + h_xy = right[2 * n_points:, index] + primal_electric[index] = exy2e(beta, solve_dxes, epsilon_array) @ e_xy + primal_magnetic[index] = hxy2h(beta, solve_dxes, mu_array) @ h_xy + dual_electric[index, :2 * n_points] = dual_states[:2 * n_points, index] + dual_magnetic[index, :2 * n_points] = dual_states[2 * n_points:, index] + + basis = ModeBasis( + wavenumbers = values, + wavenumber_matrix = numpy.diag(values), + electric_fields = numpy.stack((primal_electric, dual_electric), axis=1), + magnetic_fields = numpy.stack((primal_magnetic, dual_magnetic), axis=1), + ) + diagnostics = _compute_mode_diagnostics( + basis, + operator, + targets, + overlap_dxes, + physical_mask, + ) + if ( + numpy.max(diagnostics.right_residuals) > residual_tol + or numpy.max(diagnostics.left_residuals) > residual_tol + ): + raise RuntimeError('targeted eigenmodes did not meet the requested residual tolerance') + if diagnostics.biorthogonality_error > residual_tol: + raise RuntimeError('failed to construct a biorthogonal Lorentz dual basis') + return basis, diagnostics + + +def solve_modes_near( + omega: complex, + solve_dxes: dx_lists2_t, + epsilon: vfdslice, + *, + target_wavenumbers: Sequence[complex], + modes_per_target: int, + mu: vfdslice | None = None, + overlap_dxes: dx_lists2_t | None = None, + physical_mask: NDArray[numpy.bool_] | None = None, + candidate_margin: int = 4, + eigensolver: TargetedEigensolver = scipy_eigs_near, + eigensolver_opts: dict[str, Any] | None = None, + residual_tol: float = 1e-8, + duplicate_eigenvalue_rtol: float = 1e-8, + duplicate_subspace_tol: float = 1e-7, + ) -> tuple[ModeBasis, ModeDiagnostics]: + """Solve targeted complex modes through the electric beta-squared operator. + + The half-sized transverse-electric problem is solved near each + ``target_wavenumber**2``. Each eigenpair is then lifted to the signed + first-order state ``[Ex, Ey, Hx, Hy]`` using the square-root branch nearest + the requested signed target. Opposite signed targets share a beta-squared + eigensolve. + + Args: + omega: Nonzero angular frequency. + solve_dxes: Yee-grid spacings used by the possibly PML-stretched solve. + epsilon: Vectorized three-component relative permittivity. + target_wavenumbers: Finite signed-beta targets. Their squares are passed + to the targeted eigensolver. + modes_per_target: Independent candidates requested around each target. + mu: Optional vectorized three-component relative permeability. + overlap_dxes: Grid used for physical overlaps and normalization. + physical_mask: Optional transverse mask selecting the non-PML aperture. + candidate_margin: Extra eigenpairs requested from each targeted solve. + eigensolver: Backend receiving the beta-squared operator and target. + eigensolver_opts: Keyword arguments forwarded to ``eigensolver``. + residual_tol: Maximum accepted full-state and dual-basis residual. + duplicate_eigenvalue_rtol: Relative tolerance for signed-beta clusters. + duplicate_subspace_tol: Numerical-rank tolerance for candidate merging. + + Returns: + Modal basis compatible with tracking and EME helpers, together with + its numerical and physical diagnostics. + + Raises: + ValueError: If the backend result is invalid or inputs are incompatible. + RuntimeError: If a zero-beta candidate cannot be lifted or residual + checks fail. + """ + squared_operator = operator_e(omega, solve_dxes, epsilon, mu) + squared_cache: dict[tuple[complex, int], TargetedEigensolution] = {} + + def lift_squared_solution( + first_order_operator: sparse.sparray, + target: complex, + how_many: int, + **options: Any, + ) -> TargetedEigensolution: + """Solve one beta-squared target and lift it to a signed state basis.""" + squared_target = complex(target) ** 2 + cache_key = (squared_target, how_many) + if cache_key not in squared_cache: + squared_cache[cache_key] = eigensolver( + squared_operator, + squared_target, + how_many, + **options, + ) + solution = squared_cache[cache_key] + eigenvalues = numpy.asarray(solution.eigenvalues, dtype=complex) + right_e = numpy.asarray(solution.right_eigenvectors, dtype=complex) + left_e = numpy.asarray(solution.left_eigenvectors, dtype=complex) + expected_shape = (squared_operator.shape[0], eigenvalues.size) + if eigenvalues.shape != (eigenvalues.size,): + raise ValueError('beta-squared eigensolver eigenvalues must be one-dimensional') + if right_e.shape != expected_shape or left_e.shape != expected_shape: + raise ValueError('beta-squared eigensolver eigenvector shapes are invalid') + if eigenvalues.size < modes_per_target: + raise RuntimeError('beta-squared eigensolver returned too few candidates') + if not ( + numpy.isfinite(eigenvalues).all() + and numpy.isfinite(right_e).all() + and numpy.isfinite(left_e).all() + ): + raise ValueError('beta-squared eigensolver result contains non-finite values') + + principal = numpy.sqrt(eigenvalues) + use_negative = numpy.abs(-principal - target) < numpy.abs(principal - target) + wavenumbers = numpy.where(use_negative, -principal, principal) + if numpy.any(wavenumbers == 0): + raise RuntimeError('zero-beta modes cannot be lifted from the beta-squared operator') + + transverse_size = squared_operator.shape[0] + if first_order_operator.shape != (2 * transverse_size, 2 * transverse_size): + raise ValueError('first-order and beta-squared operator dimensions are incompatible') + e_from_h = first_order_operator[:transverse_size, transverse_size:] + h_from_e = first_order_operator[transverse_size:, :transverse_size] + right_h = (h_from_e @ right_e) / wavenumbers[None, :] + left_h = (e_from_h.T @ left_e) / wavenumbers[None, :] + return TargetedEigensolution( + eigenvalues = numpy.asarray(wavenumbers, dtype=complex), + right_eigenvectors = numpy.vstack((right_e, right_h)), + left_eigenvectors = numpy.vstack((left_e, left_h)), + ) + + return solve_modes_near_beta( + omega, + solve_dxes, + epsilon, + target_wavenumbers = target_wavenumbers, + modes_per_target = modes_per_target, + mu = mu, + overlap_dxes = overlap_dxes, + physical_mask = physical_mask, + candidate_margin = candidate_margin, + eigensolver = lift_squared_solution, + eigensolver_opts = eigensolver_opts, + residual_tol = residual_tol, + duplicate_eigenvalue_rtol = duplicate_eigenvalue_rtol, + duplicate_subspace_tol = duplicate_subspace_tol, + ) + + +def _block_relative_residuals( + operator: sparse.sparray, + matrix: NDArray[numpy.complex128], + vectors: NDArray[numpy.complex128], + ) -> NDArray[numpy.float64]: + """Compute column residuals for an invariant subspace representation.""" + image = operator @ vectors + expected = vectors @ matrix + numerator = numpy.linalg.norm(image - expected, axis=0) + denominator = numpy.maximum(numpy.linalg.norm(image, axis=0), numpy.linalg.norm(expected, axis=0)) + return numpy.asarray(numerator / numpy.maximum(denominator, numpy.finfo(float).tiny), dtype=float) + + +def _compute_mode_diagnostics( + basis: ModeBasis, + operator: sparse.sparray, + targets: NDArray[numpy.complex128], + overlap_dxes: dx_lists2_t, + physical_mask: NDArray[numpy.bool_], + ) -> ModeDiagnostics: + """Compute diagnostics from an already-built operator and validated context.""" + n_points = basis.electric_fields.shape[2] // 3 + primal_electric = basis.electric_fields[:, 0] + dual_electric = basis.electric_fields[:, 1] + primal_magnetic = basis.magnetic_fields[:, 0] + dual_magnetic = basis.magnetic_fields[:, 1] + right = numpy.vstack((primal_electric[:, :2 * n_points].T, primal_magnetic[:, :2 * n_points].T)) + dual = numpy.vstack((dual_electric[:, :2 * n_points].T, dual_magnetic[:, :2 * n_points].T)) + metric = lorentz_metric(overlap_dxes) + left = metric.T @ dual + gram = dual.T @ metric @ right + normalized_right, right_norms = _normalize_columns(right) + normalized_left, left_norms = _normalize_columns(left) + if numpy.any(right_norms == 0) or numpy.any(left_norms == 0): + biorthogonality_condition = numpy.inf + else: + normalized_gram = normalized_left.T @ normalized_right + biorthogonality_condition = float(numpy.linalg.cond(normalized_gram)) + powers = numpy.asarray([ + inner_product(e_field, h_field, overlap_dxes, conj_h=True) + for e_field, h_field in zip(primal_electric, primal_magnetic, strict=True) + ]) + pml_participation = _pml_participation(right, overlap_dxes, physical_mask) + target_indices = numpy.argmin(numpy.abs(basis.wavenumbers[:, None] - targets[None, :]), axis=1) + target_distances = numpy.abs(basis.wavenumbers - targets[target_indices]) + return ModeDiagnostics( + right_residuals = _block_relative_residuals(operator, basis.wavenumber_matrix, right), + left_residuals = _block_relative_residuals(operator.T, basis.wavenumber_matrix.T, left), + powers = powers, + pml_participation = numpy.asarray(pml_participation, dtype=float), + target_indices = numpy.asarray(target_indices, dtype=int), + target_distances = numpy.asarray(target_distances, dtype=float), + biorthogonality_error = float(numpy.max(numpy.abs(gram - numpy.eye(len(basis))))), + biorthogonality_condition = biorthogonality_condition, + ) + + +def compute_mode_diagnostics( + basis: ModeBasis, + omega: complex, + solve_dxes: dx_lists2_t, + epsilon: vfdslice, + *, + target_wavenumbers: Sequence[complex], + mu: vfdslice | None = None, + overlap_dxes: dx_lists2_t | None = None, + physical_mask: NDArray[numpy.bool_] | None = None, + ) -> ModeDiagnostics: + """Compute numerical and physical diagnostics for a modal basis. + + Args: + basis: Modal basis to diagnose. + omega: Nonzero angular frequency used by the mode solve. + solve_dxes: Yee-grid spacings used to build the eigenproblem. + epsilon: Vectorized three-component relative permittivity. + target_wavenumbers: Signed-beta targets used to label the spectrum. + mu: Optional vectorized three-component relative permeability. + overlap_dxes: Grid used for physical overlaps and normalization. The + real parts of ``solve_dxes`` are used by default. + physical_mask: Optional transverse non-PML mask. It is inferred from + complex stretching in ``solve_dxes`` by default. + + Returns: + Per-mode residual, power, PML, target, and biorthogonality diagnostics. + + Raises: + ValueError: If the basis or diagnostic context has invalid dimensions. + """ + targets = numpy.asarray(target_wavenumbers, dtype=complex) + if targets.ndim != 1 or targets.size == 0 or not numpy.isfinite(targets).all(): + raise ValueError('target_wavenumbers must be a finite nonempty one-dimensional sequence') + if overlap_dxes is None: + overlap_dxes = [[numpy.real(values) for values in family] for family in solve_dxes] + grid_shape = (solve_dxes[0][0].size, solve_dxes[0][1].size) + if physical_mask is None: + physical_mask = infer_physical_mask(solve_dxes) + physical_mask = numpy.asarray(physical_mask, dtype=bool) + if physical_mask.shape != grid_shape: + raise ValueError('physical_mask must match the transverse grid shape') + field_shape = (len(basis), 2, 3 * grid_shape[0] * grid_shape[1]) + if basis.electric_fields.shape != field_shape or basis.magnetic_fields.shape != field_shape: + raise ValueError('basis field arrays must have shape (mode, primal_or_dual, field)') + if basis.wavenumber_matrix.shape != (len(basis), len(basis)): + raise ValueError('basis wavenumber_matrix must match the mode count') + operator = operator_beta(omega, solve_dxes, epsilon, mu) + return _compute_mode_diagnostics(basis, operator, targets, overlap_dxes, physical_mask) + + +def transform_mode_basis( + basis: ModeBasis, + transform: NDArray[numpy.complex128], + *, + spectrum_order: NDArray[numpy.int_] | None = None, + ) -> ModeBasis: + """Apply a modal basis transform and its contragredient dual transform. + + Args: + basis: Modal basis to transform. + transform: Finite invertible square matrix acting on primal basis + columns. Dual fields receive the contragredient transformation. + spectrum_order: Optional permutation assigning spectral labels to the + transformed basis order. + + Returns: + A transformed modal basis. + + Raises: + ValueError: If ``transform`` has an invalid shape or ``spectrum_order`` + is not a complete integer permutation. + numpy.linalg.LinAlgError: If ``transform`` is singular. + """ + transform = numpy.asarray(transform, dtype=complex) + if transform.shape != (len(basis), len(basis)) or not numpy.isfinite(transform).all(): + raise ValueError('basis transform must be a finite square mode matrix') + inverse = numpy.linalg.inv(transform) + wavenumbers = basis.wavenumbers + if spectrum_order is not None: + raw_order = numpy.asarray(spectrum_order) + if ( + raw_order.shape != (len(basis),) + or not numpy.issubdtype(raw_order.dtype, numpy.integer) + or not numpy.array_equal(numpy.sort(raw_order), numpy.arange(len(basis))) + ): + raise ValueError('spectrum_order must be a complete integer permutation') + spectrum_order = numpy.asarray(raw_order, dtype=int) + wavenumbers = wavenumbers[spectrum_order] + primal_electric = transform.T @ basis.electric_fields[:, 0] + dual_electric = inverse @ basis.electric_fields[:, 1] + primal_magnetic = transform.T @ basis.magnetic_fields[:, 0] + dual_magnetic = inverse @ basis.magnetic_fields[:, 1] + return ModeBasis( + wavenumbers = wavenumbers, + wavenumber_matrix = inverse @ basis.wavenumber_matrix @ transform, + electric_fields = numpy.stack((primal_electric, dual_electric), axis=1), + magnetic_fields = numpy.stack((primal_magnetic, dual_magnetic), axis=1), + ) + + +def power_normalize_modes( + basis: ModeBasis, + indices: Sequence[int], + *, + overlap_dxes: dx_lists2_t, + power_atol: float = 1e-12, + power_rtol: float = 1e-6, + ) -> ModeBasis: + """Normalize selected positive-forward-power modes to unit power. + + Primal fields are scaled by the inverse square root of their real power and + Lorentz duals are scaled reciprocally, preserving biorthogonality and the + represented beta operator. + + Args: + basis: Modal basis to copy and rescale. + indices: Basis-vector indices to normalize. + overlap_dxes: Grid used to evaluate modal power. + power_atol: Absolute threshold for positive power and negligible + imaginary power. + power_rtol: Relative threshold for negligible imaginary power. + + Returns: + A power-normalized modal basis. + + Raises: + ValueError: If tolerances are invalid or a selected mode does not carry + positive, nearly real forward power. + """ + if not numpy.isfinite(power_atol) or power_atol <= 0: + raise ValueError('power_atol must be finite and positive') + if not numpy.isfinite(power_rtol) or power_rtol <= 0: + raise ValueError('power_rtol must be finite and positive') + powers = numpy.asarray([ + inner_product(e_field, h_field, overlap_dxes, conj_h=True) + for e_field, h_field in zip(basis.electric_fields[:, 0], basis.magnetic_fields[:, 0], strict=True) + ]) + scales = numpy.ones(len(basis), dtype=complex) + for index in indices: + power = powers[index] + if power.real <= power_atol or abs(power.imag) > max(power_atol, power_rtol * abs(power.real)): + raise ValueError(f'mode {index} does not have positive nearly-real forward power') + scales[index] = 1 / numpy.sqrt(power.real) + return transform_mode_basis(basis, numpy.diag(scales)) + + +def lorentz_overlap( + dual_mode: tuple[vcfdfield2, vcfdfield2], + mode: tuple[vcfdfield2, vcfdfield2], + dxes: dx_lists2_t, + *, + measure: Literal['physical', 'stretched'] = 'stretched', + ) -> complex: + """Evaluate a symmetric unconjugated reaction using the selected measure. + + Use the same grid and measure that defined the supplied Lorentz duals. + Complex coordinate widths are retained by default. + """ + return 0.5 * ( + inner_product(dual_mode[0], mode[1], dxes, conj_h=False, measure=measure) + + inner_product(mode[0], dual_mode[1], dxes, conj_h=False, measure=measure) + ) + + +def _degenerate_clusters( + previous: NDArray[numpy.complex128], + current: NDArray[numpy.complex128], + rtol: float, + ) -> list[list[int]]: + """Find connected mode groups degenerate in two adjacent spectra.""" + count = previous.size + remaining = set(range(count)) + clusters: list[list[int]] = [] + while remaining: + seed = remaining.pop() + cluster = {seed} + changed = True + while changed: + changed = False + for candidate in tuple(remaining): + if any( + abs(previous[candidate] - previous[item]) <= rtol * max(1.0, abs(previous[candidate]), abs(previous[item])) + and abs(current[candidate] - current[item]) <= rtol * max(1.0, abs(current[candidate]), abs(current[item])) + for item in cluster + ): + remaining.remove(candidate) + cluster.add(candidate) + changed = True + clusters.append(sorted(cluster)) + return clusters + + +def track_mode_path( + bases: Sequence[ModeBasis], + *, + overlap_dxes: dx_lists2_t, + degeneracy_rtol: float = 1e-6, + minimum_overlap: float = 1e-3, + ) -> tuple[ModeBasis, ...]: + """Assign, align, and parallel-transport modal bases along a path. + + Adjacent modes are assigned by Lorentz overlap, with beta distance used as + a deterministic tie-breaker. Nearly degenerate groups are aligned by a + unitary Procrustes rotation, so their propagation operator may become + non-diagonal in the transported basis. + + Args: + bases: Equal-sized modal bases sampled in path order. + overlap_dxes: Common grid used for adjacent Lorentz overlaps. + degeneracy_rtol: Relative spectral tolerance used to group modes for + subspace rotation. + minimum_overlap: Minimum accepted adjacent diagonal overlap after + assignment and subspace alignment. + + Returns: + Basis sequence in tracked order with aligned phases, contragredient + duals, and transformed beta matrices. + + Raises: + ValueError: If the sequence, tolerances, or field shapes are incompatible. + RuntimeError: If an assigned mode has insufficient adjacent overlap. + """ + if not bases: + raise ValueError('bases must not be empty') + if not numpy.isfinite(degeneracy_rtol) or degeneracy_rtol <= 0: + raise ValueError('degeneracy_rtol must be finite and positive') + if not numpy.isfinite(minimum_overlap) or minimum_overlap <= 0: + raise ValueError('minimum_overlap must be finite and positive') + count = len(bases[0]) + if count == 0 or any(len(basis) != count for basis in bases): + raise ValueError('all bases must contain the same nonzero mode count') + reference = bases[0] + for index, basis in enumerate(bases[1:], start=1): + if basis.electric_fields.shape != reference.electric_fields.shape or basis.magnetic_fields.shape != reference.magnetic_fields.shape: + raise ValueError(f'bases[{index}] modal field shapes do not match bases[0]') + + tracked = [bases[0]] + for current_unordered in bases[1:]: + previous = tracked[-1] + previous_dual_e = previous.electric_fields[:, 1] + previous_dual_h = previous.magnetic_fields[:, 1] + current_primal_e = current_unordered.electric_fields[:, 0] + current_primal_h = current_unordered.magnetic_fields[:, 0] + overlap = numpy.asarray([ + [ + lorentz_overlap((dual_e, dual_h), (mode_e, mode_h), overlap_dxes) + for mode_e, mode_h in zip(current_primal_e, current_primal_h, strict=True) + ] + for dual_e, dual_h in zip(previous_dual_e, previous_dual_h, strict=True) + ]) + beta_scale = numpy.maximum(1.0, numpy.maximum( + numpy.abs(previous.wavenumbers[:, None]), + numpy.abs(current_unordered.wavenumbers[None, :]), + )) + beta_distance = numpy.abs(previous.wavenumbers[:, None] - current_unordered.wavenumbers[None, :]) / beta_scale + cost = -numpy.abs(overlap) + 1e-9 * beta_distance + rows, columns = linear_sum_assignment(cost) + order = columns[numpy.argsort(rows)] + permutation = numpy.eye(count, dtype=complex)[:, order] + reordered_e = current_primal_e[order] + reordered_h = current_primal_h[order] + reordered_wavenumbers = current_unordered.wavenumbers[order] + + cluster_transform = numpy.eye(count, dtype=complex) + for cluster in _degenerate_clusters(previous.wavenumbers, reordered_wavenumbers, degeneracy_rtol): + if len(cluster) == 1: + continue + indices = numpy.asarray(cluster) + cross = numpy.asarray([ + [ + lorentz_overlap( + (previous_dual_e[ii], previous_dual_h[ii]), + (reordered_e[jj], reordered_h[jj]), + overlap_dxes, + ) + for jj in indices + ] + for ii in indices + ]) + left_u, _singular_values, right_vh = numpy.linalg.svd(cross) + transform = right_vh.conj().T @ left_u.conj().T + cluster_transform[numpy.ix_(indices, indices)] = transform + + aligned_e = cluster_transform.T @ reordered_e + aligned_h = cluster_transform.T @ reordered_h + diagonal_overlap = numpy.asarray([ + lorentz_overlap( + (previous_dual_e[index], previous_dual_h[index]), + (aligned_e[index], aligned_h[index]), + overlap_dxes, + ) + for index in range(count) + ]) + if numpy.any(numpy.abs(diagonal_overlap) < minimum_overlap): + bad = numpy.flatnonzero(numpy.abs(diagonal_overlap) < minimum_overlap) + raise RuntimeError(f'could not track modes {bad.tolist()}: adjacent overlap is too small') + phase_scales = numpy.exp(-1j * numpy.angle(diagonal_overlap)) + transform = permutation @ cluster_transform @ numpy.diag(phase_scales) + tracked.append(transform_mode_basis(current_unordered, transform, spectrum_order=order)) + + return tuple(tracked) diff --git a/meanas/fdfd/waveguide_2d_complex.py b/meanas/fdfd/waveguide_2d_complex.py deleted file mode 100644 index eadf316..0000000 --- a/meanas/fdfd/waveguide_2d_complex.py +++ /dev/null @@ -1,881 +0,0 @@ -"""Targeted complex waveguide modes on open, complex-stretched cross-sections.""" -from collections.abc import Callable, Sequence -import dataclasses -from typing import TYPE_CHECKING, Any - -import numpy -from scipy import sparse -from scipy import linalg -from scipy.optimize import linear_sum_assignment -from scipy.sparse import linalg as splinalg - -from ..eigensolvers import TargetedEigensolution, scipy_eigs_near -from ..fdmath.operators import deriv_back, deriv_forward -from . import waveguide_2d - -if TYPE_CHECKING: - from numpy.typing import NDArray - - from ..fdmath import dx_lists2_t, vcfdfield2, vfdslice - - -type targeted_eigensolver_t = Callable[..., TargetedEigensolution] - - -@dataclasses.dataclass(frozen=True) -class ComplexModeDiagnostics: - """Per-mode numerical and physical diagnostics. - - ``biorthogonality_condition`` is the condition number of the right/left - Gram matrix after independently equilibrating its vector columns. - - Attributes: - right_residuals: Relative residual of each primal mode or basis column. - left_residuals: Relative residual of each transpose-left mode or column. - powers: Conjugated Poynting power of each primal modal field. - pml_participation: Fraction of transverse state weight inside the PML. - target_indices: Index of the nearest requested target for each spectral - value. - target_distances: Absolute distance from each spectral value to that - target. - biorthogonality_error: Maximum error in the Lorentz dual Gram matrix. - biorthogonality_condition: Equilibrated right/left Gram condition - number. - """ - - right_residuals: NDArray[numpy.float64] - left_residuals: NDArray[numpy.float64] - powers: NDArray[numpy.complex128] - pml_participation: NDArray[numpy.float64] - target_indices: NDArray[numpy.int_] - target_distances: NDArray[numpy.float64] - biorthogonality_error: float - biorthogonality_condition: float - - -@dataclasses.dataclass(frozen=True) -class ComplexModeBasis: - """Primal and Lorentz-dual modes returned by :func:`solve_modes_near`. - - ``wavenumbers`` records the eigenspectrum. ``wavenumber_matrix`` is the - actual beta operator in the current field basis; it becomes non-diagonal - when tracking rotates a nearly degenerate subspace. - - Attributes: - wavenumbers: Eigenvalue spectrum associated with the basis. - wavenumber_matrix: Representation of the beta operator in the current - possibly mixed basis. - electric_fields: Primal vectorized three-component electric fields. - magnetic_fields: Primal vectorized three-component magnetic fields. - dual_electric_fields: Lorentz-dual electric projection fields. - dual_magnetic_fields: Lorentz-dual magnetic projection fields. - diagnostics: Numerical and physical diagnostics for the basis. - solve_dxes: Possibly complex-stretched grid used by the eigenproblem. - overlap_dxes: Real-space grid used for overlaps and normalization. - omega: Angular frequency used to construct the operator. - epsilon: Vectorized three-component relative permittivity. - mu: Optional vectorized relative permeability. - physical_mask: Transverse cells treated as outside the PML. - """ - - wavenumbers: NDArray[numpy.complex128] - wavenumber_matrix: NDArray[numpy.complex128] - electric_fields: NDArray[numpy.complex128] - magnetic_fields: NDArray[numpy.complex128] - dual_electric_fields: NDArray[numpy.complex128] - dual_magnetic_fields: NDArray[numpy.complex128] - diagnostics: ComplexModeDiagnostics - solve_dxes: dx_lists2_t - overlap_dxes: dx_lists2_t - omega: complex - epsilon: NDArray[numpy.complex128] - mu: NDArray[numpy.complex128] | None - physical_mask: NDArray[numpy.bool_] - - def __len__(self) -> int: - """Return the number of modal basis vectors.""" - return self.wavenumbers.size - - @property - def modes(self) -> tuple[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]], ...]: - """Return primal electric/magnetic field pairs in basis order.""" - return tuple(zip(self.electric_fields, self.magnetic_fields, strict=True)) - - @property - def dual_modes(self) -> tuple[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]], ...]: - """Return Lorentz-dual electric/magnetic field pairs in basis order.""" - return tuple(zip(self.dual_electric_fields, self.dual_magnetic_fields, strict=True)) - - -def operator_beta( - omega: complex, - dxes: dx_lists2_t, - epsilon: vfdslice, - mu: vfdslice | None = None, - ) -> sparse.sparray: - r"""Build the first-order transverse Maxwell operator ``M psi = beta psi``. - - The state ordering is ``[E_x, E_y, H_x, H_y]``. Longitudinal fields are - eliminated, but both transverse field families are retained so the sign of - ``beta`` remains explicit. Algebraically, ``M @ M`` has the existing - electric and magnetic ``beta**2`` waveguide operators on its diagonal. - - Args: - omega: Nonzero angular frequency. - dxes: Two-dimensional Yee-grid spacings, which may include complex PML - stretching. - epsilon: Vectorized three-component relative permittivity. - mu: Optional vectorized three-component relative permeability. Unit - permeability is used when omitted. - - Returns: - Sparse first-order signed-beta operator on transverse E/H states. - - Raises: - ValueError: If the material vectors, grid shape, or frequency are - incompatible. - """ - epsilon = numpy.asarray(epsilon) - if mu is None: - mu = numpy.ones_like(epsilon) - mu = numpy.asarray(mu) - if epsilon.ndim != 1 or mu.ndim != 1 or epsilon.shape != mu.shape or epsilon.size % 3: - raise ValueError('epsilon and mu must be equal-length vectorized three-component fields') - if omega == 0: - raise ValueError('omega must be nonzero') - - n_points = epsilon.size // 3 - if n_points != dxes[0][0].size * dxes[0][1].size: - raise ValueError('material and grid shapes do not match') - - dfx, dfy = deriv_forward(dxes[0]) - dbx, dby = deriv_back(dxes[1]) - zero = sparse.csr_array((n_points, n_points)) - identity = sparse.eye_array(n_points) - rotation = sparse.block_array([[zero, -identity], [identity, zero]], format='csr') - - eps_x, eps_y, eps_z = numpy.split(epsilon, 3) - mu_x, mu_y, mu_z = numpy.split(mu, 3) - eps_xy = sparse.diags_array(numpy.concatenate((eps_x, eps_y))) - mu_xy = sparse.diags_array(numpy.concatenate((mu_x, mu_y))) - eps_z_inv = sparse.diags_array(1 / eps_z) - mu_z_inv = sparse.diags_array(1 / mu_z) - - grad_forward = sparse.vstack((dfx, dfy), format='csr') - grad_backward = sparse.vstack((dbx, dby), format='csr') - rotated_div_backward = sparse.hstack((-dby, dbx), format='csr') - rotated_div_forward = sparse.hstack((-dfy, dfx), format='csr') - - beta_e_from_h = ( - -omega * rotation @ mu_xy - + grad_forward @ eps_z_inv @ rotated_div_backward / omega - ) - beta_h_from_e = ( - omega * rotation @ eps_xy - - grad_backward @ mu_z_inv @ rotated_div_forward / omega - ) - empty = sparse.csr_array((2 * n_points, 2 * n_points)) - return sparse.block_array( - [[empty, beta_e_from_h], [beta_h_from_e, empty]], - format = 'csr', - ) - - -def lorentz_metric(dxes: dx_lists2_t) -> sparse.sparray: - """Return the state metric matching the EME symmetric Lorentz overlap. - - Args: - dxes: Two-dimensional Yee-grid spacings used for physical overlaps. - - Returns: - Sparse bilinear metric on states ordered as ``[Ex, Ey, Hx, Hy]``. - """ - nx = dxes[0][0].size - ny = dxes[0][1].size - real_dxes = [[numpy.real(values) for values in family] for family in dxes] - weight_ex_hy = numpy.outer(real_dxes[1][0], real_dxes[0][1]).reshape(nx * ny) - weight_ey_hx = numpy.outer(real_dxes[0][0], real_dxes[1][1]).reshape(nx * ny) - diagonal_a = sparse.diags_array(weight_ex_hy) - diagonal_b = sparse.diags_array(weight_ey_hx) - empty_n = sparse.csr_array((nx * ny, nx * ny)) - # inner_product(E, H) = E.T @ q @ H; it already includes a factor 1/2. - q_matrix = 0.5 * sparse.block_array( - [[empty_n, diagonal_a], [-diagonal_b, empty_n]], - format = 'csr', - ) - empty_2n = sparse.csr_array(q_matrix.shape) - # _lorentz_overlap adds the two reaction products and multiplies by 1/2. - return 0.5 * sparse.block_array( - [[empty_2n, q_matrix], [q_matrix.T, empty_2n]], - format = 'csr', - ) - - -def _copy_dxes(dxes: dx_lists2_t) -> list[list[NDArray]]: - """Deep-copy the two Yee-grid spacing families.""" - return [[numpy.asarray(values).copy() for values in family] for family in dxes] - - -def _infer_physical_mask(dxes: dx_lists2_t) -> NDArray[numpy.bool_]: - """Infer the non-PML aperture from imaginary grid stretching.""" - pml_x = numpy.zeros(dxes[0][0].size, dtype=bool) - pml_y = numpy.zeros(dxes[0][1].size, dtype=bool) - for family in dxes: - pml_x |= numpy.abs(numpy.imag(family[0])) > 0 - pml_y |= numpy.abs(numpy.imag(family[1])) > 0 - return ~(pml_x[:, None] | pml_y[None, :]) - - -def _state_weights(dxes: dx_lists2_t, physical_mask: NDArray[numpy.bool_]) -> NDArray[numpy.float64]: - """Build cell-area weights for all four transverse state components.""" - dx = 0.5 * (numpy.real(dxes[0][0]) + numpy.real(dxes[1][0])) - dy = 0.5 * (numpy.real(dxes[0][1]) + numpy.real(dxes[1][1])) - weights = numpy.outer(dx, dy) * physical_mask - return numpy.tile(weights.ravel(), 4) - - -def _normalize_columns( - matrix: NDArray[numpy.complex128], - ) -> tuple[NDArray[numpy.complex128], NDArray[numpy.float64]]: - """Normalize matrix columns and return their original Euclidean norms.""" - norms = numpy.linalg.norm(matrix, axis=0) - normalized = numpy.divide(matrix, norms, out=numpy.zeros_like(matrix), where=norms != 0) - return normalized, numpy.asarray(norms, dtype=float) - - -def _select_unique_modes( - solutions: Sequence[TargetedEigensolution], - targets: NDArray[numpy.complex128], - modes_per_target: int, - *, - eigenvalue_rtol: float, - subspace_tol: float, - state_weights: NDArray[numpy.float64], - ) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128], NDArray[numpy.complex128], NDArray[numpy.int_]]: - """Merge targeted candidates into independent, cross-pairable eigenspaces.""" - sqrt_weights = numpy.sqrt(state_weights) - - def rank_and_pivots(vectors: list[NDArray[numpy.complex128]]) -> tuple[int, NDArray[numpy.int_]]: - """Estimate weighted subspace rank and QR pivot order.""" - if not vectors: - return 0, numpy.empty(0, dtype=int) - matrix = sqrt_weights[:, None] * numpy.column_stack(vectors) - matrix, _norms = _normalize_columns(matrix) - _q, triangular, pivots = linalg.qr(matrix, mode='economic', pivoting=True) - diagonal = numpy.abs(numpy.diag(triangular)) - if diagonal.size == 0 or diagonal[0] == 0: - return 0, numpy.asarray(pivots, dtype=int) - rank = int(numpy.count_nonzero(diagonal > subspace_tol * diagonal[0])) - return rank, numpy.asarray(pivots, dtype=int) - - def normalized_cross_gram( - right_vectors: list[NDArray[numpy.complex128]], - left_vectors: list[NDArray[numpy.complex128]], - ) -> NDArray[numpy.complex128]: - """Form the bilinear cross Gram matrix after column equilibration.""" - right = numpy.column_stack(right_vectors) - left = numpy.column_stack(left_vectors) - right, _right_norms = _normalize_columns(right) - left, _left_norms = _normalize_columns(left) - return left.T @ right - - def matrix_rank(matrix: NDArray[numpy.complex128]) -> int: - """Estimate matrix rank using the configured subspace tolerance.""" - singular_values = linalg.svdvals(matrix) - if singular_values.size == 0: - return 0 - cutoff = subspace_tol * max(1.0, float(singular_values[0])) - return int(numpy.count_nonzero(singular_values > cutoff)) - - initial_records: list[tuple[complex, NDArray[numpy.complex128], NDArray[numpy.complex128]]] = [] - remaining_records: list[tuple[complex, NDArray[numpy.complex128], NDArray[numpy.complex128]]] = [] - for target_index, (target, solution) in enumerate(zip(targets, solutions, strict=True)): - local_right: list[NDArray[numpy.complex128]] = [] - local_left: list[NDArray[numpy.complex128]] = [] - order = numpy.argsort(numpy.abs(solution.eigenvalues - target)) - stop_position: int | None = None - for position, candidate_index in enumerate(order): - value = complex(solution.eigenvalues[candidate_index]) - right_vector = solution.right_eigenvectors[:, candidate_index] - left_vector = solution.left_eigenvectors[:, candidate_index] - initial_records.append((value, right_vector, left_vector)) - local_right.append(right_vector) - local_left.append(left_vector) - right_rank, _right_pivots = rank_and_pivots(local_right) - left_rank, _left_pivots = rank_and_pivots(local_left) - if right_rank >= modes_per_target and left_rank >= modes_per_target: - stop_position = position - break - if stop_position is None: - raise RuntimeError(f'target {target_index} did not produce enough independent right and left modes') - for candidate_index in order[stop_position + 1:]: - remaining_records.append(( - complex(solution.eigenvalues[candidate_index]), - solution.right_eigenvectors[:, candidate_index], - solution.left_eigenvectors[:, candidate_index], - )) - - clusters: list[list[int]] = [] - for candidate_index, (value, _right, _left) in enumerate(initial_records): - for cluster in clusters: - anchor = initial_records[cluster[0]][0] - if abs(value - anchor) <= eigenvalue_rtol * max(1.0, abs(value), abs(anchor)): - cluster.append(candidate_index) - break - else: - clusters.append([candidate_index]) - - chosen_values: list[complex] = [] - chosen_right: list[NDArray[numpy.complex128]] = [] - chosen_left: list[NDArray[numpy.complex128]] = [] - for cluster in clusters: - anchor = initial_records[cluster[0]][0] - right_values = [initial_records[index][0] for index in cluster] - left_values = [initial_records[index][0] for index in cluster] - right_vectors = [initial_records[index][1] for index in cluster] - left_vectors = [initial_records[index][2] for index in cluster] - right_rank, _right_pivots = rank_and_pivots(right_vectors) - left_rank, _left_pivots = rank_and_pivots(left_vectors) - required_rank = max(right_rank, left_rank) - cross_gram = normalized_cross_gram(right_vectors, left_vectors) - cross_rank = matrix_rank(cross_gram) - for value, right_vector, left_vector in remaining_records: - if cross_rank >= required_rank: - break - if abs(value - anchor) > eigenvalue_rtol * max(1.0, abs(value), abs(anchor)): - continue - right_vectors.append(right_vector) - left_vectors.append(left_vector) - right_values.append(value) - left_values.append(value) - cross_gram = normalized_cross_gram(right_vectors, left_vectors) - cross_rank = matrix_rank(cross_gram) - if cross_rank < required_rank: - raise RuntimeError( - f'eigenvalue cluster near {anchor!r} has incompatible right/transpose-left ' - f'subspaces (required cross rank {required_rank}, available {cross_rank})', - ) - - _q, _triangular, right_pivots = linalg.qr(cross_gram, mode='economic', pivoting=True) - right_selection = numpy.sort(numpy.asarray(right_pivots[:required_rank], dtype=int)) - _q, _triangular, left_pivots = linalg.qr( - cross_gram[:, right_selection].T, - mode='economic', - pivoting=True, - ) - left_selection = numpy.asarray(left_pivots[:required_rank], dtype=int) - selected_cross_gram = cross_gram[numpy.ix_(left_selection, right_selection)] - selected_cross_rank = matrix_rank(selected_cross_gram) - if selected_cross_rank < required_rank: - raise RuntimeError( - f'eigenvalue cluster near {anchor!r} has incompatible right/transpose-left ' - f'subspaces (required cross rank {required_rank}, selected {selected_cross_rank})', - ) - selected_right_values = numpy.asarray(right_values)[right_selection] - selected_left_values = numpy.asarray(left_values)[left_selection] - rows, columns = linear_sum_assignment(numpy.abs(selected_right_values[:, None] - selected_left_values[None, :])) - paired_left = numpy.asarray(left_selection)[columns[numpy.argsort(rows)]] - chosen_values.extend(numpy.asarray(right_values)[right_selection]) - chosen_right.extend(right_vectors[int(index)] for index in right_selection) - chosen_left.extend(left_vectors[int(index)] for index in paired_left) - - values = numpy.asarray(chosen_values, dtype=complex) - target_indices = numpy.argmin(numpy.abs(values[:, None] - targets[None, :]), axis=1) - return ( - values, - numpy.column_stack(chosen_right), - numpy.column_stack(chosen_left), - numpy.asarray(target_indices, dtype=int), - ) - - -def _relative_residuals( - operator: sparse.sparray, - values: NDArray[numpy.complex128], - vectors: NDArray[numpy.complex128], - ) -> NDArray[numpy.float64]: - """Compute scale-aware eigenpair residuals for individual vectors.""" - residuals = numpy.empty(values.size, dtype=float) - for index, value in enumerate(values): - vector = vectors[:, index] - residuals[index] = numpy.linalg.norm(operator @ vector - value * vector) / ( - max(1.0, abs(value)) * numpy.linalg.norm(vector) - ) - return residuals - - -def solve_modes_near( - omega: complex, - solve_dxes: dx_lists2_t, - epsilon: vfdslice, - *, - target_wavenumbers: Sequence[complex], - modes_per_target: int, - mu: vfdslice | None = None, - overlap_dxes: dx_lists2_t | None = None, - physical_mask: NDArray[numpy.bool_] | None = None, - candidate_margin: int = 4, - eigensolver: targeted_eigensolver_t = scipy_eigs_near, - eigensolver_opts: dict[str, Any] | None = None, - residual_tol: float = 1e-8, - duplicate_eigenvalue_rtol: float = 1e-8, - duplicate_subspace_tol: float = 1e-7, - ) -> ComplexModeBasis: - """Solve for complex signed waveguide modes near one or more beta targets. - - Each target requests an independent candidate solve. Overlapping results - are merged by eigenvalue cluster and weighted subspace rank, after which - transpose-left eigenvectors are converted into a Lorentz-biorthogonal dual - basis. Returned fields use the full three-component vectorization, while - the underlying eigenproblem is ordered as ``[Ex, Ey, Hx, Hy]``. - - Args: - omega: Nonzero angular frequency. - solve_dxes: Yee-grid spacings used to build the possibly PML-stretched - eigenproblem. - epsilon: Vectorized three-component relative permittivity. - target_wavenumbers: Finite signed-beta targets for shift-invert solves. - modes_per_target: Independent candidates requested around each target. - mu: Optional vectorized three-component relative permeability. - overlap_dxes: Grid used for physical overlaps and normalization. By - default, the real parts of ``solve_dxes`` are used. - physical_mask: Optional transverse mask selecting the non-PML aperture. - By default, cells with imaginary grid stretching are excluded. - candidate_margin: Extra eigenpairs requested from each targeted solve - to support deduplication and degenerate-subspace repair. - eigensolver: Backend returning paired right and transpose-left vectors. - eigensolver_opts: Keyword arguments forwarded to ``eigensolver``. - residual_tol: Maximum accepted right, left, and dual-basis residual. - duplicate_eigenvalue_rtol: Relative tolerance for eigenvalue clustering. - duplicate_subspace_tol: Relative numerical-rank tolerance used while - merging repeated or degenerate candidates. - - Returns: - Complex modal basis with primal fields, Lorentz duals, propagation data, - copied solve metadata, and numerical diagnostics. - - Raises: - ValueError: If inputs or backend result shapes contain invalid values. - RuntimeError: If too few independent modes are found, right/left spaces - cannot be paired, or requested residual tolerances are not met. - """ - if modes_per_target < 1: - raise ValueError('modes_per_target must be positive') - if candidate_margin < 0: - raise ValueError('candidate_margin must be nonnegative') - if not numpy.isfinite(residual_tol) or residual_tol <= 0: - raise ValueError('residual_tol must be finite and positive') - if not numpy.isfinite(duplicate_eigenvalue_rtol) or duplicate_eigenvalue_rtol <= 0: - raise ValueError('duplicate_eigenvalue_rtol must be finite and positive') - if not numpy.isfinite(duplicate_subspace_tol) or duplicate_subspace_tol <= 0: - raise ValueError('duplicate_subspace_tol must be finite and positive') - targets = numpy.asarray(target_wavenumbers, dtype=complex) - if targets.ndim != 1 or targets.size == 0 or not numpy.isfinite(targets).all(): - raise ValueError('target_wavenumbers must be a finite nonempty one-dimensional sequence') - if overlap_dxes is None: - overlap_dxes = [[numpy.real(values) for values in family] for family in solve_dxes] - if physical_mask is None: - physical_mask = _infer_physical_mask(solve_dxes) - physical_mask = numpy.asarray(physical_mask, dtype=bool) - grid_shape = (solve_dxes[0][0].size, solve_dxes[0][1].size) - if physical_mask.shape != grid_shape: - raise ValueError('physical_mask must match the transverse grid shape') - - operator = operator_beta(omega, solve_dxes, epsilon, mu) - options = {} if eigensolver_opts is None else dict(eigensolver_opts) - solutions = [ - eigensolver(operator, complex(target), modes_per_target + candidate_margin, **options) - for target in targets - ] - for target_index, solution in enumerate(solutions): - count = numpy.asarray(solution.eigenvalues).size - if numpy.asarray(solution.eigenvalues).shape != (count,): - raise ValueError(f'eigensolver result {target_index} eigenvalues must be one-dimensional') - expected_shape = (operator.shape[0], count) - if numpy.asarray(solution.right_eigenvectors).shape != expected_shape or numpy.asarray(solution.left_eigenvectors).shape != expected_shape: - raise ValueError(f'eigensolver result {target_index} eigenvector shapes are invalid') - if count < modes_per_target: - raise RuntimeError(f'eigensolver result {target_index} returned too few candidates') - if not ( - numpy.isfinite(solution.eigenvalues).all() - and numpy.isfinite(solution.right_eigenvectors).all() - and numpy.isfinite(solution.left_eigenvectors).all() - ): - raise ValueError(f'eigensolver result {target_index} contains non-finite values') - merge_weights = _state_weights(overlap_dxes, numpy.ones(grid_shape, dtype=bool)) - values, right, left, target_indices = _select_unique_modes( - solutions, - targets, - modes_per_target, - eigenvalue_rtol = duplicate_eigenvalue_rtol, - subspace_tol = duplicate_subspace_tol, - state_weights = merge_weights, - ) - - right, right_norms = _normalize_columns(right) - left, left_norms = _normalize_columns(left) - if numpy.any(right_norms == 0) or numpy.any(left_norms == 0): - raise RuntimeError('selected right/transpose-left eigenbasis contains a zero vector') - gram = left.T @ right - condition = float(numpy.linalg.cond(gram)) - if not numpy.isfinite(condition) or condition > 1e12: - raise RuntimeError(f'left/right eigenbasis is singular or ill-conditioned ({condition=:.3e})') - left = linalg.solve(gram, left.T).T - - weights = _state_weights(overlap_dxes, physical_mask) - for index in range(values.size): - vector = right[:, index] - norm_value = numpy.sqrt(numpy.sum(weights * numpy.abs(vector) ** 2)) - if norm_value == 0: - raise RuntimeError(f'mode {index} has no support in the physical aperture') - weighted_vector = numpy.where(weights > 0, vector, 0) - phase_sample = weighted_vector[numpy.argmax(numpy.abs(weighted_vector))] - scale = numpy.exp(-1j * numpy.angle(phase_sample)) / norm_value - right[:, index] *= scale - left[:, index] /= scale - - right_residuals = _relative_residuals(operator, values, right) - left_residuals = _relative_residuals(operator.T, values, left) - if numpy.max(right_residuals) > residual_tol or numpy.max(left_residuals) > residual_tol: - raise RuntimeError('targeted eigenmodes did not meet the requested residual tolerance') - - metric = lorentz_metric(overlap_dxes).tocsc() - dual_states = numpy.asarray(splinalg.spsolve(metric.T, left)) - if dual_states.ndim == 1: - dual_states = dual_states[:, None] - biorthogonal_gram = dual_states.T @ metric @ right - biorthogonality_error = float(numpy.max(numpy.abs(biorthogonal_gram - numpy.eye(values.size)))) - if biorthogonality_error > residual_tol: - raise RuntimeError('failed to construct a biorthogonal Lorentz dual basis') - - n_points = grid_shape[0] * grid_shape[1] - electric_fields = numpy.empty((values.size, 3 * n_points), dtype=complex) - magnetic_fields = numpy.empty_like(electric_fields) - dual_electric_fields = numpy.zeros_like(electric_fields) - dual_magnetic_fields = numpy.zeros_like(electric_fields) - epsilon_array = numpy.asarray(epsilon) - mu_array = None if mu is None else numpy.asarray(mu) - for index, beta in enumerate(values): - e_xy = right[:2 * n_points, index] - h_xy = right[2 * n_points:, index] - electric_fields[index] = waveguide_2d.exy2e(beta, solve_dxes, epsilon_array) @ e_xy - magnetic_fields[index] = waveguide_2d.hxy2h(beta, solve_dxes, mu_array) @ h_xy - dual_electric_fields[index, :2 * n_points] = dual_states[:2 * n_points, index] - dual_magnetic_fields[index, :2 * n_points] = dual_states[2 * n_points:, index] - - powers = numpy.asarray([ - waveguide_2d.inner_product(e_field, h_field, overlap_dxes, conj_h=True) - for e_field, h_field in zip(electric_fields, magnetic_fields, strict=True) - ]) - full_weights = numpy.tile(numpy.outer( - 0.5 * (numpy.real(overlap_dxes[0][0]) + numpy.real(overlap_dxes[1][0])), - 0.5 * (numpy.real(overlap_dxes[0][1]) + numpy.real(overlap_dxes[1][1])), - ).ravel(), 4) - pml_weights = full_weights * numpy.tile(~physical_mask.ravel(), 4) - pml_participation = numpy.sum(pml_weights[:, None] * numpy.abs(right) ** 2, axis=0) / numpy.sum( - full_weights[:, None] * numpy.abs(right) ** 2, - axis = 0, - ) - target_distances = numpy.abs(values - targets[target_indices]) - - diagnostics = ComplexModeDiagnostics( - right_residuals = right_residuals, - left_residuals = left_residuals, - powers = powers, - pml_participation = numpy.asarray(pml_participation, dtype=float), - target_indices = target_indices, - target_distances = numpy.asarray(target_distances, dtype=float), - biorthogonality_error = biorthogonality_error, - biorthogonality_condition = condition, - ) - return ComplexModeBasis( - wavenumbers = values, - wavenumber_matrix = numpy.diag(values), - electric_fields = electric_fields, - magnetic_fields = magnetic_fields, - dual_electric_fields = dual_electric_fields, - dual_magnetic_fields = dual_magnetic_fields, - diagnostics = diagnostics, - solve_dxes = _copy_dxes(solve_dxes), - overlap_dxes = _copy_dxes(overlap_dxes), - omega = complex(omega), - epsilon = numpy.asarray(epsilon, dtype=complex).copy(), - mu = None if mu is None else numpy.asarray(mu, dtype=complex).copy(), - physical_mask = physical_mask.copy(), - ) - - -def _block_relative_residuals( - operator: sparse.sparray, - matrix: NDArray[numpy.complex128], - vectors: NDArray[numpy.complex128], - ) -> NDArray[numpy.float64]: - """Compute column residuals for an invariant subspace representation.""" - image = operator @ vectors - expected = vectors @ matrix - numerator = numpy.linalg.norm(image - expected, axis=0) - denominator = numpy.maximum(numpy.linalg.norm(image, axis=0), numpy.linalg.norm(expected, axis=0)) - return numpy.asarray(numerator / numpy.maximum(denominator, numpy.finfo(float).tiny), dtype=float) - - -def _refresh_diagnostics(basis: ComplexModeBasis) -> ComplexModeBasis: - """Recompute basis-dependent residual, power, PML, and Gram diagnostics.""" - n_points = basis.electric_fields.shape[1] // 3 - right = numpy.vstack((basis.electric_fields[:, :2 * n_points].T, basis.magnetic_fields[:, :2 * n_points].T)) - dual = numpy.vstack((basis.dual_electric_fields[:, :2 * n_points].T, basis.dual_magnetic_fields[:, :2 * n_points].T)) - operator = operator_beta(basis.omega, basis.solve_dxes, basis.epsilon, basis.mu) - metric = lorentz_metric(basis.overlap_dxes) - left = metric.T @ dual - gram = dual.T @ metric @ right - normalized_right, right_norms = _normalize_columns(right) - normalized_left, left_norms = _normalize_columns(left) - if numpy.any(right_norms == 0) or numpy.any(left_norms == 0): - biorthogonality_condition = numpy.inf - else: - normalized_gram = normalized_left.T @ normalized_right - biorthogonality_condition = float(numpy.linalg.cond(normalized_gram)) - powers = numpy.asarray([ - waveguide_2d.inner_product(e_field, h_field, basis.overlap_dxes, conj_h=True) - for e_field, h_field in basis.modes - ]) - full_weights = numpy.tile(numpy.outer( - 0.5 * (numpy.real(basis.overlap_dxes[0][0]) + numpy.real(basis.overlap_dxes[1][0])), - 0.5 * (numpy.real(basis.overlap_dxes[0][1]) + numpy.real(basis.overlap_dxes[1][1])), - ).ravel(), 4) - pml_weights = full_weights * numpy.tile(~basis.physical_mask.ravel(), 4) - pml_participation = numpy.sum(pml_weights[:, None] * numpy.abs(right) ** 2, axis=0) / numpy.sum( - full_weights[:, None] * numpy.abs(right) ** 2, - axis = 0, - ) - diagnostics = dataclasses.replace( - basis.diagnostics, - right_residuals = _block_relative_residuals(operator, basis.wavenumber_matrix, right), - left_residuals = _block_relative_residuals(operator.T, basis.wavenumber_matrix.T, left), - powers = powers, - pml_participation = numpy.asarray(pml_participation, dtype=float), - biorthogonality_error = float(numpy.max(numpy.abs(gram - numpy.eye(len(basis))))), - biorthogonality_condition = biorthogonality_condition, - ) - return dataclasses.replace(basis, diagnostics=diagnostics) - - -def _transform_basis( - basis: ComplexModeBasis, - transform: NDArray[numpy.complex128], - *, - spectrum_order: NDArray[numpy.int_] | None = None, - ) -> ComplexModeBasis: - """Apply a modal basis transform and its contragredient dual transform.""" - transform = numpy.asarray(transform, dtype=complex) - if transform.shape != (len(basis), len(basis)) or not numpy.isfinite(transform).all(): - raise ValueError('basis transform must be a finite square mode matrix') - inverse = numpy.linalg.inv(transform) - diagnostics = basis.diagnostics - wavenumbers = basis.wavenumbers - if spectrum_order is not None: - wavenumbers = wavenumbers[spectrum_order] - diagnostics = dataclasses.replace( - diagnostics, - target_indices = diagnostics.target_indices[spectrum_order], - target_distances = diagnostics.target_distances[spectrum_order], - ) - transformed = dataclasses.replace( - basis, - wavenumbers = wavenumbers, - wavenumber_matrix = inverse @ basis.wavenumber_matrix @ transform, - electric_fields = transform.T @ basis.electric_fields, - magnetic_fields = transform.T @ basis.magnetic_fields, - dual_electric_fields = inverse @ basis.dual_electric_fields, - dual_magnetic_fields = inverse @ basis.dual_magnetic_fields, - diagnostics = diagnostics, - ) - return _refresh_diagnostics(transformed) - - -def _scaled_basis(basis: ComplexModeBasis, scales: NDArray[numpy.complex128]) -> ComplexModeBasis: - """Scale primal basis vectors and reciprocally scale their Lorentz duals.""" - return _transform_basis(basis, numpy.diag(scales)) - - -def power_normalize_modes( - basis: ComplexModeBasis, - indices: Sequence[int], - *, - power_atol: float = 1e-12, - power_rtol: float = 1e-6, - ) -> ComplexModeBasis: - """Normalize selected positive-forward-power modes to unit power. - - Primal fields are scaled by the inverse square root of their real power and - Lorentz duals are scaled reciprocally, preserving biorthogonality and the - represented beta operator. - - Args: - basis: Modal basis to copy and rescale. - indices: Basis-vector indices to normalize. - power_atol: Absolute threshold for positive power and negligible - imaginary power. - power_rtol: Relative threshold for negligible imaginary power. - - Returns: - A transformed basis with refreshed diagnostics. - - Raises: - ValueError: If tolerances are invalid or a selected mode does not carry - positive, nearly real forward power. - """ - if not numpy.isfinite(power_atol) or power_atol <= 0: - raise ValueError('power_atol must be finite and positive') - if not numpy.isfinite(power_rtol) or power_rtol <= 0: - raise ValueError('power_rtol must be finite and positive') - scales = numpy.ones(len(basis), dtype=complex) - for index in indices: - power = basis.diagnostics.powers[index] - if power.real <= power_atol or abs(power.imag) > max(power_atol, power_rtol * abs(power.real)): - raise ValueError(f'mode {index} does not have positive nearly-real forward power') - scales[index] = 1 / numpy.sqrt(power.real) - return _scaled_basis(basis, scales) - - -def _lorentz_overlap( - dual_mode: tuple[vcfdfield2, vcfdfield2], - mode: tuple[vcfdfield2, vcfdfield2], - dxes: dx_lists2_t, - ) -> complex: - """Evaluate a dual/primal symmetric unconjugated Lorentz overlap.""" - return 0.5 * ( - waveguide_2d.inner_product(dual_mode[0], mode[1], dxes, conj_h=False) - + waveguide_2d.inner_product(mode[0], dual_mode[1], dxes, conj_h=False) - ) - - -def _reorder_basis(basis: ComplexModeBasis, order: NDArray[numpy.int_]) -> ComplexModeBasis: - """Permute a basis and its associated spectral labels.""" - transform = numpy.eye(len(basis), dtype=complex)[:, order] - return _transform_basis(basis, transform, spectrum_order=order) - - -def _degenerate_clusters( - previous: NDArray[numpy.complex128], - current: NDArray[numpy.complex128], - rtol: float, - ) -> list[list[int]]: - """Find connected mode groups degenerate in two adjacent spectra.""" - count = previous.size - remaining = set(range(count)) - clusters: list[list[int]] = [] - while remaining: - seed = remaining.pop() - cluster = {seed} - changed = True - while changed: - changed = False - for candidate in tuple(remaining): - if any( - abs(previous[candidate] - previous[item]) <= rtol * max(1.0, abs(previous[candidate]), abs(previous[item])) - and abs(current[candidate] - current[item]) <= rtol * max(1.0, abs(current[candidate]), abs(current[item])) - for item in cluster - ): - remaining.remove(candidate) - cluster.add(candidate) - changed = True - clusters.append(sorted(cluster)) - return clusters - - -def track_mode_path( - bases: Sequence[ComplexModeBasis], - *, - degeneracy_rtol: float = 1e-6, - minimum_overlap: float = 1e-3, - ) -> tuple[ComplexModeBasis, ...]: - """Assign, align, and parallel-transport modal bases along a path. - - Adjacent modes are assigned by Lorentz overlap, with beta distance used as - a deterministic tie-breaker. Nearly degenerate groups are aligned by a - unitary Procrustes rotation, so their propagation operator may become - non-diagonal in the transported basis. - - Args: - bases: Equal-sized modal bases sampled in path order. - degeneracy_rtol: Relative spectral tolerance used to group modes for - subspace rotation. - minimum_overlap: Minimum accepted adjacent diagonal overlap after - assignment and subspace alignment. - - Returns: - Basis sequence in tracked order with aligned phases, contragredient - duals, transformed beta matrices, and refreshed diagnostics. - - Raises: - ValueError: If the sequence, tolerances, field shapes, or overlap grids - are incompatible. - RuntimeError: If an assigned mode has insufficient adjacent overlap. - """ - if not bases: - raise ValueError('bases must not be empty') - if not numpy.isfinite(degeneracy_rtol) or degeneracy_rtol <= 0: - raise ValueError('degeneracy_rtol must be finite and positive') - if not numpy.isfinite(minimum_overlap) or minimum_overlap <= 0: - raise ValueError('minimum_overlap must be finite and positive') - count = len(bases[0]) - if count == 0 or any(len(basis) != count for basis in bases): - raise ValueError('all bases must contain the same nonzero mode count') - reference = bases[0] - for index, basis in enumerate(bases[1:], start=1): - if basis.electric_fields.shape != reference.electric_fields.shape or basis.magnetic_fields.shape != reference.magnetic_fields.shape: - raise ValueError(f'bases[{index}] modal field shapes do not match bases[0]') - for family in range(2): - for axis in range(2): - left_grid = numpy.asarray(reference.overlap_dxes[family][axis]) - right_grid = numpy.asarray(basis.overlap_dxes[family][axis]) - if left_grid.shape != right_grid.shape or not numpy.allclose(left_grid, right_grid, rtol=1e-12, atol=0): - raise ValueError('all bases must use identical overlap grids; resample fields before tracking') - - tracked = [bases[0]] - for current_unordered in bases[1:]: - previous = tracked[-1] - overlap = numpy.asarray([ - [_lorentz_overlap(dual_mode, mode, previous.overlap_dxes) for mode in current_unordered.modes] - for dual_mode in previous.dual_modes - ]) - beta_scale = numpy.maximum(1.0, numpy.maximum( - numpy.abs(previous.wavenumbers[:, None]), - numpy.abs(current_unordered.wavenumbers[None, :]), - )) - beta_distance = numpy.abs(previous.wavenumbers[:, None] - current_unordered.wavenumbers[None, :]) / beta_scale - cost = -numpy.abs(overlap) + 1e-9 * beta_distance - rows, columns = linear_sum_assignment(cost) - order = columns[numpy.argsort(rows)] - permutation = numpy.eye(count, dtype=complex)[:, order] - reordered_e = current_unordered.electric_fields[order] - reordered_h = current_unordered.magnetic_fields[order] - reordered_wavenumbers = current_unordered.wavenumbers[order] - - cluster_transform = numpy.eye(count, dtype=complex) - for cluster in _degenerate_clusters(previous.wavenumbers, reordered_wavenumbers, degeneracy_rtol): - if len(cluster) == 1: - continue - indices = numpy.asarray(cluster) - cross = numpy.asarray([ - [_lorentz_overlap(previous.dual_modes[ii], (reordered_e[jj], reordered_h[jj]), previous.overlap_dxes) for jj in indices] - for ii in indices - ]) - left_u, _singular_values, right_vh = numpy.linalg.svd(cross) - transform = right_vh.conj().T @ left_u.conj().T - cluster_transform[numpy.ix_(indices, indices)] = transform - - aligned_e = cluster_transform.T @ reordered_e - aligned_h = cluster_transform.T @ reordered_h - diagonal_overlap = numpy.asarray([ - _lorentz_overlap(previous.dual_modes[index], (aligned_e[index], aligned_h[index]), previous.overlap_dxes) - for index in range(count) - ]) - if numpy.any(numpy.abs(diagonal_overlap) < minimum_overlap): - bad = numpy.flatnonzero(numpy.abs(diagonal_overlap) < minimum_overlap) - raise RuntimeError(f'could not track modes {bad.tolist()}: adjacent overlap is too small') - phase_scales = numpy.exp(-1j * numpy.angle(diagonal_overlap)) - transform = permutation @ cluster_transform @ numpy.diag(phase_scales) - tracked.append(_transform_basis(current_unordered, transform, spectrum_order=order)) - - return tuple(tracked) diff --git a/meanas/fdfd/waveguide_3d.py b/meanas/fdfd/waveguide_3d.py index 3f7a29b..019a7c7 100644 --- a/meanas/fdfd/waveguide_3d.py +++ b/meanas/fdfd/waveguide_3d.py @@ -21,7 +21,7 @@ The intended workflow is: 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, TypedDict, cast +from typing import Any, TypedDict, cast, Literal import warnings import operator from collections.abc import Callable, Sequence @@ -78,40 +78,86 @@ def solve_mode( The returned fields are normalized through the `waveguide_2d` normalization convention before being expanded back to 3D. """ + args_2d, _order, _reverse, _dx_prop = _mode_cross_section(omega, dxes, axis, slices, epsilon, mu) + e_xy, wavenumber_2d = waveguide_2d.solve_mode(mode_number, **args_2d) + return mode_from_2d(e_xy, wavenumber_2d, omega=omega, dxes=dxes, axis=axis, + polarity=polarity, slices=slices, epsilon=epsilon, mu=mu) + + +def _mode_cross_section( + omega: complex, + dxes: dx_lists_t, + axis: int, + slices: Sequence[slice], + epsilon: fdfield, + mu: fdfield | None, + ) -> tuple[dict[str, Any], NDArray, NDArray, complex]: + if axis not in range(3): + raise ValueError('axis must be 0, 1, or 2') + if numpy.ndim(epsilon) != 4 or epsilon.shape[0] != 3: + raise ValueError('epsilon must have shape (3, nx, ny, nz)') + slices = tuple(slices) + if len(slices) != 3 or any(not isinstance(part, slice) for part in slices): + raise ValueError('slices must contain three slices') + sizes = [len(range(size)[part]) for size, part in zip(epsilon.shape[1:], slices, strict=True)] + if sizes[axis] != 1 or any(size == 0 for size in sizes): + raise ValueError('slices must select one propagation cell and a nonempty cross-section') if mu is None: mu = numpy.ones_like(epsilon) - - slices = tuple(slices) - - # - # Solve the 2D problem in the specified plane - # - # Define rotation to set z as propagation direction + elif mu.shape != epsilon.shape: + raise ValueError('mu must match epsilon') order = numpy.roll(range(3), 2 - axis) - reverse_order = numpy.roll(range(3), axis - 2) - - # Find dx in propagation direction - dxab_forward = numpy.array([dx[order[2]][slices[order[2]]] for dx in dxes]) - dx_prop = 0.5 * dxab_forward.sum() - - # Reduce to 2D and solve the 2D problem - args_2d: dict[str, Any] = { + reverse = numpy.roll(range(3), axis - 2) + dx_prop = 0.5 * sum(dx[axis][slices[axis]].sum() for dx in dxes) + if not numpy.isfinite(dx_prop) or dx_prop == 0: + raise ValueError('propagation cell width must be finite and nonzero') + args: dict[str, Any] = { 'omega': omega, 'dxes': [[dx[i][slices[i]] for i in order[:2]] for dx in dxes], 'epsilon': vec([epsilon[i][slices].transpose(order) for i in order]), 'mu': vec([mu[i][slices].transpose(order) for i in order]), } - e_xy, wavenumber_2d = waveguide_2d.solve_mode(mode_number, **args_2d) + return args, order, reverse, dx_prop - # - # Apply corrections and expand to 3D - # +def mode_from_2d( + e_xy: NDArray, + wavenumber_2d: complex, + *, + omega: complex, + dxes: dx_lists_t, + axis: int, + polarity: int, + slices: Sequence[slice], + epsilon: fdfield, + mu: fdfield | None = None, + normalization: Literal['power', 'lorentz'] = 'power', + ) -> Waveguide3DMode: + """Convert a supplied 2D electric eigenmode to a staggered full-grid mode. + + ``e_xy`` contains vectorized Ex/Ey in the rotated plane (the order used by + solve_mode). It must solve the 2D problem extracted from ``epsilon`` and + ``dxes`` at ``slices``. No eigensolver is called. The helper reconstructs E/H, + normalizes them, applies propagation-grid dispersion and half-cell shifts, + rotates components, and embeds them in the full epsilon-shaped grid. + + ``polarity`` is +1 toward increasing indices and -1 toward decreasing + indices. ``normalization`` follows waveguide_2d.normalized_fields_e. + """ + if polarity not in (-1, 1): + raise ValueError('polarity must be -1 or +1') + args_2d, _order, reverse_order, dx_prop = _mode_cross_section(omega, dxes, axis, slices, epsilon, mu) + slices = tuple(slices) + expected = 2 * numpy.prod([len(values) for values in args_2d['dxes'][0]]) + e_xy = numpy.asarray(e_xy) + if e_xy.shape != (expected,) or not numpy.isfinite(e_xy).all() or not numpy.isfinite(wavenumber_2d): + raise ValueError('the 2D eigenvector and wavenumber must be finite and match the cross-section') + wavenumber_2d = complex(wavenumber_2d) # Correct wavenumber to account for numerical dispersion. wavenumber = 2 / dx_prop * numpy.arcsin(wavenumber_2d * dx_prop / 2) shape = [d.size for d in args_2d['dxes'][0]] - ve, vh = waveguide_2d.normalized_fields_e(e_xy, wavenumber=wavenumber_2d, prop_phase=dx_prop * wavenumber, **args_2d) + ve, vh = waveguide_2d.normalized_fields_e(e_xy, wavenumber=wavenumber_2d, prop_phase=dx_prop * wavenumber, normalization=normalization, **args_2d) e = unvec(ve, shape) h = unvec(vh, shape) @@ -163,6 +209,8 @@ def compute_source( polarity: Propagation direction (+1 for +ve, -1 for -ve) 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. + epsilon: Dielectric distribution on the same full grid as E and dxes. + Used by the boundary-source operator. mu: Magnetic permeability (default 1 everywhere) Returns: @@ -587,7 +635,7 @@ def get_s_fdfd( """ Extract a two-port guided-mode scattering matrix from local 3D FDFD solves. - The returned S-matrix is ordered as `[[R12, T12], [T21, R21]]`, with + The returned S-matrix is ordered as `[[R_L, T_R_to_L], [T_L_to_R, R_R]]`, with reference planes at the supplied monitor slices. Each column is normalized by the incident modal amplitude measured on the corresponding input monitor. """ diff --git a/meanas/fdmath/functional.py b/meanas/fdmath/functional.py index 27d368a..dacde37 100644 --- a/meanas/fdmath/functional.py +++ b/meanas/fdmath/functional.py @@ -13,52 +13,61 @@ from numpy import floating, complexfloating from .types import fdfield, fdfield_updater_t +def _derivative(axis: int, forward: bool, widths: NDArray | None) -> fdfield_updater_t: + """Periodic difference using views, with one output allocation at most.""" + def derivative(f: NDArray, *, out: NDArray | None = None) -> NDArray: + f = numpy.asarray(f) + if f.ndim != 3 or f.shape[axis] == 0: + raise ValueError('derivatives require a nonempty three-dimensional axis') + if widths is not None and (numpy.ndim(widths) != 1 or len(widths) != f.shape[axis]): + raise ValueError('cell widths must match the differentiated axis') + dtype = f.dtype if widths is None else numpy.true_divide.resolve_dtypes((f.dtype, numpy.asarray(widths).dtype, None))[2] + if out is None: + output = numpy.empty(f.shape, dtype=dtype) + else: + output = numpy.asarray(out) + if output.shape != f.shape: + raise ValueError('out must match the field shape') + if numpy.shares_memory(f, output) or (widths is not None and numpy.shares_memory(widths, output)): + raise ValueError('out must not alias the field or widths') + if not output.flags.writeable or not numpy.can_cast(dtype, output.dtype, casting='same_kind'): + raise ValueError('out must be writable with a compatible dtype') + low, high = [slice(None)] * 3, [slice(None)] * 3 + low[axis], high[axis] = slice(None, -1), slice(1, None) + first, last = [slice(None)] * 3, [slice(None)] * 3 + first[axis], last[axis] = slice(0, 1), slice(-1, None) + destination = low if forward else high + numpy.subtract(f[tuple(high)], f[tuple(low)], out=output[tuple(destination)]) + destination = last if forward else first + numpy.subtract(f[tuple(first)], f[tuple(last)], out=output[tuple(destination)]) + if widths is not None: + expand = [1, 1, 1] + expand[axis] = len(widths) + numpy.divide(output, numpy.asarray(widths).reshape(expand), out=output) + return output + return derivative + + def deriv_forward( dx_e: Sequence[NDArray[floating | complexfloating]] | None = None, ) -> tuple[fdfield_updater_t, fdfield_updater_t, fdfield_updater_t]: - """ - Utility operators for taking discretized derivatives (backward variant). + """Forward periodic derivatives divided by electric Yee widths. - Args: - dx_e: Lists of cell sizes for all axes - `[[dx_0, dx_1, ...], [dy_0, dy_1, ...], ...]`. - - Returns: - List of functions for taking forward derivatives along each axis. + Each callable accepts ``derivative(field, *, out=None)``. An output buffer + must match the field shape and must not alias the field or width arrays. + Slice-based subtraction avoids a full-size roll temporary. """ - if dx_e is not None: - derivs = (lambda f: (numpy.roll(f, -1, axis=0) - f) / dx_e[0][:, None, None], - lambda f: (numpy.roll(f, -1, axis=1) - f) / dx_e[1][None, :, None], - lambda f: (numpy.roll(f, -1, axis=2) - f) / dx_e[2][None, None, :]) - else: - derivs = (lambda f: numpy.roll(f, -1, axis=0) - f, - lambda f: numpy.roll(f, -1, axis=1) - f, - lambda f: numpy.roll(f, -1, axis=2) - f) - return derivs + return tuple(_derivative(axis, True, None if dx_e is None else dx_e[axis]) for axis in range(3)) def deriv_back( dx_h: Sequence[NDArray[floating | complexfloating]] | None = None, ) -> tuple[fdfield_updater_t, fdfield_updater_t, fdfield_updater_t]: - """ - Utility operators for taking discretized derivatives (forward variant). + """Backward periodic derivatives divided by magnetic Yee widths. - Args: - dx_h: Lists of cell sizes for all axes - `[[dx_0, dx_1, ...], [dy_0, dy_1, ...], ...]`. - - Returns: - List of functions for taking forward derivatives along each axis. + Callables accept the same non-aliasing ``out`` buffer as deriv_forward. """ - if dx_h is not None: - derivs = (lambda f: (f - numpy.roll(f, 1, axis=0)) / dx_h[0][:, None, None], - lambda f: (f - numpy.roll(f, 1, axis=1)) / dx_h[1][None, :, None], - lambda f: (f - numpy.roll(f, 1, axis=2)) / dx_h[2][None, None, :]) - else: - derivs = (lambda f: f - numpy.roll(f, 1, axis=0), - lambda f: f - numpy.roll(f, 1, axis=1), - lambda f: f - numpy.roll(f, 1, axis=2)) - return derivs + return tuple(_derivative(axis, False, None if dx_h is None else dx_h[axis]) for axis in range(3)) TT = TypeVar('TT', bound='NDArray[floating | complexfloating]') diff --git a/meanas/fdtd/pml.py b/meanas/fdtd/pml.py index aba9cb7..679bb25 100644 --- a/meanas/fdtd/pml.py +++ b/meanas/fdtd/pml.py @@ -41,6 +41,7 @@ def cpml_params( m: float = 3.5, ma: float = 1, cfs_alpha: float = 0, + dx: float = 1.0, ) -> dict[str, Any]: """ Construct the parameter block for one CPML face. @@ -57,6 +58,9 @@ def cpml_params( 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. + dx: Physical cell width normal to this face, uniform within the CPML. + The default assumes unit cells. Arbitrary nonuniform grading is + not supported by this parameter constructor. Returns: Dictionary with: @@ -78,7 +82,10 @@ def cpml_params( if epsilon_eff <= 0: raise ValueError('epsilon_eff must be positive') - sigma_max = -ln_R_per_layer / 2 * (m + 1) + if not numpy.isfinite(dx) or dx <= 0: + raise ValueError('dx must be finite and positive') + + sigma_max = -ln_R_per_layer / (2 * dx) * (m + 1) kappa_max = numpy.sqrt(epsilon_eff * mu_eff) alpha_max = cfs_alpha @@ -152,7 +159,7 @@ def updates_with_cpml( The closures retain the CPML auxiliary state internally. """ - Dfx, Dfy, Dfz = deriv_forward(dxes[1]) + Dfx, Dfy, Dfz = deriv_forward(dxes[0]) Dbx, Dby, Dbz = deriv_back(dxes[1]) psi_E: list[list[tuple[Any, Any]]] = [[(None, None) for _ in range(2)] for _ in range(3)] diff --git a/meanas/test/test_eme_numerics.py b/meanas/test/test_eme_numerics.py index 45ad7cc..b2c078d 100644 --- a/meanas/test/test_eme_numerics.py +++ b/meanas/test/test_eme_numerics.py @@ -2,7 +2,6 @@ from typing import cast import numpy import pytest -from scipy import sparse from ..fdmath import vec from ..fdfd import eme, waveguide_2d, waveguide_3d, waveguide_cyl @@ -33,34 +32,11 @@ def _mode_sets() -> tuple[list[tuple[numpy.ndarray, numpy.ndarray]], list[tuple[ return left_modes, right_modes -def _gain_only_tr(*args, **kwargs) -> tuple[numpy.ndarray, numpy.ndarray]: - return numpy.array([[2.0, 0.0], [0.0, 0.5]]), numpy.zeros((2, 2)) - - -def _gain_and_reflection_tr(*args, **kwargs) -> tuple[numpy.ndarray, numpy.ndarray]: - return numpy.array([[2.0, 0.0], [0.0, 0.5]]), numpy.array([[0.0, 1.0], [2.0, 0.0]]) - - -def _nonsymmetric_tr(left_marker: object): - def fake_get_tr(_eh_left, wavenumbers_left, _eh_right, _wavenumbers_right, **kwargs): - if wavenumbers_left is left_marker: - return ( - numpy.array([[1.0, 2.0], [0.5, 1.0]]), - numpy.array([[0.0, 1.0], [2.0, 0.0]]), - ) - return ( - numpy.array([[1.0, -1.0], [0.0, 1.0]]), - numpy.array([[0.0, 0.5], [1.5, 0.0]]), - ) - - return fake_get_tr - - def _dummy_modes() -> tuple[list[tuple[numpy.ndarray, numpy.ndarray]], numpy.ndarray]: return [_mode(0.0), _mode(0.7)], numpy.array([1.0, 0.5]) -def test_get_tr_returns_finite_bounded_transfer_matrices() -> None: +def test_get_tr_returns_finite_coordinate_transfer_matrices() -> None: left_modes, right_modes = _mode_sets() transmission, reflection = eme.get_tr( @@ -77,7 +53,8 @@ def test_get_tr_returns_finite_bounded_transfer_matrices() -> None: assert reflection.shape == (2, 2) assert numpy.isfinite(transmission).all() assert numpy.isfinite(reflection).all() - assert (singular_values <= 1.0 + 1e-12).all() + # These arbitrary fields are not power-normalized: coefficients may exceed one. + assert singular_values.max() > 1.0 def test_get_tr_accepts_scaled_dual_projection_modes() -> None: @@ -98,73 +75,53 @@ def test_get_tr_accepts_scaled_dual_projection_modes() -> None: ) assert_close(dual_t, plain_t) - assert_close(dual_r, plain_r) + assert_close(dual_r, plain_r, atol=1e-11) -def test_get_abcd_matches_explicit_block_formula() -> None: +def test_get_abcd_maps_left_state_to_right_state() -> None: left_modes, right_modes = _mode_sets() - t12, r12 = eme.get_tr(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, dxes=DXES) - t21, r21 = eme.get_tr(right_modes, WAVENUMBERS_R, left_modes, WAVENUMBERS_L, dxes=DXES) - t21_inv = numpy.linalg.pinv(t21) - - expected = numpy.block([ - [t12 - r21 @ t21_inv @ r12, r21 @ t21_inv], - [-t21_inv @ r12, t21_inv], - ]) - abcd = eme.get_abcd(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, dxes=DXES) - - assert sparse.issparse(abcd) - assert abcd.shape == (4, 4) - assert_close(abcd.toarray(), expected) + ss = eme.get_s(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, DXES) + transfer = eme.get_abcd(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, DXES).toarray() + incoming = numpy.array([1.0, .3j, -.4j, .2]) + outgoing = ss @ incoming + state_left = numpy.concatenate((incoming[:2], outgoing[:2])) + state_right = numpy.concatenate((outgoing[2:], incoming[2:])) + assert_close(transfer @ state_left, state_right, atol=1e-11) -def test_get_s_plain_matches_block_assembly_from_get_tr() -> None: +def test_get_s_left_incidence_matches_get_tr() -> None: left_modes, right_modes = _mode_sets() - t12, r12 = eme.get_tr(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, dxes=DXES) - t21, r21 = eme.get_tr(right_modes, WAVENUMBERS_R, left_modes, WAVENUMBERS_L, dxes=DXES) - expected = numpy.block([[r12, t12], [t21, r21]]) - - ss = eme.get_s(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, dxes=DXES) - + transmission, reflection = eme.get_tr(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, DXES) + ss = eme.get_s(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, DXES) assert ss.shape == (4, 4) - assert numpy.isfinite(ss).all() - assert_close(ss, expected) + assert_close(ss[:2, :2], reflection, atol=1e-11) + assert_close(ss[2:, :2], transmission, atol=1e-11) def test_get_s_force_nogain_caps_singular_values(monkeypatch) -> None: - monkeypatch.setattr(eme, 'get_tr', _gain_only_tr) + raw = numpy.diag([2., .5, 1.2, .3]) + monkeypatch.setattr(eme, '_interface_s', lambda *args, **kwargs: raw.copy()) modes, wavenumbers = _dummy_modes() - - plain_s = eme.get_s(modes, wavenumbers, modes, wavenumbers) - clipped_s = eme.get_s(modes, wavenumbers, modes, wavenumbers, force_nogain=True) - - plain_singular_values = numpy.linalg.svd(plain_s, compute_uv=False) - clipped_singular_values = numpy.linalg.svd(clipped_s, compute_uv=False) - - assert plain_singular_values.max() > 1.0 - assert (clipped_singular_values <= 1.0 + 1e-12).all() - assert numpy.isfinite(clipped_s).all() + plain_s = eme.get_s(modes, wavenumbers, modes, wavenumbers, DXES) + clipped_s = eme.get_s(modes, wavenumbers, modes, wavenumbers, DXES, force_nogain=True) + assert numpy.linalg.svd(plain_s, compute_uv=False).max() > 1 + assert_close(numpy.linalg.svd(clipped_s, compute_uv=False), [1., 1., .5, .3]) def test_get_s_force_reciprocal_symmetrizes_output(monkeypatch) -> None: - left = numpy.array([1.0, 0.5]) - right = numpy.array([0.9, 0.4]) - modes, _wavenumbers = _dummy_modes() - - monkeypatch.setattr(eme, 'get_tr', _nonsymmetric_tr(left)) - ss = eme.get_s(modes, left, modes, right, force_reciprocal=True) - - assert_close(ss, ss.T) + raw = numpy.arange(16, dtype=float).reshape(4, 4) + monkeypatch.setattr(eme, '_interface_s', lambda *args, **kwargs: raw.copy()) + modes, wavenumbers = _dummy_modes() + ss = eme.get_s(modes, wavenumbers, modes, wavenumbers, DXES, force_reciprocal=True) + assert_close(ss, .5 * (raw + raw.T)) def test_get_s_force_nogain_and_reciprocal_returns_finite_output(monkeypatch) -> None: - monkeypatch.setattr(eme, 'get_tr', _gain_and_reflection_tr) + raw = numpy.arange(16, dtype=float).reshape(4, 4) + monkeypatch.setattr(eme, '_interface_s', lambda *args, **kwargs: raw.copy()) modes, wavenumbers = _dummy_modes() - ss = eme.get_s(modes, wavenumbers, modes, wavenumbers, force_nogain=True, force_reciprocal=True) - - assert ss.shape == (4, 4) - assert numpy.isfinite(ss).all() - assert_close(ss, ss.T) + ss = eme.get_s(modes, wavenumbers, modes, wavenumbers, DXES, force_nogain=True, force_reciprocal=True) + assert_close(ss, ss.T, atol=1e-14) assert (numpy.linalg.svd(ss, compute_uv=False) <= 1.0 + 1e-12).all() @@ -464,8 +421,8 @@ def _abcd_to_s(abcd: numpy.ndarray) -> numpy.ndarray: t12 = aa - bb * cc / dd return numpy.array( [ - [r12, t12], - [t21, r21], + [r12, t21], + [t12, r21], ], dtype=complex, ) @@ -777,7 +734,7 @@ def test_quarter_wave_matching_layer_is_nearly_reflectionless_at_design_frequenc _mode_1, beta_1 = _build_uniform_mode(float(n1)) quarter_wave_length = numpy.pi / (2 * numpy.real(beta_1)) - stack_abcd = interface_01 @ _propagation_abcd(beta_1, quarter_wave_length) @ interface_12 + stack_abcd = interface_12 @ _propagation_abcd(beta_1, quarter_wave_length) @ interface_01 ss = _abcd_to_s(stack_abcd) assert ss.shape == (2, 2) @@ -810,7 +767,7 @@ def test_quarter_wave_ar_layer_reduces_reflection_relative_to_abrupt_interface() interface_12 = _interface_abcd(n1, n2) _mode_1, beta_1 = _build_uniform_mode(float(n1)) quarter_wave_length = numpy.pi / (2 * numpy.real(beta_1)) - ar_stack = _abcd_to_s(interface_01 @ _propagation_abcd(beta_1, quarter_wave_length) @ interface_12) + ar_stack = _abcd_to_s(interface_12 @ _propagation_abcd(beta_1, quarter_wave_length) @ interface_01) abrupt_reflection = abs(abrupt[0, 0]) abrupt_transmission = abs(abrupt[1, 0]) @@ -841,7 +798,7 @@ def test_half_wave_uniform_slab_restores_unit_transmission_between_matched_media _mode_1, beta_1 = _build_uniform_mode(n1) half_wave_length = numpy.pi / numpy.real(beta_1) - stack_abcd = interface_01 @ _propagation_abcd(beta_1, half_wave_length) @ interface_10 + stack_abcd = interface_10 @ _propagation_abcd(beta_1, half_wave_length) @ interface_01 ss = _abcd_to_s(stack_abcd) assert ss.shape == (2, 2) @@ -899,8 +856,7 @@ def test_quarter_wave_bragg_reflector_matches_closed_form_stopband_response() -> stack_abcd = numpy.eye(2, dtype=complex) for _ in range(periods): - stack_abcd = stack_abcd @ interface_lh @ _propagation_abcd(beta_h, quarter_wave_high) - stack_abcd = stack_abcd @ interface_hl @ _propagation_abcd(beta_l, quarter_wave_low) + stack_abcd = _propagation_abcd(beta_l, quarter_wave_low) @ interface_hl @ _propagation_abcd(beta_h, quarter_wave_high) @ interface_lh @ stack_abcd ss = _abcd_to_s(stack_abcd) expected = _expected_bragg_reflector_s(n_low, n_high, periods) diff --git a/meanas/test/test_fdfd_algebra_helpers.py b/meanas/test/test_fdfd_algebra_helpers.py index 825edb3..bf863a9 100644 --- a/meanas/test/test_fdfd_algebra_helpers.py +++ b/meanas/test/test_fdfd_algebra_helpers.py @@ -182,7 +182,7 @@ def test_uniform_grid_scpml_default_s_function_matches_explicit_default() -> Non def test_stretch_with_scpml_only_modifies_requested_front_edge() -> None: s_function = scpml.prepare_s_function(ln_R=-12.0, m=3.0) base = [[numpy.ones(6), numpy.ones(4), numpy.ones(3)] for _ in range(2)] - stretched = scpml.stretch_with_scpml(base, axis=0, polarity=1, omega=2.0, epsilon_effective=4.0, thickness=2, s_function=s_function) + stretched = scpml.stretch_with_scpml(base, axis=0, polarity=-1, omega=2.0, epsilon_effective=4.0, thickness=2, s_function=s_function) assert_close(stretched[0][0][2:], 1.0) assert_close(stretched[1][0][2:], 1.0) @@ -195,7 +195,7 @@ def test_stretch_with_scpml_only_modifies_requested_front_edge() -> None: def test_stretch_with_scpml_only_modifies_requested_back_edge() -> None: s_function = scpml.prepare_s_function(ln_R=-12.0, m=3.0) base = [[numpy.ones(6), numpy.ones(4), numpy.ones(3)] for _ in range(2)] - stretched = scpml.stretch_with_scpml(base, axis=0, polarity=-1, omega=2.0, epsilon_effective=4.0, thickness=2, s_function=s_function) + stretched = scpml.stretch_with_scpml(base, axis=0, polarity=1, omega=2.0, epsilon_effective=4.0, thickness=2, s_function=s_function) assert_close(stretched[0][0][:4], 1.0) assert_close(stretched[1][0][:4], 1.0) diff --git a/meanas/test/test_numerical_feedback.py b/meanas/test/test_numerical_feedback.py new file mode 100644 index 0000000..b773115 --- /dev/null +++ b/meanas/test/test_numerical_feedback.py @@ -0,0 +1,375 @@ +"""Independent regressions for the numerical/API feedback audit.""" +import dataclasses + +import numpy as np +import pytest +from numpy.testing import assert_allclose +from scipy import linalg, sparse + +from .. import eigensolvers +from ..fdmath import vec +from ..fdmath.functional import deriv_forward, deriv_back +from ..fdtd import pml, base +from ..fdfd import eme, scpml, waveguide_2d as wg, waveguide_3d + + +def grid(shape): + return [[np.ones(n) for n in shape] for _ in range(2)] + + +def mode(profile, admittance=1, scale=1): + profile = np.asarray(profile) + e = np.zeros((3, profile.size, 1), complex) + h = np.zeros_like(e) + e[0, :, 0] = np.sqrt(2) * scale * profile + h[1, :, 0] = np.sqrt(2) * scale * admittance * profile + return vec(e), vec(h) + + +def test_disabled_cpml_matches_unequal_nonuniform_yee_updates(): + rng = np.random.default_rng(6) + shape = (3, 4, 5, 6) + widths = [[np.linspace(.7 + .3 * family, 1.4 + .2 * family, n) + for n in shape[1:]] for family in range(2)] + e = rng.normal(size=shape) + 1j * rng.normal(size=shape) + h = rng.normal(size=shape) + 1j * rng.normal(size=shape) + eps, mu = 1 + rng.random(shape), 1 + rng.random(shape) + ec, hc = e.copy(), h.copy() + ue, uh = pml.updates_with_cpml([[None, None] for _ in range(3)], .1, widths, eps, dtype=complex) + ue(ec, hc, eps) + base.maxwell_e(.1, widths)(e, h, eps) + uh(ec, hc, mu) + base.maxwell_h(.1, widths)(e, h, mu) + assert_allclose(ec, e, atol=1e-14) + assert_allclose(hc, h, atol=1e-14) + + +def test_cpml_physical_spacing_scales_attenuation_rate(): + small = pml.cpml_params(0, 1, dt=.1, dx=.25) + unit = pml.cpml_params(0, 1, dt=.4) + for family in ('param_e', 'param_h'): + assert_allclose(small[family], unit[family]) + for invalid in (0, -1, np.inf, np.nan): + with pytest.raises(ValueError, match='dx'): + pml.cpml_params(0, 1, .1, dx=invalid) + + +def test_stretched_metric_satisfies_complex_reciprocity(): + rng = np.random.default_rng(8) + shape = (4, 5) + widths = [[.7 + rng.random(n) - .2j * rng.random(n) for n in shape] for _ in range(2)] + operator = wg.operator_beta(.8, widths, vec(1 + rng.random((3, *shape)))) + metric = wg.lorentz_metric(widths) + defect = operator.T @ metric - metric @ operator + assert sparse.linalg.norm(defect) / sparse.linalg.norm(metric @ operator) < 1e-12 + physical = wg.lorentz_metric(widths, measure='physical') + assert sparse.linalg.norm(operator.T @ physical - physical @ operator) > 1e-3 + e, h = mode([1, .3j, .2, .4]) + # Direct one-dimensional complex-area quadrature, independent of the metric. + dx = [[np.full(4, 1 - .2j), np.ones(1)] for _ in range(2)] + assert_allclose(wg.inner_product(e, h, dx, measure='stretched'), (1 - .2j) * np.sum(np.array([1, .3j, .2, .4]) ** 2)) + assert_allclose(wg.inner_product(e, h, dx, conj_h=True), np.sum(abs(np.array([1, .3j, .2, .4])) ** 2)) + + +@pytest.mark.parametrize('magnetic', [False, True]) +def test_reconstruction_can_normalize_complex_self_reaction(magnetic): + widths = [[np.full(2, 1 - .2j), np.ones(2)] for _ in range(2)] + transverse = np.concatenate((np.zeros(4), np.ones(4))) if magnetic else np.concatenate((np.ones(4), np.zeros(4))) + reconstruct = wg.normalized_fields_h if magnetic else wg.normalized_fields_e + e, h = reconstruct(transverse, 1, 1, widths, np.ones(12), normalization='lorentz') + assert_allclose(wg.inner_product(e, h, widths, measure='stretched'), 1, atol=1e-13) + with pytest.raises(ValueError, match='finite, nonzero'): + reconstruct(np.zeros(8), 1, 1, widths, np.ones(12)) + with pytest.raises(ValueError, match='positive forward power'): + reconstruct(transverse, -1, 1, widths, np.ones(12)) + + +@pytest.mark.parametrize('polarity', [-1, 1]) +def test_scpml_face_copy_real_profile_and_final_stretch_cap(polarity): + widths = grid((10, 2)) + result = scpml.stretch_with_scpml( + widths, 0, polarity, .2, thickness=3, + kappa_function=lambda depth: 1 + depth ** 2, max_imag_ratio=1, + ) + selected = slice(0, 3) if polarity == -1 else slice(-3, None) + untouched = slice(3, None) if polarity == -1 else slice(None, -3) + assert result is not widths + for family in range(2): + assert_allclose(widths[family][0], 1) + assert_allclose(result[family][0][untouched], 1) + assert np.any(result[family][0][selected].real > 1) + assert np.any(result[family][0][selected].imag < 0) + assert np.all(abs(result[family][0].imag) <= result[family][0].real) + assert not np.shares_memory(result[family][1], widths[family][1]) + mutated = scpml.stretch_with_scpml(widths, 0, polarity, .2, thickness=3, copy=False) + assert mutated is widths + assert np.any(widths[0][0].imag < 0) + + +def test_uniform_scpml_caps_final_dimensionless_stretch(): + widths = scpml.uniform_grid_scpml((10, 1, 1), (3, 0, 0), .2, + kappa_function=lambda depth: 1 + depth, max_imag_ratio=.7) + for family in widths: + assert np.all(abs(family[0].imag) <= .7 * family[0].real + 1e-14) + assert_allclose(family[1], 1) + + +def test_indexed_eigensolve_accepts_seed_and_start_without_mutation(): + operator = sparse.diags_array(np.linspace(-3, 5, 12)) + first = eigensolvers.signed_eigensolve(operator, 2, rng=42) + second = eigensolvers.signed_eigensolve(operator, 2, rng=42) + assert_allclose(first[0], second[0], atol=1e-13) + assert_allclose(first[1], second[1], atol=1e-12) + start = np.linspace(1, 2, 12).astype(complex) + before = start.copy() + eigensolvers.signed_eigensolve(operator, 2, v0=start) + eigensolvers.power_iteration(operator, guess_vector=start) + assert_allclose(start, before) + with pytest.raises(ValueError, match='finite, nonzero'): + eigensolvers.signed_eigensolve(operator, 2, v0=np.zeros(12)) + + +@pytest.mark.parametrize('axis', [0, 1, 2]) +@pytest.mark.parametrize('polarity', [-1, 1]) +def test_mode_from_2d_reuses_conversion_without_calling_eigensolver(monkeypatch, axis, polarity): + shape = (4, 5, 6) + widths, eps = grid(shape), np.ones((3, *shape)) + slices = [slice(None)] * 3 + slices[axis] = slice(1, 2) + points = int(np.prod([n for i, n in enumerate(shape) if i != axis])) + transverse = np.concatenate((np.ones(points), np.zeros(points))) + def forbidden(*args, **kwargs): + raise AssertionError('the conversion helper must not solve modes') + monkeypatch.setattr(wg, 'solve_mode', forbidden) + result = waveguide_3d.mode_from_2d(transverse, .5, omega=.5, dxes=widths, axis=axis, + polarity=polarity, slices=slices, epsilon=eps) + assert_allclose(result['wavenumber'], 2 * np.arcsin(.25)) + assert np.isfinite(result['E']).all() + assert np.isfinite(result['H']).all() + monkeypatch.setattr(wg, 'solve_mode', lambda *args, **kwargs: (transverse, .5)) + wrapped = waveguide_3d.solve_mode(0, .5, widths, axis, polarity, slices, eps) + assert_allclose(result['E'], wrapped['E']) + assert_allclose(result['H'], wrapped['H']) + + +def test_mixed_projection_scalar_overlap_and_unclipped_galerkin(): + a = .8 + left, right = mode([1, 0]), mode([a, np.sqrt(1 - a*a)]) + ss = eme.get_s([left], [1], [right], [1], grid((2, 1))) + transmission, reflection = 2*a/(1+a*a), (1-a*a)/(1+a*a) + assert_allclose(ss, [[reflection, transmission], [transmission, -reflection]], atol=1e-14) + assert_allclose(ss.T @ ss, np.eye(2), atol=1e-14) + t, r = eme.get_tr([left], [1], [right], [1], grid((2, 1)), formulation='galerkin') + assert_allclose(t, 1/a) + assert_allclose(r, 0, atol=1e-14) + + +def test_asymmetric_transmission_uses_outgoing_rows_incoming_columns(): + left, right = mode([1], scale=2), mode([1], scale=3) + ss = eme.get_s([left], [1], [right], [1], grid((1, 1))) + assert_allclose(ss, [[0, 1.5], [2/3, 0]], atol=1e-14) + # Continuity for the two physical incidence directions independently. + assert_allclose(2 * (1 + ss[0, 0]), 3 * ss[1, 0]) + assert_allclose(3 * (1 + ss[1, 1]), 2 * ss[0, 1]) + transfer = eme.get_abcd([left], [1], [right], [1], grid((1, 1))).toarray() + assert_allclose(transfer, np.eye(2) * (2/3), atol=1e-14) + assert_allclose(eme.abcd_to_s(transfer), ss, atol=1e-14) + assert_allclose(eme.abcd_to_s(sparse.csr_array(transfer)), ss, atol=1e-14) + + +def test_full_nondiagonal_gram_projections_match_exact_field_coordinates(): + left_matrix = np.array([[1, .2j], [.1, 1]], complex) + right_matrix = np.array([[1, .3], [.2, 1]], complex) + dl = np.array([[1, .2], [.3j, 1]], complex) + dr = np.array([[1, -.1j], [.4, 1]], complex) + left = [mode(column) for column in left_matrix.T] + right = [mode(column) for column in right_matrix.T] + ss = eme.get_s(left, [1, 1], right, [1, 1], grid((2, 1)), + dual_ehLs=[mode(column) for column in dl.T], + dual_ehRs=[mode(column) for column in dr.T]) + zero = np.zeros((2, 2)) + expected = np.block([[zero, np.linalg.solve(left_matrix, right_matrix)], + [np.linalg.solve(right_matrix, left_matrix), zero]]) + assert_allclose(ss, expected, atol=1e-13) + + +def test_mixed_interface_accepts_unequal_port_counts(): + left = [mode([1, 0])] + right = [mode([1, 0]), mode([0, 1])] + ss = eme.get_s(left, [1], right, [1, 2], grid((2, 1))) + assert_allclose(ss, [[0, 1, 0], [1, 0, 0], [0, 0, -1]], atol=1e-14) + with pytest.raises(ValueError, match='equal port mode counts'): + eme.get_abcd(left, [1], right, [1, 2], grid((2, 1))) + + +def test_redheffer_matches_independent_internal_wave_elimination(): + rng = np.random.default_rng(52) + left = .1 * (rng.normal(size=(3, 3)) + 1j * rng.normal(size=(3, 3))) + right = .1 * (rng.normal(size=(5, 5)) + 1j * rng.normal(size=(5, 5))) + internal = np.block([[np.eye(2), -left[1:, 1:]], [-right[:2, :2], np.eye(2)]]) + incoming = np.block([[left[1:, :1], np.zeros((2, 3))], [np.zeros((2, 1)), right[:2, 2:]]]) + waves = np.linalg.solve(internal, incoming) + outgoing = np.block([[left[:1, :1], np.zeros((1, 3))], [np.zeros((3, 1)), right[2:, 2:]]]) + outgoing[:1] += left[:1, 1:] @ waves[2:] + outgoing[1:] += right[2:, :2] @ waves[:2] + assert_allclose(eme.cascade_s(left, right, internal_modes=2), outgoing, atol=1e-14) + + +def test_taper_overlap_has_no_generator_gain(): + left, right = mode([1, 0]), mode([.8, .6]) + sections = [eme.ModeSection(0, [left], [2]), eme.ModeSection(1, [right], [2])] + ss = eme.get_taper_s(sections, grid((2, 1))) + assert_allclose(np.linalg.svd(ss, compute_uv=False), 1, atol=1e-14) + assert_allclose(abs(ss[1, 0]), 2*.8/(1+.8**2), atol=1e-14) + with pytest.raises(TypeError, match='rtol'): + eme.get_taper_s(sections, grid((2, 1)), rtol=1e-8) + + +def test_long_leaky_taper_stays_finite_without_transfer_growth(): + fields = mode([1]) + sections = [eme.ModeSection(0, [fields], [1 - 1j]), eme.ModeSection(1000, [fields], [1 - 1j])] + ss = eme.get_taper_s(sections, grid((1, 1))) + assert_allclose(ss, 0, atol=1e-300) + with pytest.raises(np.linalg.LinAlgError, match='S to ABCD'): + eme.get_taper_abcd(sections, grid((1, 1))) + + +def test_prepared_dense_beta_overrides_are_aligned_and_avoid_overlaps(monkeypatch): + fields = [mode([1, 0]), mode([0, 1])] + phases = np.exp(1j * np.array([.3, -.7])) + shifted = [(e * phase, h * phase) for (e, h), phase in zip(fields, phases, strict=True)] + beta = np.array([[1 - .1j, .2], [.1j, 2 - .1j]]) + sections = [eme.ModeSection(0, fields, [1, 2], wavenumber_matrix=beta), + eme.ModeSection(2, shifted, [1, 2], wavenumber_matrix=beta)] + prepared = eme.prepare_taper(sections, grid((2, 1))) + overrides = np.array([beta * 1.1, beta * .9]) + changed = [dataclasses.replace(section, z=z, wavenumber_matrix=b) + for section, z, b in zip(sections, [0, 3], overrides, strict=True)] + expected = eme.get_taper_s(changed, grid((2, 1))) + def forbidden(*args, **kwargs): + raise AssertionError('evaluation must not recompute modal overlaps') + monkeypatch.setattr(eme, 'inner_product', forbidden) + monkeypatch.setattr(eme, 'lorentz_overlap', forbidden) + actual = eme.evaluate_taper_s(prepared, z_coords=[0, 3], wavenumber_matrices=overrides) + assert_allclose(actual, expected, atol=1e-13) + fields[0][0][:] = 100 + assert_allclose(eme.evaluate_taper_s(prepared, z_coords=[0, 3], wavenumber_matrices=overrides), expected, atol=1e-13) + with pytest.raises(ValueError, match='read-only'): + prepared.interfaces[0, 0, 0] = 5 + + +def test_explicit_backward_override_and_dense_uniform_propagation(): + fields = [mode([1, 0]), mode([0, 1])] + backward = [(e, -h) for e, h in fields] + beta = np.array([[1 - .1j, .2], [.1j, 2 - .1j]]) + minus = -beta + .03j * np.eye(2) + sections = [eme.ModeSection(z, fields, [1, 2], backward_modes=backward, + backward_wavenumbers=[-1, -2], wavenumber_matrix=beta, + backward_wavenumber_matrix=minus) for z in (0, 2)] + prepared = eme.prepare_taper(sections, grid((2, 1))) + with pytest.raises(ValueError, match='backward overrides'): + eme.evaluate_taper_s(prepared, wavenumber_matrices=np.array([beta, beta])) + ss = eme.evaluate_taper_s(prepared, wavenumber_matrices=np.array([beta, beta]), + backward_wavenumber_matrices=np.array([minus, minus])) + expected = np.block([[np.zeros((2, 2)), linalg.expm(2j * minus)], + [linalg.expm(-2j * beta), np.zeros((2, 2))]]) + assert_allclose(ss, expected, atol=1e-13) + + +@pytest.mark.parametrize('dtype', [np.int8, np.float32, np.float64, np.complex64, np.complex128]) +@pytest.mark.parametrize('axis', [0, 1, 2]) +@pytest.mark.parametrize('forward', [False, True]) +@pytest.mark.parametrize('spaced', [False, True]) +@pytest.mark.parametrize('contiguous', [False, True]) +def test_slice_derivatives_match_periodic_reference_and_output_buffers(dtype, axis, forward, spaced, contiguous): + f = np.arange(6 * 8 * 4).reshape(6, 8, 4).astype(dtype)[::2, ::2, ::2] + if np.issubdtype(dtype, np.complexfloating): + f = f * (1 + .3j) + if contiguous: + f = np.ascontiguousarray(f) + widths = [np.linspace(.7, 1.3, n) for n in f.shape] if spaced else None + derivative = (deriv_forward if forward else deriv_back)(widths)[axis] + expected = np.roll(f, -1, axis=axis) - f if forward else f - np.roll(f, 1, axis=axis) + if spaced: + shape = [1, 1, 1] + shape[axis] = f.shape[axis] + expected = expected / widths[axis].reshape(shape) + assert_allclose(derivative(f), expected, rtol=1e-6, atol=1e-6) + output = np.empty_like(expected) + assert derivative(f, out=output) is output + assert_allclose(output, expected, rtol=1e-6, atol=1e-6) + with pytest.raises(ValueError, match='alias'): + derivative(f, out=f) + singleton = np.ones((1, 1, 1), dtype=dtype) + plain = (deriv_forward if forward else deriv_back)()[axis] + assert_allclose(plain(singleton), 0) + + +def test_pml_cap_removes_above_light_line_artifact_in_homogeneous_fixture(): + omega = .4 + strong = grid((24, 1)) + capped = grid((24, 1)) + for polarity in (-1, 1): + strong = scpml.stretch_with_scpml(strong, 0, polarity, omega, thickness=5) + capped = scpml.stretch_with_scpml(capped, 0, polarity, omega, thickness=5, max_imag_ratio=1) + strong_values = np.linalg.eigvals(wg.operator_e(omega, strong, np.ones(72)).toarray()) + capped_values = np.linalg.eigvals(wg.operator_e(omega, capped, np.ones(72)).toarray()) + assert strong_values.real.max() > omega**2 + 1e-4 + assert capped_values.real.max() <= omega**2 + 1e-10 + + +def test_sampled_taper_converges_to_continuous_maxwell_propagation(): + from scipy.integrate import solve_ivp + + omega, length = 1.3, 2.0 + def maxwell(z, state): + index = 1 + z / length + operator = np.array([[0, -1j * omega], [-1j * omega * index**2, 0]]) + return (operator @ state.reshape(2, 2)).ravel() + integrated = solve_ivp(maxwell, (0, length), np.eye(2, dtype=complex).ravel(), rtol=1e-11, atol=1e-13) + left_fields = np.array([[1, 1], [1, -1]], complex) + right_fields = np.array([[1, 1], [2, -2]], complex) / np.sqrt(2) + transfer = np.linalg.solve(right_fields, integrated.y[:, -1].reshape(2, 2) @ left_fields) + a, b, c, d = transfer.ravel() + reference = np.array([[-c/d, 1/d], [a-b*c/d, b/d]]) + errors = [] + for count in (9, 17, 33): + sections = [] + for z in np.linspace(0, length, count): + index = 1 + z / length + fields = mode([1], admittance=index, scale=1/np.sqrt(index)) + sections.append(eme.ModeSection(z, [fields], [omega * index])) + scattering = eme.get_taper_s(sections, grid((1, 1))) + assert_allclose(scattering.conj().T @ scattering, np.eye(2), atol=1e-12) + errors.append(np.linalg.norm(scattering - reference)) + assert errors[1] < errors[0] / 3 + assert errors[2] < errors[1] / 3 + assert errors[2] < 5e-4 + + +def test_singular_conversions_and_interface_report_errors(): + with pytest.raises(np.linalg.LinAlgError, match='S to ABCD'): + eme.s_to_abcd(np.eye(2)) + with pytest.raises(np.linalg.LinAlgError, match='ABCD to S'): + eme.abcd_to_s(np.array([[1, 0], [0, 0]])) + fields = mode([0]) + with pytest.raises(np.linalg.LinAlgError, match='interface continuity'): + eme.get_s([fields], [1], [fields], [1], grid((1, 1))) + mirror = np.eye(2) + with pytest.raises(np.linalg.LinAlgError, match='Redheffer internal feedback'): + eme.cascade_s(mirror, mirror, internal_modes=1) + + +def test_uniform_custom_profiles_leave_physical_aperture_unchanged(): + widths = scpml.uniform_grid_scpml((10, 1, 1), (2, 0, 0), 1, + kappa_function=lambda depth: np.full_like(depth, 2)) + for family in widths: + assert_allclose(family[0][3:8], 1) + + +@pytest.mark.parametrize('forward', [False, True]) +def test_derivatives_do_not_subtract_across_independent_rows(forward): + fields = np.array([[[3e38, 3e38], [-3e38, -3e38]]], dtype=np.float32) + derivative = (deriv_forward if forward else deriv_back)()[2] + with np.errstate(over='raise', invalid='raise'): + assert_allclose(derivative(fields), 0) diff --git a/meanas/test/test_waveguide_2d_complex.py b/meanas/test/test_waveguide_2d_complex.py deleted file mode 100644 index fc1fb1c..0000000 --- a/meanas/test/test_waveguide_2d_complex.py +++ /dev/null @@ -1,694 +0,0 @@ -import dataclasses -import numpy -import pytest -from numpy.testing import assert_allclose -from scipy import sparse -from scipy import linalg - -from ..eigensolvers import TargetedEigensolution, scipy_eigs_near -from ..fdmath import vec -from ..fdfd import eme, scpml, waveguide_2d, waveguide_2d_complex, waveguide_3d - - -def _grid(shape: tuple[int, int], spacing: float = 0.3) -> list[list[numpy.ndarray]]: - return [[numpy.full(shape[0], spacing), numpy.full(shape[1], spacing)] for _ in range(2)] - - -def _waveguide_case( - shape: tuple[int, int] = (11, 11), - *, - pml_cells: int = 0, - ) -> tuple[float, list[list[numpy.ndarray]], numpy.ndarray]: - omega = 2 * numpy.pi / 1.55 - epsilon_cladding = 1.45 ** 2 - epsilon = numpy.full((3, *shape), epsilon_cladding) - cx, cy = (size // 2 for size in shape) - epsilon[:, cx - 1:cx + 2, cy - 1:cy + 2] = 2.0 ** 2 - dxes = _grid(shape) - if pml_cells: - for axis in (0, 1): - for polarity in (-1, +1): - dxes = scpml.stretch_with_scpml( - dxes, - axis=axis, - polarity=polarity, - omega=omega, - epsilon_effective=epsilon_cladding, - thickness=pml_cells, - ) - return omega, dxes, vec(epsilon) - - -def test_operator_beta_squares_to_existing_e_and_h_operators() -> None: - rng = numpy.random.default_rng(4) - shape = (4, 5) - omega = 1.7 + 0.03j - dxes = [[ - 0.7 + rng.random(size) + 0.02j * rng.random(size) - for size in shape - ] for _ in range(2)] - epsilon = vec(1.5 + rng.random((3, *shape))) - mu = vec(0.9 + 0.2 * rng.random((3, *shape))) - - operator = waveguide_2d_complex.operator_beta(omega, dxes, epsilon, mu) - n_state = operator.shape[0] // 2 - squared = operator @ operator - electric = squared[:n_state, :n_state] - magnetic = squared[n_state:, n_state:] - - assert_allclose(electric.toarray(), waveguide_2d.operator_e(omega, dxes, epsilon, mu).toarray(), atol=3e-12) - assert_allclose(magnetic.toarray(), waveguide_2d.operator_h(omega, dxes, epsilon, mu).toarray(), atol=3e-12) - assert sparse.linalg.norm(squared[:n_state, n_state:]) == 0 - assert sparse.linalg.norm(squared[n_state:, :n_state]) == 0 - - -def test_complex_solver_matches_guided_beta_and_builds_lorentz_duals() -> None: - omega, dxes, epsilon = _waveguide_case() - _e_xy, expected_beta = waveguide_2d.solve_mode(0, omega=omega, dxes=dxes, epsilon=epsilon) - - basis = waveguide_2d_complex.solve_modes_near( - omega, - dxes, - epsilon, - target_wavenumbers=[0.98 * expected_beta], - modes_per_target=1, - candidate_margin=2, - ) - - assert_allclose(basis.wavenumbers[0], expected_beta, rtol=1e-4, atol=1e-10) - assert basis.diagnostics.right_residuals[0] < 1e-10 - assert basis.diagnostics.left_residuals[0] < 1e-10 - overlap = 0.5 * ( - waveguide_2d.inner_product(basis.dual_electric_fields[0], basis.magnetic_fields[0], dxes) - + waveguide_2d.inner_product(basis.electric_fields[0], basis.dual_magnetic_fields[0], dxes) - ) - assert_allclose(overlap, 1.0, atol=1e-10) - - -def test_complex_solver_returns_passive_pml_modes_and_diagnostics() -> None: - omega, dxes, epsilon = _waveguide_case(shape=(15, 15), pml_cells=3) - - basis = waveguide_2d_complex.solve_modes_near( - omega, - dxes, - epsilon, - target_wavenumbers=[1.45 * omega], - modes_per_target=3, - candidate_margin=2, - ) - - assert numpy.all(numpy.imag(basis.wavenumbers) < 1e-8) - assert numpy.all((basis.diagnostics.pml_participation >= 0) & (basis.diagnostics.pml_participation <= 1)) - assert basis.diagnostics.biorthogonality_error < 1e-10 - - -def test_mode_tracking_restores_permutation_phase_and_eme_adapter() -> None: - omega, dxes, epsilon = _waveguide_case(shape=(15, 15), pml_cells=3) - basis = waveguide_2d_complex.solve_modes_near( - omega, - dxes, - epsilon, - target_wavenumbers=[1.45 * omega], - modes_per_target=2, - candidate_margin=2, - ) - permuted = waveguide_2d_complex._reorder_basis(basis, numpy.array([1, 0])) - permuted = waveguide_2d_complex._scaled_basis(permuted, numpy.exp(1j * numpy.array([0.7, -0.4]))) - - tracked = waveguide_2d_complex.track_mode_path([basis, permuted]) - assert_allclose(tracked[1].wavenumbers, basis.wavenumbers) - for index in range(len(basis)): - overlap = waveguide_2d_complex._lorentz_overlap( - tracked[0].dual_modes[index], - tracked[1].modes[index], - tracked[0].overlap_dxes, - ) - assert overlap.real > 0 - assert abs(overlap.imag) < 1e-10 - - sections = eme.mode_sections_from_bases([0.0, 2.0], tracked) - assert len(sections) == 2 - assert len(sections[0].modes) == len(basis) - assert sections[0].dual_modes is not None - assert sections[0].backward_modes is None - - -def test_power_normalization_preserves_dual_overlap() -> None: - omega, dxes, epsilon = _waveguide_case() - _e_xy, beta = waveguide_2d.solve_mode(0, omega=omega, dxes=dxes, epsilon=epsilon) - basis = waveguide_2d_complex.solve_modes_near( - omega, - dxes, - epsilon, - target_wavenumbers=[0.98 * beta], - modes_per_target=1, - candidate_margin=2, - ) - - normalized = waveguide_2d_complex.power_normalize_modes(basis, [0]) - - assert_allclose(normalized.diagnostics.powers[0], 1.0, atol=1e-10) - overlap = waveguide_2d_complex._lorentz_overlap(normalized.dual_modes[0], normalized.modes[0], dxes) - assert_allclose(overlap, 1.0, atol=1e-10) - for parameter in ('power_atol', 'power_rtol'): - for invalid in (numpy.nan, numpy.inf, -numpy.inf): - with pytest.raises(ValueError, match=rf'{parameter} must be finite and positive'): - waveguide_2d_complex.power_normalize_modes(basis, [0], **{parameter: invalid}) - - -def test_overlapping_targets_merge_instead_of_backfilling_farther_modes() -> None: - values = numpy.arange(1, 5, dtype=complex) - vectors = numpy.eye(4, dtype=complex) - solution = TargetedEigensolution(values, vectors, vectors) - - selected, _right, _left, target_indices = waveguide_2d_complex._select_unique_modes( - [solution, solution], - numpy.asarray([1.1, 1.2]), - 2, - eigenvalue_rtol=1e-8, - subspace_tol=1e-8, - state_weights=numpy.ones(4), - ) - - assert_allclose(selected, [1, 2]) - assert_allclose(target_indices, [0, 1]) - - -def test_rotated_degenerate_candidate_subspaces_are_deduplicated_by_rank() -> None: - values = numpy.asarray([2.0, 2.0]) - vectors = numpy.eye(4, 2, dtype=complex) - rotation = numpy.asarray([[1, 1j], [1j, 1]], dtype=complex) / numpy.sqrt(2) - first = TargetedEigensolution(values, vectors, vectors) - second = TargetedEigensolution(values, vectors @ rotation, vectors @ rotation) - - selected, right, left, _targets = waveguide_2d_complex._select_unique_modes( - [first, second], - numpy.asarray([1.9, 2.1]), - 2, - eigenvalue_rtol=1e-8, - subspace_tol=1e-8, - state_weights=numpy.ones(4), - ) - - assert selected.size == 2 - assert numpy.linalg.matrix_rank(right) == 2 - assert numpy.linalg.matrix_rank(left) == 2 - - -def test_independently_cross_paired_degenerate_subspaces_are_accepted() -> None: - e1 = numpy.asarray([1.0, 0.0], dtype=complex) - e2 = numpy.asarray([0.0, 1.0], dtype=complex) - first = TargetedEigensolution(numpy.asarray([2.0]), numpy.column_stack([e1]), numpy.column_stack([e1])) - second = TargetedEigensolution( - numpy.asarray([2.0, 2.0]), - numpy.column_stack([e2, e1]), - numpy.column_stack([e1, e2]), - ) - - selected, right, left, _targets = waveguide_2d_complex._select_unique_modes( - [first, second], numpy.asarray([1.9, 2.1]), 1, - eigenvalue_rtol=1e-8, subspace_tol=1e-8, state_weights=numpy.ones(2), - ) - - assert selected.size == 2 - assert numpy.linalg.matrix_rank(right) == 2 - assert numpy.linalg.matrix_rank(left) == 2 - - -def test_single_target_cross_paired_degenerate_candidates_form_dual_basis() -> None: - e1 = numpy.asarray([1.0, 0.0], dtype=complex) - e2 = numpy.asarray([0.0, 1.0], dtype=complex) - solution = TargetedEigensolution( - numpy.asarray([2.0, 2.0]), - numpy.column_stack([e1, e2]), - numpy.column_stack([e2, e1]), - ) - - selected, right, left, _targets = waveguide_2d_complex._select_unique_modes( - [solution], numpy.asarray([2.1]), 1, - eigenvalue_rtol=1e-8, subspace_tol=1e-8, state_weights=numpy.ones(2), - ) - - assert selected.size == 1 - assert right.shape == (2, 1) - assert left.shape == (2, 1) - assert abs((left.T @ right)[0, 0]) > 0.5 - - -def test_reserve_candidate_repairs_higher_rank_cross_gram() -> None: - vectors = numpy.eye(3, dtype=complex) - solution = TargetedEigensolution( - numpy.full(3, 2.0), - vectors, - vectors[:, [1, 2, 0]], - ) - - selected, right, left, _targets = waveguide_2d_complex._select_unique_modes( - [solution], numpy.asarray([2.1]), 2, - eigenvalue_rtol=1e-8, subspace_tol=1e-8, state_weights=numpy.ones(3), - ) - - assert selected.size == 2 - assert numpy.linalg.matrix_rank(left.T @ right) == 2 - - -def test_cluster_with_insufficient_cross_rank_is_rejected() -> None: - e1 = numpy.asarray([1.0, 0.0], dtype=complex) - e2 = numpy.asarray([0.0, 1.0], dtype=complex) - first = TargetedEigensolution(numpy.asarray([2.0]), numpy.column_stack([e1]), numpy.column_stack([e1])) - second = TargetedEigensolution(numpy.asarray([2.0]), numpy.column_stack([e2]), numpy.column_stack([e1])) - - with pytest.raises(RuntimeError, match=r'required cross rank 2, available 1'): - waveguide_2d_complex._select_unique_modes( - [first, second], numpy.asarray([1.9, 2.1]), 1, - eigenvalue_rtol=1e-8, subspace_tol=1e-8, state_weights=numpy.ones(2), - ) - - -def test_single_target_does_not_expand_to_full_degenerate_candidate_margin() -> None: - vectors = numpy.eye(5, dtype=complex) - solution = TargetedEigensolution(numpy.full(5, 2.0), vectors, vectors) - - selected, right, left, _targets = waveguide_2d_complex._select_unique_modes( - [solution], numpy.asarray([2.1]), 1, - eigenvalue_rtol=1e-8, subspace_tol=1e-8, state_weights=numpy.ones(5), - ) - - assert selected.size == 1 - assert right.shape == (5, 1) - assert left.shape == (5, 1) - - -def test_mode_selection_is_invariant_to_candidate_scaling() -> None: - values = numpy.asarray([1.0, 2.0], dtype=complex) - vectors = numpy.eye(2, dtype=complex) - reference = TargetedEigensolution(values, vectors, vectors) - scaled = TargetedEigensolution( - values, - vectors * numpy.asarray([1e-12, 1e8]), - vectors * numpy.asarray([1e9j, -1e-11]), - ) - - outputs = [ - waveguide_2d_complex._select_unique_modes( - [solution], numpy.asarray([1.5]), 2, - eigenvalue_rtol=1e-8, subspace_tol=1e-7, state_weights=numpy.ones(2), - ) - for solution in (reference, scaled) - ] - - assert_allclose(outputs[0][0], outputs[1][0]) - for _values, right, left, _targets in outputs: - assert numpy.linalg.matrix_rank(left.T @ right) == 2 - - -def test_mode_selection_is_invariant_to_uniform_weight_scaling() -> None: - values = numpy.asarray([1.0, 2.0], dtype=complex) - vectors = numpy.eye(2, dtype=complex) - solution = TargetedEigensolution(values, vectors, vectors) - - outputs = [ - waveguide_2d_complex._select_unique_modes( - [solution], numpy.asarray([1.5]), 2, - eigenvalue_rtol=1e-8, subspace_tol=1e-7, - state_weights=numpy.full(2, weight_scale), - ) - for weight_scale in (1.0, 1e8) - ] - - assert_allclose(outputs[0][0], outputs[1][0]) - assert_allclose(outputs[0][1], outputs[1][1]) - assert_allclose(outputs[0][2], outputs[1][2]) - - -def test_complex_solver_is_invariant_to_backend_eigenvector_scaling() -> None: - omega, dxes, epsilon = _waveguide_case() - eigensolver_opts = {'v0': numpy.linspace(1.0, 2.0, 4 * 11 * 11, dtype=complex)} - cached_solution = None - - def cached_backend(operator, target, count, **options): - nonlocal cached_solution - if cached_solution is None: - cached_solution = scipy_eigs_near(operator, target, count, **options) - return cached_solution - - reference = waveguide_2d_complex.solve_modes_near( - omega, dxes, epsilon, - target_wavenumbers=[1.45 * omega], modes_per_target=2, candidate_margin=2, - eigensolver=cached_backend, eigensolver_opts=eigensolver_opts, - ) - - def rescaled_backend(operator, target, count, **options): - solution = cached_backend(operator, target, count, **options) - order = numpy.argsort(numpy.abs(solution.eigenvalues - target)) - right_scales = numpy.ones(count) - right_scales[order[:2]] = [1e-16, 1e16] - return TargetedEigensolution( - solution.eigenvalues, - solution.right_eigenvectors * right_scales, - solution.left_eigenvectors, - ) - - scaled = waveguide_2d_complex.solve_modes_near( - omega, dxes, epsilon, - target_wavenumbers=[1.45 * omega], modes_per_target=2, candidate_margin=2, - eigensolver=rescaled_backend, eigensolver_opts=eigensolver_opts, - ) - - assert_allclose( - numpy.sort_complex(scaled.wavenumbers), - numpy.sort_complex(reference.wavenumbers), - atol=1e-10, - rtol=1e-10, - ) - assert numpy.max(scaled.diagnostics.right_residuals) < 1e-8 - assert numpy.max(scaled.diagnostics.left_residuals) < 1e-8 - assert scaled.diagnostics.biorthogonality_error < 1e-9 - assert_allclose( - scaled.diagnostics.biorthogonality_condition, - reference.diagnostics.biorthogonality_condition, - rtol=1e-10, - atol=1e-12, - ) - - -def test_refreshed_biorthogonality_condition_is_invariant_to_reciprocal_scaling() -> None: - omega, dxes, epsilon = _waveguide_case() - basis = waveguide_2d_complex.solve_modes_near( - omega, dxes, epsilon, - target_wavenumbers=[1.45 * omega], modes_per_target=2, candidate_margin=2, - ) - - scaled = waveguide_2d_complex._scaled_basis(basis, numpy.asarray([1e-4, 1e4])) - - assert_allclose( - scaled.diagnostics.biorthogonality_condition, - basis.diagnostics.biorthogonality_condition, - rtol=1e-10, - atol=1e-12, - ) - assert scaled.diagnostics.biorthogonality_error < 1e-7 - - -@pytest.mark.parametrize('parameter', [ - 'residual_tol', - 'duplicate_eigenvalue_rtol', - 'duplicate_subspace_tol', -]) -@pytest.mark.parametrize('invalid', [numpy.nan, numpy.inf, -numpy.inf]) -def test_complex_solver_rejects_nonfinite_tolerances(parameter: str, invalid: float) -> None: - omega, dxes, epsilon = _waveguide_case() - - with pytest.raises(ValueError, match=rf'{parameter} must be finite and positive'): - waveguide_2d_complex.solve_modes_near( - omega, dxes, epsilon, - target_wavenumbers=[1.45 * omega], modes_per_target=1, - **{parameter: invalid}, - ) - - -def test_basis_rotation_carries_full_wavenumber_matrix_and_residuals() -> None: - omega, dxes, epsilon = _waveguide_case(shape=(13, 13), pml_cells=2) - basis = waveguide_2d_complex.solve_modes_near( - omega, dxes, epsilon, - target_wavenumbers=[1.45 * omega], modes_per_target=2, candidate_margin=2, - ) - rotation = numpy.asarray([[1, 1j], [1j, 1]], dtype=complex) / numpy.sqrt(2) - - rotated = waveguide_2d_complex._transform_basis(basis, rotation) - expected = numpy.linalg.inv(rotation) @ numpy.diag(basis.wavenumbers) @ rotation - - assert_allclose(rotated.wavenumber_matrix, expected, atol=1e-11) - assert_allclose(numpy.sort_complex(numpy.linalg.eigvals(rotated.wavenumber_matrix)), numpy.sort_complex(basis.wavenumbers), atol=1e-10) - assert numpy.max(rotated.diagnostics.right_residuals) < 1e-8 - assert numpy.max(rotated.diagnostics.left_residuals) < 1e-8 - assert rotated.diagnostics.biorthogonality_error < 1e-9 - - -def test_eme_propagates_constant_mixed_basis_with_full_matrix() -> None: - omega, dxes, epsilon = _waveguide_case(shape=(13, 13), pml_cells=2) - basis = waveguide_2d_complex.solve_modes_near( - omega, dxes, epsilon, - target_wavenumbers=[1.45 * omega], modes_per_target=2, candidate_margin=2, - ) - rotation = numpy.asarray([[1, 1j], [1j, 1]], dtype=complex) / numpy.sqrt(2) - rotated = waveguide_2d_complex._transform_basis(basis, rotation) - length = 0.7 - sections = eme.mode_sections_from_bases([0.0, length], [rotated, rotated]) - - transfer = eme.get_taper_abcd(sections, dxes=rotated.overlap_dxes).toarray() - expected = linalg.block_diag( - linalg.expm(-1j * rotated.wavenumber_matrix * length), - linalg.expm(+1j * rotated.wavenumber_matrix * length), - ) - assert_allclose(transfer, expected, atol=1e-9, rtol=1e-9) - - -def test_eme_adapter_rejects_subset_that_cuts_coupled_block() -> None: - omega, dxes, epsilon = _waveguide_case(shape=(13, 13), pml_cells=2) - basis = waveguide_2d_complex.solve_modes_near( - omega, dxes, epsilon, - target_wavenumbers=[1.45 * omega], modes_per_target=2, candidate_margin=2, - ) - rotation = numpy.asarray([[1, 1j], [1j, 1]], dtype=complex) / numpy.sqrt(2) - rotated = waveguide_2d_complex._transform_basis(basis, rotation) - - with pytest.raises(ValueError, match='complete coupled block'): - eme.mode_sections_from_bases([0.0, 1.0], [rotated, rotated], mode_indices=[0]) - with pytest.raises(ValueError, match='complete coupled block'): - eme.mode_sections_from_bases( - [0.0, 1.0], [basis, basis], mode_indices=[0], backward_bases=[rotated, rotated], - ) - sections = eme.mode_sections_from_bases([0.0, 1.0], [basis, basis], mode_indices=[0]) - assert len(sections[0].modes) == 1 - for invalid_tolerance in (numpy.nan, numpy.inf, -numpy.inf): - with pytest.raises(ValueError, match='finite and positive'): - eme.mode_sections_from_bases([0.0, 1.0], [basis, basis], coupling_rtol=invalid_tolerance) - for invalid_indices in ([0.9], [True]): - with pytest.raises(ValueError, match='entries must be integers'): - eme.mode_sections_from_bases([0.0, 1.0], [basis, basis], mode_indices=invalid_indices) - - -def test_mode_tracking_rejects_different_overlap_grids() -> None: - omega, dxes, epsilon = _waveguide_case() - basis = waveguide_2d_complex.solve_modes_near( - omega, dxes, epsilon, - target_wavenumbers=[1.45 * omega], modes_per_target=1, candidate_margin=2, - ) - changed_dxes = [[values.copy() for values in family] for family in basis.overlap_dxes] - changed_dxes[0][0][0] *= 1.01 - changed = dataclasses.replace(basis, overlap_dxes=changed_dxes) - - with pytest.raises(ValueError, match='identical overlap grids'): - waveguide_2d_complex.track_mode_path([basis, changed]) - for parameter in ('degeneracy_rtol', 'minimum_overlap'): - for invalid in (numpy.nan, numpy.inf, -numpy.inf): - with pytest.raises(ValueError, match=rf'{parameter} must be finite and positive'): - waveguide_2d_complex.track_mode_path([basis], **{parameter: invalid}) - - -def test_mode_tracking_refreshes_diagnostics_once_per_added_section(monkeypatch: pytest.MonkeyPatch) -> None: - omega, dxes, epsilon = _waveguide_case(shape=(13, 13), pml_cells=2) - basis = waveguide_2d_complex.solve_modes_near( - omega, dxes, epsilon, - target_wavenumbers=[1.45 * omega], modes_per_target=2, candidate_margin=2, - ) - permuted = dataclasses.replace( - basis, - wavenumbers=basis.wavenumbers[::-1], - wavenumber_matrix=basis.wavenumber_matrix[::-1, ::-1], - electric_fields=basis.electric_fields[::-1], - magnetic_fields=basis.magnetic_fields[::-1], - dual_electric_fields=basis.dual_electric_fields[::-1], - dual_magnetic_fields=basis.dual_magnetic_fields[::-1], - ) - original_refresh = waveguide_2d_complex._refresh_diagnostics - refresh_count = 0 - - def counted_refresh(current_basis): - nonlocal refresh_count - refresh_count += 1 - return original_refresh(current_basis) - - monkeypatch.setattr(waveguide_2d_complex, '_refresh_diagnostics', counted_refresh) - tracked = waveguide_2d_complex.track_mode_path([basis, permuted]) - - assert refresh_count == 1 - assert_allclose(numpy.sort_complex(numpy.linalg.eigvals(tracked[1].wavenumber_matrix)), numpy.sort_complex(basis.wavenumbers), atol=1e-9) - assert tracked[1].diagnostics.biorthogonality_error < 1e-9 - - -def test_mode_tracking_procrustes_path_carries_nondiagonal_wavenumber_matrix() -> None: - omega, dxes, epsilon = _waveguide_case(shape=(13, 13), pml_cells=2) - basis = waveguide_2d_complex.solve_modes_near( - omega, dxes, epsilon, - target_wavenumbers=[1.45 * omega], modes_per_target=2, candidate_margin=2, - ) - rotation = numpy.asarray([[1, 1j], [1j, 1]], dtype=complex) / numpy.sqrt(2) - previous = waveguide_2d_complex._transform_basis(basis, rotation) - - tracked = waveguide_2d_complex.track_mode_path([previous, basis], degeneracy_rtol=1.0) - off_diagonal = tracked[1].wavenumber_matrix - numpy.diag(numpy.diag(tracked[1].wavenumber_matrix)) - - assert numpy.linalg.norm(off_diagonal) > 1e-8 - assert_allclose(tracked[1].wavenumber_matrix, previous.wavenumber_matrix, atol=1e-9, rtol=1e-9) - assert numpy.max(tracked[1].diagnostics.right_residuals) < 1e-8 - assert numpy.max(tracked[1].diagnostics.left_residuals) < 1e-8 - assert tracked[1].diagnostics.biorthogonality_error < 1e-9 - - -def test_signed_beta_targets_return_forward_backward_pair() -> None: - omega, dxes, epsilon = _waveguide_case() - _field, beta = waveguide_2d.solve_mode(0, omega=omega, dxes=dxes, epsilon=epsilon) - basis = waveguide_2d_complex.solve_modes_near( - omega, dxes, epsilon, - target_wavenumbers=[0.99 * beta, -0.99 * beta], modes_per_target=1, candidate_margin=2, - ) - - assert basis.wavenumbers.size == 2 - assert_allclose(basis.wavenumbers[0], -basis.wavenumbers[1], rtol=1e-8, atol=1e-9) - n_points = 11 * 11 - phase = numpy.vdot(basis.electric_fields[0, :2 * n_points], basis.electric_fields[1, :2 * n_points]) - phase /= abs(phase) - assert_allclose(basis.electric_fields[0, :2 * n_points], basis.electric_fields[1, :2 * n_points] / phase, rtol=1e-7, atol=1e-8) - assert_allclose(basis.magnetic_fields[0, :2 * n_points], -basis.magnetic_fields[1, :2 * n_points] / phase, rtol=1e-7, atol=1e-8) - - -def test_guided_mode_is_stable_under_window_and_pml_refinement() -> None: - results = [] - for shape, pml_cells in ((15, 3), (19, 4)): - omega, dxes, epsilon = _waveguide_case(shape=(shape, shape), pml_cells=pml_cells) - basis = waveguide_2d_complex.solve_modes_near( - omega, dxes, epsilon, - target_wavenumbers=[1.8 * omega], modes_per_target=1, candidate_margin=2, - ) - results.append(basis) - - assert_allclose(results[0].wavenumbers, results[1].wavenumbers, rtol=1e-4, atol=1e-8) - assert results[1].diagnostics.pml_participation[0] < results[0].diagnostics.pml_participation[0] - - -def test_invalid_targeted_backend_result_is_rejected() -> None: - omega, dxes, epsilon = _waveguide_case() - - def invalid_backend(operator, _target, _count): - return TargetedEigensolution( - numpy.asarray([1.0]), - numpy.ones((operator.shape[0] - 1, 1)), - numpy.ones((operator.shape[0] - 1, 1)), - ) - - with pytest.raises(ValueError, match='eigenvector shapes'): - waveguide_2d_complex.solve_modes_near( - omega, dxes, epsilon, - target_wavenumbers=[1.45 * omega], modes_per_target=1, eigensolver=invalid_backend, - ) - - -@pytest.mark.complete -def test_richer_complex_basis_reduces_driven_fdfd_monitor_residual() -> None: - omega = 2 * numpy.pi / 1.55 - shape = (7, 7) - dxes_2d = _grid(shape) - left = numpy.full((3, *shape), 1.45 ** 2) - right = left.copy() - left[:, 2:5, 3:4] = 2.0 ** 2 - right[:, 2:5, 2:5] = 2.0 ** 2 - _ss, diagnostics = waveguide_3d.get_s_fdfd_from_2d_ports( - left, right, - omega=omega, - dxes_2d=dxes_2d, - mode_numbers=[0], - axis=2, - pml_cells=4, - pml_buffer_cells=2, - source_monitor_cells=4, - monitor_interface_cells=4, - transverse_padding_cells=2, - transverse_pml_cells=2, - pml_epsilon_effective=1.45 ** 2, - matrix_solver_opts={'atol': 1e-10, 'rtol': 1e-8}, - return_diagnostics=True, - ) - monitor_index = diagnostics['left_monitor_slice'][2].start - e_field = diagnostics['electric_fields'][0][:, :, :, monitor_index].reshape(3, -1) - h_field = diagnostics['magnetic_fields'][0][:, :, :, monitor_index].reshape(3, -1) - solve_dxes = [[diagnostics['solve_dxes'][family][axis] for axis in (0, 1)] for family in range(2)] - overlap_dxes = [[diagnostics['dxes'][family][axis] for axis in (0, 1)] for family in range(2)] - epsilon = vec(diagnostics['epsilon'][:, :, :, monitor_index]) - - def reconstruction_error(modes_per_target: int) -> float: - basis = waveguide_2d_complex.solve_modes_near( - omega, - solve_dxes, - epsilon, - overlap_dxes=overlap_dxes, - target_wavenumbers=[-1.8 * omega, -1.45 * omega, 1.45 * omega, 1.8 * omega], - modes_per_target=modes_per_target, - candidate_margin=2, - eigensolver_opts={ - 'v0': numpy.linspace(1.0, 2.0, 4 * e_field.shape[1], dtype=complex), - 'ncv': 24, - }, - ) - coefficients = numpy.asarray([ - waveguide_2d_complex._lorentz_overlap(dual_mode, (e_field.ravel(), h_field.ravel()), overlap_dxes) - for dual_mode in basis.dual_modes - ]) - reconstructed_e = coefficients @ basis.electric_fields - reconstructed_h = coefficients @ basis.magnetic_fields - mask = numpy.tile(basis.physical_mask.ravel(), 3) - residual = numpy.concatenate((e_field.ravel()[mask] - reconstructed_e[mask], h_field.ravel()[mask] - reconstructed_h[mask])) - reference = numpy.concatenate((e_field.ravel()[mask], h_field.ravel()[mask])) - return float(numpy.linalg.norm(residual) / numpy.linalg.norm(reference)) - - sparse_error = reconstruction_error(1) - rich_error = reconstruction_error(2) - assert rich_error < sparse_error - - -@pytest.mark.complete -def test_thin_core_taper_guided_loss_converges_with_basis_and_pml() -> None: - omega = 2 * numpy.pi / 1.55 - epsilon_cladding = 1.45 ** 2 - - def guided_loss(shape: int, pml_cells: int, modes_per_target: int) -> float: - overlap_dxes = _grid((shape, shape)) - solve_dxes = [[values.copy() for values in family] for family in overlap_dxes] - for axis in (0, 1): - for polarity in (-1, +1): - solve_dxes = scpml.stretch_with_scpml( - solve_dxes, axis=axis, polarity=polarity, omega=omega, - epsilon_effective=epsilon_cladding, thickness=pml_cells, - ) - center = shape // 2 - bases = [] - for edge_fill in (0.30, 0.35, 0.40): - epsilon = numpy.full((3, shape, shape), epsilon_cladding) - epsilon[:, center - 1:center + 2, center] = 2.0 ** 2 - mixed = epsilon_cladding + edge_fill * (2.0 ** 2 - epsilon_cladding) - epsilon[:, center - 1:center + 2, center - 1] = mixed - epsilon[:, center - 1:center + 2, center + 1] = mixed - basis = waveguide_2d_complex.solve_modes_near( - omega, solve_dxes, vec(epsilon), overlap_dxes=overlap_dxes, - target_wavenumbers=[1.8 * omega, 1.45 * omega], - modes_per_target=modes_per_target, candidate_margin=2, - eigensolver_opts={ - 'v0': numpy.linspace(1.0, 2.0, 4 * shape * shape, dtype=complex), - }, - ) - bases.append(waveguide_2d_complex.power_normalize_modes(basis, [0])) - tracked = waveguide_2d_complex.track_mode_path(bases) - scattering = eme.get_taper_s( - eme.mode_sections_from_bases([0.0, 5.0, 10.0], tracked), - dxes=overlap_dxes, - ) - mode_count = len(tracked[0]) - return float(1 - abs(scattering[0, 0]) ** 2 - abs(scattering[mode_count, 0]) ** 2) - - coarse_loss = guided_loss(15, 3, 1) - refined_loss = guided_loss(19, 4, 2) - assert numpy.isfinite([coarse_loss, refined_loss]).all() - assert -1e-6 <= coarse_loss <= 1 - assert -1e-6 <= refined_loss <= 1 - assert abs(coarse_loss - refined_loss) <= max(2e-5, 0.05 * abs(refined_loss)) diff --git a/meanas/test/test_waveguide_2d_modes.py b/meanas/test/test_waveguide_2d_modes.py new file mode 100644 index 0000000..3edbae8 --- /dev/null +++ b/meanas/test/test_waveguide_2d_modes.py @@ -0,0 +1,1368 @@ +import dataclasses +import numpy +import pytest +from numpy.testing import assert_allclose +from scipy import sparse +from scipy import linalg + +from ..eigensolvers import TargetedEigensolution, scipy_eigs_near +from ..fdmath import vec +from ..fdmath.operators import deriv_back, deriv_forward +from ..fdfd import eme, scpml, waveguide_2d, waveguide_3d + + +def _grid(shape: tuple[int, int], spacing: float = 0.3) -> list[list[numpy.ndarray]]: + return [[numpy.full(shape[0], spacing), numpy.full(shape[1], spacing)] for _ in range(2)] + + +def _waveguide_case( + shape: tuple[int, int] = (11, 11), + *, + pml_cells: int = 0, + ) -> tuple[float, list[list[numpy.ndarray]], numpy.ndarray]: + omega = 2 * numpy.pi / 1.55 + epsilon_cladding = 1.45 ** 2 + epsilon = numpy.full((3, *shape), epsilon_cladding) + cx, cy = (size // 2 for size in shape) + epsilon[:, cx - 1:cx + 2, cy - 1:cy + 2] = 2.0 ** 2 + dxes = _grid(shape) + if pml_cells: + for axis in (0, 1): + for polarity in (-1, +1): + dxes = scpml.stretch_with_scpml( + dxes, + axis=axis, + polarity=polarity, + omega=omega, + epsilon_effective=epsilon_cladding, + thickness=pml_cells, + ) + return omega, dxes, vec(epsilon) + + +def test_operator_beta_squares_to_existing_e_and_h_operators() -> None: + rng = numpy.random.default_rng(4) + shape = (4, 5) + omega = 1.7 + 0.03j + dxes = [[ + 0.7 + rng.random(size) + 0.02j * rng.random(size) + for size in shape + ] for _ in range(2)] + epsilon = vec(1.5 + rng.random((3, *shape))) + mu = vec(0.9 + 0.2 * rng.random((3, *shape))) + + operator = waveguide_2d.operator_beta(omega, dxes, epsilon, mu) + n_state = operator.shape[0] // 2 + squared = operator @ operator + electric = squared[:n_state, :n_state] + magnetic = squared[n_state:, n_state:] + + assert_allclose(electric.toarray(), waveguide_2d.operator_e(omega, dxes, epsilon, mu).toarray(), atol=3e-12) + assert_allclose(magnetic.toarray(), waveguide_2d.operator_h(omega, dxes, epsilon, mu).toarray(), atol=3e-12) + assert sparse.linalg.norm(squared[:n_state, n_state:]) == 0 + assert sparse.linalg.norm(squared[n_state:, :n_state]) == 0 + + +def test_nonuniform_reciprocal_operator_is_self_transpose_under_lorentz_metric() -> None: + rng = numpy.random.default_rng(8) + shape = (4, 5) + dxes = [ + [numpy.asarray([0.4, 0.7, 0.9, 1.2]), numpy.asarray([0.3, 0.5, 0.8, 1.0, 1.4])], + [numpy.asarray([0.55, 0.75, 1.05, 1.3]), numpy.asarray([0.2, 0.45, 0.7, 1.1, 1.5])], + ] + epsilon = vec(1.2 + rng.random((3, *shape))) + mu = vec(0.8 + 0.3 * rng.random((3, *shape))) + operator = waveguide_2d.operator_beta(1.7, dxes, epsilon, mu) + metric = waveguide_2d.lorentz_metric(dxes) + + defect = operator.T @ metric - metric @ operator + relative_defect = sparse.linalg.norm(defect) / sparse.linalg.norm(metric @ operator) + + assert relative_defect < 1e-12 + + +def test_nonuniform_reciprocal_modes_are_lorentz_orthogonal() -> None: + shape = (5, 5) + omega = 3.0 + dxes = [ + [numpy.asarray([0.2, 0.3, 0.4, 0.5, 0.6]), numpy.asarray([0.3, 0.4, 0.5, 0.6, 0.7])], + [numpy.asarray([0.25, 0.35, 0.45, 0.55, 0.6]), numpy.asarray([0.35, 0.45, 0.55, 0.65, 0.7])], + ] + epsilon_grid = numpy.full((3, *shape), 1.45 ** 2) + epsilon_grid[:, 1:4, 1:4] = 2.0 ** 2 + epsilon = vec(epsilon_grid) + basis, diagnostics = waveguide_2d.solve_modes_near_beta( + omega, + dxes, + epsilon, + target_wavenumbers=[1.7 * omega], + modes_per_target=2, + candidate_margin=3, + eigensolver_opts={ + 'v0': numpy.linspace(1.0, 2.0, 4 * shape[0] * shape[1], dtype=complex), + 'ncv': 16, + }, + ) + modes = tuple(zip(basis.electric_fields[:, 0], basis.magnetic_fields[:, 0], strict=True)) + gram = numpy.asarray([ + [waveguide_2d.lorentz_overlap(left_mode, right_mode, dxes) for right_mode in modes] + for left_mode in modes + ]) + off_diagonal = gram - numpy.diag(numpy.diag(gram)) + + assert numpy.linalg.norm(off_diagonal) / numpy.linalg.norm(numpy.diag(numpy.diag(gram))) < 1e-10 + + +def test_infer_physical_mask_excludes_complex_stretched_rows_and_columns() -> None: + dxes = [ + [numpy.ones(3, dtype=complex), numpy.ones(4, dtype=complex)], + [numpy.ones(3, dtype=complex), numpy.ones(4, dtype=complex)], + ] + dxes[0][0][1] += 0.2j + dxes[1][1][2] += 0.3j + expected = numpy.ones((3, 4), dtype=bool) + expected[1, :] = False + expected[:, 2] = False + + assert_allclose(waveguide_2d.infer_physical_mask(dxes), expected) + + +def test_waveguide_operators_are_covariant_under_axis_exchange() -> None: + rng = numpy.random.default_rng(12) + shape = (4, 5) + n_points = numpy.prod(shape) + omega = 1.37 + dxes = [ + [0.6 + 0.4 * rng.random(shape[0]), 0.7 + 0.3 * rng.random(shape[1])], + [0.5 + 0.5 * rng.random(shape[0]), 0.8 + 0.2 * rng.random(shape[1])], + ] + epsilon = vec(1.2 + rng.random((3, *shape))) + mu = vec(0.8 + 0.5 * rng.random((3, *shape))) + + def exchange_material_axes(material): + xx, yy, zz = numpy.split(material, 3) + return numpy.concatenate(( + yy.reshape(shape).T.ravel(), + xx.reshape(shape).T.ravel(), + zz.reshape(shape).T.ravel(), + )) + + exchanged_dxes = [[family[1], family[0]] for family in dxes] + exchanged_epsilon = exchange_material_axes(epsilon) + exchanged_mu = exchange_material_axes(mu) + rows = numpy.arange(n_points) + columns = rows.reshape(shape).T.ravel() + transpose = sparse.csr_array( + (numpy.ones(n_points), (rows, columns)), + shape=(n_points, n_points), + ) + empty = sparse.csr_array((n_points, n_points)) + polar_transform = sparse.block_array( + [[empty, transpose], [transpose, empty]], + format='csr', + ) + empty_transverse = sparse.csr_array((2 * n_points, 2 * n_points)) + state_transform = sparse.block_array( + [[polar_transform, empty_transverse], [empty_transverse, -polar_transform]], + format='csr', + ) + + electric = waveguide_2d.operator_e(omega, dxes, epsilon, mu) + exchanged_electric = waveguide_2d.operator_e( + omega, + exchanged_dxes, + exchanged_epsilon, + exchanged_mu, + ) + electric_defect = exchanged_electric @ polar_transform - polar_transform @ electric + assert sparse.linalg.norm(electric_defect) / sparse.linalg.norm(electric) < 1e-12 + + first_order = waveguide_2d.operator_beta(omega, dxes, epsilon, mu) + exchanged_first_order = waveguide_2d.operator_beta( + omega, + exchanged_dxes, + exchanged_epsilon, + exchanged_mu, + ) + first_order_defect = exchanged_first_order @ state_transform - state_transform @ first_order + assert sparse.linalg.norm(first_order_defect) / sparse.linalg.norm(first_order) < 1e-12 + + +def test_complex_solver_matches_guided_beta_and_builds_lorentz_duals() -> None: + omega, dxes, epsilon = _waveguide_case() + _e_xy, expected_beta = waveguide_2d.solve_mode(0, omega=omega, dxes=dxes, epsilon=epsilon) + + basis, diagnostics = waveguide_2d.solve_modes_near_beta( + omega, + dxes, + epsilon, + target_wavenumbers=[0.98 * expected_beta], + modes_per_target=1, + candidate_margin=2, + ) + + assert_allclose(basis.wavenumbers[0], expected_beta, rtol=1e-4, atol=1e-10) + assert diagnostics.right_residuals[0] < 1e-10 + assert diagnostics.left_residuals[0] < 1e-10 + overlap = 0.5 * ( + waveguide_2d.inner_product(basis.electric_fields[0, 1], basis.magnetic_fields[0, 0], dxes) + + waveguide_2d.inner_product(basis.electric_fields[0, 0], basis.magnetic_fields[0, 1], dxes) + ) + assert_allclose(overlap, 1.0, atol=1e-10) + + +def test_complex_solver_returns_passive_pml_modes_and_diagnostics() -> None: + omega, dxes, epsilon = _waveguide_case(shape=(15, 15), pml_cells=3) + + basis, diagnostics = waveguide_2d.solve_modes_near_beta( + omega, + dxes, + epsilon, + target_wavenumbers=[1.45 * omega], + modes_per_target=3, + candidate_margin=2, + ) + + assert numpy.all(numpy.imag(basis.wavenumbers) < 1e-8) + assert numpy.all((diagnostics.pml_participation >= 0) & (diagnostics.pml_participation <= 1)) + assert diagnostics.biorthogonality_error < 1e-10 + + +def test_mode_tracking_restores_permutation_phase_and_eme_adapter() -> None: + omega, dxes, epsilon = _waveguide_case(shape=(15, 15), pml_cells=3) + basis, diagnostics = waveguide_2d.solve_modes_near_beta( + omega, + dxes, + epsilon, + target_wavenumbers=[1.45 * omega], + modes_per_target=2, + candidate_margin=2, + ) + order = numpy.asarray([1, 0]) + permutation = numpy.eye(len(basis), dtype=complex)[:, order] + permuted = waveguide_2d.transform_mode_basis(basis, permutation, spectrum_order=order) + permuted = waveguide_2d.transform_mode_basis( + permuted, + numpy.diag(numpy.exp(1j * numpy.array([0.7, -0.4]))), + ) + + tracked = waveguide_2d.track_mode_path([basis, permuted], overlap_dxes=dxes) + assert_allclose(tracked[1].wavenumbers, basis.wavenumbers) + for index in range(len(basis)): + overlap = waveguide_2d.lorentz_overlap( + (tracked[0].electric_fields[index, 1], tracked[0].magnetic_fields[index, 1]), + (tracked[1].electric_fields[index, 0], tracked[1].magnetic_fields[index, 0]), + dxes, + ) + assert overlap.real > 0 + assert abs(overlap.imag) < 1e-10 + + sections = eme.mode_sections_from_bases([0.0, 2.0], tracked) + assert len(sections) == 2 + assert len(sections[0].modes) == len(basis) + assert sections[0].dual_modes is not None + assert sections[0].backward_modes is None + + +def test_power_normalization_preserves_dual_overlap() -> None: + omega, dxes, epsilon = _waveguide_case() + _e_xy, beta = waveguide_2d.solve_mode(0, omega=omega, dxes=dxes, epsilon=epsilon) + basis, diagnostics = waveguide_2d.solve_modes_near_beta( + omega, + dxes, + epsilon, + target_wavenumbers=[0.98 * beta], + modes_per_target=1, + candidate_margin=2, + ) + + normalized = waveguide_2d.power_normalize_modes(basis, [0], overlap_dxes=dxes) + normalized_diagnostics = waveguide_2d.compute_mode_diagnostics( + normalized, omega, dxes, epsilon, target_wavenumbers=[0.98 * beta], + ) + + assert_allclose(normalized_diagnostics.powers[0], 1.0, atol=1e-10) + overlap = waveguide_2d.lorentz_overlap( + (normalized.electric_fields[0, 1], normalized.magnetic_fields[0, 1]), + (normalized.electric_fields[0, 0], normalized.magnetic_fields[0, 0]), + dxes, + ) + assert_allclose(overlap, 1.0, atol=1e-10) + for parameter in ('power_atol', 'power_rtol'): + for invalid in (numpy.nan, numpy.inf, -numpy.inf): + with pytest.raises(ValueError, match=rf'{parameter} must be finite and positive'): + waveguide_2d.power_normalize_modes( + basis, [0], overlap_dxes=dxes, **{parameter: invalid}, + ) + + +def test_overlapping_targets_merge_instead_of_backfilling_farther_modes() -> None: + values = numpy.arange(1, 5, dtype=complex) + vectors = numpy.eye(4, dtype=complex) + solution = TargetedEigensolution(values, vectors, vectors) + + selected, _right, _left, target_indices = waveguide_2d._select_unique_modes( + [solution, solution], + numpy.asarray([1.1, 1.2]), + 2, + eigenvalue_rtol=1e-8, + subspace_tol=1e-8, + state_weights=numpy.ones(4), + ) + + assert_allclose(selected, [1, 2]) + assert_allclose(target_indices, [0, 1]) + + +def test_rotated_degenerate_candidate_subspaces_are_deduplicated_by_rank() -> None: + values = numpy.asarray([2.0, 2.0]) + vectors = numpy.eye(4, 2, dtype=complex) + rotation = numpy.asarray([[1, 1j], [1j, 1]], dtype=complex) / numpy.sqrt(2) + first = TargetedEigensolution(values, vectors, vectors) + second = TargetedEigensolution(values, vectors @ rotation, vectors @ rotation) + + selected, right, left, _targets = waveguide_2d._select_unique_modes( + [first, second], + numpy.asarray([1.9, 2.1]), + 2, + eigenvalue_rtol=1e-8, + subspace_tol=1e-8, + state_weights=numpy.ones(4), + ) + + assert selected.size == 2 + assert numpy.linalg.matrix_rank(right) == 2 + assert numpy.linalg.matrix_rank(left) == 2 + + +def test_independently_cross_paired_degenerate_subspaces_are_accepted() -> None: + e1 = numpy.asarray([1.0, 0.0], dtype=complex) + e2 = numpy.asarray([0.0, 1.0], dtype=complex) + first = TargetedEigensolution(numpy.asarray([2.0]), numpy.column_stack([e1]), numpy.column_stack([e1])) + second = TargetedEigensolution( + numpy.asarray([2.0, 2.0]), + numpy.column_stack([e2, e1]), + numpy.column_stack([e1, e2]), + ) + + selected, right, left, _targets = waveguide_2d._select_unique_modes( + [first, second], numpy.asarray([1.9, 2.1]), 1, + eigenvalue_rtol=1e-8, subspace_tol=1e-8, state_weights=numpy.ones(2), + ) + + assert selected.size == 2 + assert numpy.linalg.matrix_rank(right) == 2 + assert numpy.linalg.matrix_rank(left) == 2 + + +def test_single_target_cross_paired_degenerate_candidates_form_dual_basis() -> None: + e1 = numpy.asarray([1.0, 0.0], dtype=complex) + e2 = numpy.asarray([0.0, 1.0], dtype=complex) + solution = TargetedEigensolution( + numpy.asarray([2.0, 2.0]), + numpy.column_stack([e1, e2]), + numpy.column_stack([e2, e1]), + ) + + selected, right, left, _targets = waveguide_2d._select_unique_modes( + [solution], numpy.asarray([2.1]), 1, + eigenvalue_rtol=1e-8, subspace_tol=1e-8, state_weights=numpy.ones(2), + ) + + assert selected.size == 1 + assert right.shape == (2, 1) + assert left.shape == (2, 1) + assert abs((left.T @ right)[0, 0]) > 0.5 + + +def test_reserve_candidate_repairs_higher_rank_cross_gram() -> None: + vectors = numpy.eye(3, dtype=complex) + solution = TargetedEigensolution( + numpy.full(3, 2.0), + vectors, + vectors[:, [1, 2, 0]], + ) + + selected, right, left, _targets = waveguide_2d._select_unique_modes( + [solution], numpy.asarray([2.1]), 2, + eigenvalue_rtol=1e-8, subspace_tol=1e-8, state_weights=numpy.ones(3), + ) + + assert selected.size == 2 + assert numpy.linalg.matrix_rank(left.T @ right) == 2 + + +def test_cluster_with_insufficient_cross_rank_is_rejected() -> None: + e1 = numpy.asarray([1.0, 0.0], dtype=complex) + e2 = numpy.asarray([0.0, 1.0], dtype=complex) + first = TargetedEigensolution(numpy.asarray([2.0]), numpy.column_stack([e1]), numpy.column_stack([e1])) + second = TargetedEigensolution(numpy.asarray([2.0]), numpy.column_stack([e2]), numpy.column_stack([e1])) + + with pytest.raises(RuntimeError, match=r'required cross rank 2, available 1'): + waveguide_2d._select_unique_modes( + [first, second], numpy.asarray([1.9, 2.1]), 1, + eigenvalue_rtol=1e-8, subspace_tol=1e-8, state_weights=numpy.ones(2), + ) + + +def test_single_target_does_not_expand_to_full_degenerate_candidate_margin() -> None: + vectors = numpy.eye(5, dtype=complex) + solution = TargetedEigensolution(numpy.full(5, 2.0), vectors, vectors) + + selected, right, left, _targets = waveguide_2d._select_unique_modes( + [solution], numpy.asarray([2.1]), 1, + eigenvalue_rtol=1e-8, subspace_tol=1e-8, state_weights=numpy.ones(5), + ) + + assert selected.size == 1 + assert right.shape == (5, 1) + assert left.shape == (5, 1) + + +def test_mode_selection_is_invariant_to_candidate_scaling() -> None: + values = numpy.asarray([1.0, 2.0], dtype=complex) + vectors = numpy.eye(2, dtype=complex) + reference = TargetedEigensolution(values, vectors, vectors) + scaled = TargetedEigensolution( + values, + vectors * numpy.asarray([1e-12, 1e8]), + vectors * numpy.asarray([1e9j, -1e-11]), + ) + + outputs = [ + waveguide_2d._select_unique_modes( + [solution], numpy.asarray([1.5]), 2, + eigenvalue_rtol=1e-8, subspace_tol=1e-7, state_weights=numpy.ones(2), + ) + for solution in (reference, scaled) + ] + + assert_allclose(outputs[0][0], outputs[1][0]) + for _values, right, left, _targets in outputs: + assert numpy.linalg.matrix_rank(left.T @ right) == 2 + + +def test_mode_selection_is_invariant_to_uniform_weight_scaling() -> None: + values = numpy.asarray([1.0, 2.0], dtype=complex) + vectors = numpy.eye(2, dtype=complex) + solution = TargetedEigensolution(values, vectors, vectors) + + outputs = [ + waveguide_2d._select_unique_modes( + [solution], numpy.asarray([1.5]), 2, + eigenvalue_rtol=1e-8, subspace_tol=1e-7, + state_weights=numpy.full(2, weight_scale), + ) + for weight_scale in (1.0, 1e8) + ] + + assert_allclose(outputs[0][0], outputs[1][0]) + assert_allclose(outputs[0][1], outputs[1][1]) + assert_allclose(outputs[0][2], outputs[1][2]) + + +def test_complex_solver_is_invariant_to_backend_eigenvector_scaling() -> None: + omega, dxes, epsilon = _waveguide_case() + eigensolver_opts = {'v0': numpy.linspace(1.0, 2.0, 4 * 11 * 11, dtype=complex)} + cached_solution = None + + def cached_backend(operator, target, count, **options): + nonlocal cached_solution + if cached_solution is None: + cached_solution = scipy_eigs_near(operator, target, count, **options) + return cached_solution + + reference, reference_diagnostics = waveguide_2d.solve_modes_near_beta( + omega, dxes, epsilon, + target_wavenumbers=[1.45 * omega], modes_per_target=2, candidate_margin=2, + eigensolver=cached_backend, eigensolver_opts=eigensolver_opts, + ) + + def rescaled_backend(operator, target, count, **options): + solution = cached_backend(operator, target, count, **options) + order = numpy.argsort(numpy.abs(solution.eigenvalues - target)) + right_scales = numpy.ones(count) + right_scales[order[:2]] = [1e-16, 1e16] + return TargetedEigensolution( + solution.eigenvalues, + solution.right_eigenvectors * right_scales, + solution.left_eigenvectors, + ) + + scaled, scaled_diagnostics = waveguide_2d.solve_modes_near_beta( + omega, dxes, epsilon, + target_wavenumbers=[1.45 * omega], modes_per_target=2, candidate_margin=2, + eigensolver=rescaled_backend, eigensolver_opts=eigensolver_opts, + ) + + assert_allclose( + numpy.sort_complex(scaled.wavenumbers), + numpy.sort_complex(reference.wavenumbers), + atol=1e-10, + rtol=1e-10, + ) + assert numpy.max(scaled_diagnostics.right_residuals) < 1e-8 + assert numpy.max(scaled_diagnostics.left_residuals) < 1e-8 + assert scaled_diagnostics.biorthogonality_error < 1e-9 + assert_allclose( + scaled_diagnostics.biorthogonality_condition, + reference_diagnostics.biorthogonality_condition, + rtol=1e-10, + atol=1e-12, + ) + + +def test_refreshed_biorthogonality_condition_is_invariant_to_reciprocal_scaling() -> None: + omega, dxes, epsilon = _waveguide_case() + basis, diagnostics = waveguide_2d.solve_modes_near_beta( + omega, dxes, epsilon, + target_wavenumbers=[1.45 * omega], modes_per_target=2, candidate_margin=2, + ) + + scaled = waveguide_2d.transform_mode_basis(basis, numpy.diag(numpy.asarray([1e-4, 1e4]))) + scaled_diagnostics = waveguide_2d.compute_mode_diagnostics( + scaled, omega, dxes, epsilon, target_wavenumbers=[1.45 * omega], + ) + + assert_allclose( + scaled_diagnostics.biorthogonality_condition, + diagnostics.biorthogonality_condition, + rtol=1e-10, + atol=1e-12, + ) + assert scaled_diagnostics.biorthogonality_error < 1e-7 + + +@pytest.mark.parametrize('parameter', [ + 'residual_tol', + 'duplicate_eigenvalue_rtol', + 'duplicate_subspace_tol', +]) +@pytest.mark.parametrize('invalid', [numpy.nan, numpy.inf, -numpy.inf]) +def test_complex_solver_rejects_nonfinite_tolerances(parameter: str, invalid: float) -> None: + omega, dxes, epsilon = _waveguide_case() + + with pytest.raises(ValueError, match=rf'{parameter} must be finite and positive'): + waveguide_2d.solve_modes_near_beta( + omega, dxes, epsilon, + target_wavenumbers=[1.45 * omega], modes_per_target=1, + **{parameter: invalid}, + ) + + +def test_basis_rotation_carries_full_wavenumber_matrix_and_residuals() -> None: + omega, dxes, epsilon = _waveguide_case(shape=(13, 13), pml_cells=2) + basis, diagnostics = waveguide_2d.solve_modes_near_beta( + omega, dxes, epsilon, + target_wavenumbers=[1.45 * omega], modes_per_target=2, candidate_margin=2, + ) + rotation = numpy.asarray([[1, 1j], [1j, 1]], dtype=complex) / numpy.sqrt(2) + + rotated = waveguide_2d.transform_mode_basis(basis, rotation) + rotated_diagnostics = waveguide_2d.compute_mode_diagnostics( + rotated, omega, dxes, epsilon, target_wavenumbers=[1.45 * omega], + ) + expected = numpy.linalg.inv(rotation) @ numpy.diag(basis.wavenumbers) @ rotation + + assert_allclose(rotated.wavenumber_matrix, expected, atol=1e-11) + assert_allclose(numpy.sort_complex(numpy.linalg.eigvals(rotated.wavenumber_matrix)), numpy.sort_complex(basis.wavenumbers), atol=1e-10) + assert numpy.max(rotated_diagnostics.right_residuals) < 1e-8 + assert numpy.max(rotated_diagnostics.left_residuals) < 1e-8 + assert rotated_diagnostics.biorthogonality_error < 1e-9 + + for invalid_order in ( + numpy.asarray([0]), + numpy.asarray([0, 0]), + numpy.asarray([0.0, 1.0]), + numpy.asarray([False, True]), + ): + with pytest.raises(ValueError, match='complete integer permutation'): + waveguide_2d.transform_mode_basis(basis, rotation, spectrum_order=invalid_order) + + +def test_eme_propagates_constant_mixed_basis_with_full_matrix() -> None: + omega, dxes, epsilon = _waveguide_case(shape=(13, 13), pml_cells=2) + basis, diagnostics = waveguide_2d.solve_modes_near_beta( + omega, dxes, epsilon, + target_wavenumbers=[1.45 * omega], modes_per_target=2, candidate_margin=2, + ) + rotation = numpy.asarray([[1, 1j], [1j, 1]], dtype=complex) / numpy.sqrt(2) + rotated = waveguide_2d.transform_mode_basis(basis, rotation) + length = 0.7 + sections = eme.mode_sections_from_bases([0.0, length], [rotated, rotated]) + + transfer = eme.get_taper_abcd(sections, dxes=dxes).toarray() + expected = linalg.block_diag( + linalg.expm(-1j * rotated.wavenumber_matrix * length), + linalg.expm(+1j * rotated.wavenumber_matrix * length), + ) + assert_allclose(transfer, expected, atol=1e-9, rtol=1e-9) + + +def test_eme_adapter_rejects_subset_that_cuts_coupled_block() -> None: + omega, dxes, epsilon = _waveguide_case(shape=(13, 13), pml_cells=2) + basis, diagnostics = waveguide_2d.solve_modes_near_beta( + omega, dxes, epsilon, + target_wavenumbers=[1.45 * omega], modes_per_target=2, candidate_margin=2, + ) + rotation = numpy.asarray([[1, 1j], [1j, 1]], dtype=complex) / numpy.sqrt(2) + rotated = waveguide_2d.transform_mode_basis(basis, rotation) + + with pytest.raises(ValueError, match='complete coupled block'): + eme.mode_sections_from_bases([0.0, 1.0], [rotated, rotated], mode_indices=[0]) + with pytest.raises(ValueError, match='complete coupled block'): + eme.mode_sections_from_bases( + [0.0, 1.0], [basis, basis], mode_indices=[0], backward_bases=[rotated, rotated], + ) + sections = eme.mode_sections_from_bases([0.0, 1.0], [basis, basis], mode_indices=[0]) + assert len(sections[0].modes) == 1 + for invalid_tolerance in (numpy.nan, numpy.inf, -numpy.inf): + with pytest.raises(ValueError, match='finite and positive'): + eme.mode_sections_from_bases([0.0, 1.0], [basis, basis], coupling_rtol=invalid_tolerance) + for invalid_indices in ([0.9], [True]): + with pytest.raises(ValueError, match='entries must be integers'): + eme.mode_sections_from_bases([0.0, 1.0], [basis, basis], mode_indices=invalid_indices) + + +def test_mode_tracking_rejects_invalid_tolerances() -> None: + omega, dxes, epsilon = _waveguide_case() + basis, diagnostics = waveguide_2d.solve_modes_near_beta( + omega, dxes, epsilon, + target_wavenumbers=[1.45 * omega], modes_per_target=1, candidate_margin=2, + ) + for parameter in ('degeneracy_rtol', 'minimum_overlap'): + for invalid in (numpy.nan, numpy.inf, -numpy.inf): + with pytest.raises(ValueError, match=rf'{parameter} must be finite and positive'): + waveguide_2d.track_mode_path( + [basis], overlap_dxes=dxes, **{parameter: invalid}, + ) + + +def test_mode_tracking_supports_explicit_diagnostic_recomputation() -> None: + omega, dxes, epsilon = _waveguide_case(shape=(13, 13), pml_cells=2) + basis, diagnostics = waveguide_2d.solve_modes_near_beta( + omega, dxes, epsilon, + target_wavenumbers=[1.45 * omega], modes_per_target=2, candidate_margin=2, + ) + permuted = dataclasses.replace( + basis, + wavenumbers=basis.wavenumbers[::-1], + wavenumber_matrix=basis.wavenumber_matrix[::-1, ::-1], + electric_fields=basis.electric_fields[::-1], + magnetic_fields=basis.magnetic_fields[::-1], + ) + tracked = waveguide_2d.track_mode_path([basis, permuted], overlap_dxes=dxes) + tracked_diagnostics = waveguide_2d.compute_mode_diagnostics( + tracked[1], omega, dxes, epsilon, target_wavenumbers=[1.45 * omega], + ) + + assert_allclose(numpy.sort_complex(numpy.linalg.eigvals(tracked[1].wavenumber_matrix)), numpy.sort_complex(basis.wavenumbers), atol=1e-9) + assert tracked_diagnostics.biorthogonality_error < 1e-9 + + +def test_mode_tracking_procrustes_path_carries_nondiagonal_wavenumber_matrix() -> None: + omega, dxes, epsilon = _waveguide_case(shape=(13, 13), pml_cells=2) + basis, diagnostics = waveguide_2d.solve_modes_near_beta( + omega, dxes, epsilon, + target_wavenumbers=[1.45 * omega], modes_per_target=2, candidate_margin=2, + ) + rotation = numpy.asarray([[1, 1j], [1j, 1]], dtype=complex) / numpy.sqrt(2) + previous = waveguide_2d.transform_mode_basis(basis, rotation) + + tracked = waveguide_2d.track_mode_path( + [previous, basis], overlap_dxes=dxes, degeneracy_rtol=1.0, + ) + tracked_diagnostics = waveguide_2d.compute_mode_diagnostics( + tracked[1], omega, dxes, epsilon, target_wavenumbers=[1.45 * omega], + ) + off_diagonal = tracked[1].wavenumber_matrix - numpy.diag(numpy.diag(tracked[1].wavenumber_matrix)) + + assert numpy.linalg.norm(off_diagonal) > 1e-8 + assert_allclose(tracked[1].wavenumber_matrix, previous.wavenumber_matrix, atol=1e-9, rtol=1e-9) + assert numpy.max(tracked_diagnostics.right_residuals) < 1e-8 + assert numpy.max(tracked_diagnostics.left_residuals) < 1e-8 + assert tracked_diagnostics.biorthogonality_error < 1e-9 + + +def test_signed_beta_targets_return_forward_backward_pair() -> None: + omega, dxes, epsilon = _waveguide_case() + _field, beta = waveguide_2d.solve_mode(0, omega=omega, dxes=dxes, epsilon=epsilon) + basis, diagnostics = waveguide_2d.solve_modes_near_beta( + omega, dxes, epsilon, + target_wavenumbers=[0.99 * beta, -0.99 * beta], modes_per_target=1, candidate_margin=2, + ) + + assert basis.wavenumbers.size == 2 + assert_allclose(basis.wavenumbers[0], -basis.wavenumbers[1], rtol=1e-8, atol=1e-9) + n_points = 11 * 11 + phase = numpy.vdot(basis.electric_fields[0, 0, :2 * n_points], basis.electric_fields[1, 0, :2 * n_points]) + phase /= abs(phase) + assert_allclose(basis.electric_fields[0, 0, :2 * n_points], basis.electric_fields[1, 0, :2 * n_points] / phase, rtol=1e-7, atol=1e-8) + assert_allclose(basis.magnetic_fields[0, 0, :2 * n_points], -basis.magnetic_fields[1, 0, :2 * n_points] / phase, rtol=1e-7, atol=1e-8) + + +def test_guided_mode_is_stable_under_window_and_pml_refinement() -> None: + results = [] + result_diagnostics = [] + for shape, pml_cells in ((15, 3), (19, 4)): + omega, dxes, epsilon = _waveguide_case(shape=(shape, shape), pml_cells=pml_cells) + basis, diagnostics = waveguide_2d.solve_modes_near_beta( + omega, dxes, epsilon, + target_wavenumbers=[1.8 * omega], modes_per_target=1, candidate_margin=2, + ) + results.append(basis) + result_diagnostics.append(diagnostics) + + assert_allclose(results[0].wavenumbers, results[1].wavenumbers, rtol=1e-4, atol=1e-8) + assert result_diagnostics[1].pml_participation[0] < result_diagnostics[0].pml_participation[0] + + +def test_beta_squared_targeted_solver_matches_first_order_pml_basis() -> None: + omega, dxes, epsilon = _waveguide_case(shape=(13, 13), pml_cells=2) + kwargs = { + 'target_wavenumbers': [1.45 * omega], + 'modes_per_target': 2, + 'candidate_margin': 2, + } + + first_order, first_order_diagnostics = waveguide_2d.solve_modes_near_beta(omega, dxes, epsilon, **kwargs) + beta_squared, beta_squared_diagnostics = waveguide_2d.solve_modes_near(omega, dxes, epsilon, **kwargs) + + assert_allclose( + numpy.sort_complex(beta_squared.wavenumbers), + numpy.sort_complex(first_order.wavenumbers), + rtol=1e-9, + atol=1e-10, + ) + assert numpy.max(beta_squared_diagnostics.right_residuals) < 1e-9 + assert numpy.max(beta_squared_diagnostics.left_residuals) < 1e-9 + assert beta_squared_diagnostics.biorthogonality_error < 1e-9 + assert_allclose( + numpy.sort(beta_squared_diagnostics.pml_participation), + numpy.sort(first_order_diagnostics.pml_participation), + rtol=1e-8, + atol=1e-10, + ) + + +@pytest.mark.parametrize( + ('solver', 'state_components'), + [ + (waveguide_2d.solve_modes_near_beta, 4), + (waveguide_2d.solve_modes_near, 2), + ], + ids=['first_order', 'beta_squared'], + ) +@pytest.mark.parametrize('omega', [2.0, 0.8], ids=['propagating', 'evanescent']) +def test_targeted_solver_matches_discrete_periodic_dispersion( + solver, + state_components: int, + omega: float, + ) -> None: + shape = (8, 5) + n_points = numpy.prod(shape) + spacing_x = 0.4 + spacing_y = 0.7 + epsilon_value = 2.0 + mu_value = 1.1 + harmonic = 1 + dxes = [[ + numpy.full(shape[0], spacing_x), + numpy.full(shape[1], spacing_y), + ] for _ in range(2)] + epsilon = vec(numpy.full((3, *shape), epsilon_value)) + mu = vec(numpy.full((3, *shape), mu_value)) + transverse_wavenumber = 2 * numpy.sin(numpy.pi * harmonic / shape[0]) / spacing_x + expected_beta_squared = omega ** 2 * epsilon_value * mu_value - transverse_wavenumber ** 2 + expected_beta = numpy.sqrt(expected_beta_squared + 0j) + + phase = numpy.exp(2j * numpy.pi * harmonic * numpy.arange(shape[0]) / shape[0])[:, None] + electric_y = numpy.broadcast_to(phase, shape).ravel() + electric_xy = numpy.concatenate((numpy.zeros(n_points, dtype=complex), electric_y)) + electric_operator = waveguide_2d.operator_e(omega, dxes, epsilon, mu) + dispersion_residual = numpy.linalg.norm( + electric_operator @ electric_xy - expected_beta_squared * electric_xy + ) / numpy.linalg.norm(electric_xy) + assert dispersion_residual < 1e-12 + + basis, diagnostics = solver( + omega, + dxes, + epsilon, + mu=mu, + target_wavenumbers=[0.99 * expected_beta], + modes_per_target=1, + candidate_margin=6, + eigensolver_opts={ + 'v0': numpy.linspace(1.0, 2.0, state_components * n_points, dtype=complex), + 'ncv': 20, + }, + ) + + assert_allclose(basis.wavenumbers[0], expected_beta, rtol=1e-10, atol=1e-11) + if expected_beta_squared > 0: + assert basis.wavenumbers[0].real > 0 + assert abs(basis.wavenumbers[0].imag) < 1e-10 + else: + assert basis.wavenumbers[0].imag > 0 + assert abs(basis.wavenumbers[0].real) < 1e-10 + + +@pytest.mark.parametrize( + ('solver', 'state_components'), + [ + (waveguide_2d.solve_modes_near_beta, 4), + (waveguide_2d.solve_modes_near, 2), + ], + ids=['first_order', 'beta_squared'], + ) +def test_targeted_solver_satisfies_loss_attenuation_balance(solver, state_components: int) -> None: + shape = (5, 4) + n_points = numpy.prod(shape) + spacing_x = 0.3 + spacing_y = 0.45 + omega = 1.4 + dxes = [[ + numpy.full(shape[0], spacing_x), + numpy.full(shape[1], spacing_y), + ] for _ in range(2)] + epsilon_components = numpy.asarray([2.3 - 0.12j, 3.7, 2.8]) + mu_components = numpy.asarray([1.2, 1.1, 0.9]) + epsilon = vec(numpy.stack([numpy.full(shape, value) for value in epsilon_components])) + mu = vec(numpy.stack([numpy.full(shape, value) for value in mu_components])) + expected_beta = omega * numpy.sqrt(mu_components[1] * epsilon_components[0]) + + basis, diagnostics = solver( + omega, + dxes, + epsilon, + mu=mu, + target_wavenumbers=[0.99 * expected_beta], + modes_per_target=1, + candidate_margin=2, + eigensolver_opts={ + 'v0': numpy.linspace(1.0, 2.0, state_components * n_points, dtype=complex), + 'ncv': 12, + }, + ) + beta = basis.wavenumbers[0] + electric = basis.electric_fields[0, 0] + magnetic = basis.magnetic_fields[0, 0] + power = waveguide_2d.inner_product(electric, magnetic, dxes, conj_h=True).real + dissipated_power = ( + -0.5 * omega * spacing_x * spacing_y + * numpy.sum(numpy.imag(epsilon) * numpy.abs(electric) ** 2) + ) + attenuation_loss = -2 * beta.imag * power + + assert_allclose(beta, expected_beta, rtol=1e-10, atol=1e-11) + assert beta.real > 0 + assert beta.imag < 0 + assert power > 0 + assert dissipated_power > 0 + assert_allclose(attenuation_loss, dissipated_power, rtol=1e-10, atol=1e-12) + + +@pytest.mark.parametrize( + ('solver', 'state_components'), + [ + (waveguide_2d.solve_modes_near_beta, 4), + (waveguide_2d.solve_modes_near, 2), + ], + ids=['first_order', 'beta_squared'], + ) +def test_targeted_solver_group_velocity_matches_energy_velocity(solver, state_components: int) -> None: + shape = (8, 5) + n_points = numpy.prod(shape) + spacing_x = 0.4 + spacing_y = 0.7 + epsilon_value = 2.0 + mu_value = 1.1 + harmonic = 1 + omega = 2.0 + delta_omega = 1e-3 * omega + dxes = [[ + numpy.full(shape[0], spacing_x), + numpy.full(shape[1], spacing_y), + ] for _ in range(2)] + epsilon = vec(numpy.full((3, *shape), epsilon_value)) + mu = vec(numpy.full((3, *shape), mu_value)) + transverse_wavenumber = 2 * numpy.sin(numpy.pi * harmonic / shape[0]) / spacing_x + + bases = [] + for solve_omega in (omega - delta_omega, omega, omega + delta_omega): + expected_beta = numpy.sqrt( + solve_omega ** 2 * epsilon_value * mu_value - transverse_wavenumber ** 2 + ) + basis, _diagnostics = solver( + solve_omega, + dxes, + epsilon, + mu=mu, + target_wavenumbers=[0.99 * expected_beta], + modes_per_target=1, + candidate_margin=6, + eigensolver_opts={ + 'v0': numpy.linspace(1.0, 2.0, state_components * n_points, dtype=complex), + 'ncv': 20, + }, + ) + bases.append(basis) + + beta_minus, beta, beta_plus = (basis.wavenumbers[0].real for basis in bases) + finite_difference_velocity = 2 * delta_omega / (beta_plus - beta_minus) + electric = bases[1].electric_fields[0, 0] + magnetic = bases[1].magnetic_fields[0, 0] + power = waveguide_2d.inner_product(electric, magnetic, dxes, conj_h=True).real + stored_energy = 0.25 * spacing_x * spacing_y * ( + numpy.sum(numpy.real(epsilon) * numpy.abs(electric) ** 2) + + numpy.sum(numpy.real(mu) * numpy.abs(magnetic) ** 2) + ) + energy_velocity = power / stored_energy + analytic_velocity = beta / (omega * epsilon_value * mu_value) + + assert power > 0 + assert stored_energy > 0 + assert_allclose(energy_velocity, analytic_velocity, rtol=1e-10, atol=1e-12) + assert_allclose(finite_difference_velocity, energy_velocity, rtol=2e-6, atol=1e-10) + + +@pytest.mark.parametrize( + 'solver', + [waveguide_2d.solve_modes_near_beta, waveguide_2d.solve_modes_near], + ids=['first_order', 'beta_squared'], + ) +def test_targeted_solver_matches_homogeneous_anisotropic_modes(solver) -> None: + shape = (5, 5) + omega = 1.7 + dxes = _grid(shape) + epsilon_components = numpy.asarray([2.0, 4.0, 3.0]) + mu_components = numpy.asarray([2.0, 1.0, 1.5]) + epsilon = vec(numpy.stack([numpy.full(shape, value) for value in epsilon_components])) + mu = vec(numpy.stack([numpy.full(shape, value) for value in mu_components])) + expected_wavenumbers = omega * numpy.asarray([ + numpy.sqrt(mu_components[1] * epsilon_components[0]), + numpy.sqrt(mu_components[0] * epsilon_components[1]), + ]) + + basis, diagnostics = solver( + omega, + dxes, + epsilon, + mu=mu, + target_wavenumbers=0.995 * expected_wavenumbers, + modes_per_target=1, + candidate_margin=2, + ) + + assert_allclose(basis.wavenumbers, expected_wavenumbers, rtol=1e-10, atol=1e-11) + ex_mode_e = numpy.split(basis.electric_fields[0, 0], 3) + ex_mode_h = numpy.split(basis.magnetic_fields[0, 0], 3) + ey_mode_e = numpy.split(basis.electric_fields[1, 0], 3) + ey_mode_h = numpy.split(basis.magnetic_fields[1, 0], 3) + + assert numpy.linalg.norm(numpy.concatenate((ex_mode_e[1], ex_mode_e[2]))) < 1e-10 * numpy.linalg.norm(ex_mode_e[0]) + assert numpy.linalg.norm(numpy.concatenate((ex_mode_h[0], ex_mode_h[2]))) < 1e-10 * numpy.linalg.norm(ex_mode_h[1]) + assert numpy.linalg.norm(numpy.concatenate((ey_mode_e[0], ey_mode_e[2]))) < 1e-10 * numpy.linalg.norm(ey_mode_e[1]) + assert numpy.linalg.norm(numpy.concatenate((ey_mode_h[1], ey_mode_h[2]))) < 1e-10 * numpy.linalg.norm(ey_mode_h[0]) + assert_allclose( + ex_mode_h[1], + basis.wavenumbers[0] * ex_mode_e[0] / (omega * mu_components[1]), + rtol=1e-10, + atol=1e-11, + ) + assert_allclose( + ey_mode_h[0], + -basis.wavenumbers[1] * ey_mode_e[1] / (omega * mu_components[0]), + rtol=1e-10, + atol=1e-11, + ) + + +@pytest.mark.parametrize( + 'solver', + [waveguide_2d.solve_modes_near_beta, waveguide_2d.solve_modes_near], + ids=['first_order', 'beta_squared'], + ) +def test_targeted_solver_reconstructed_fields_satisfy_maxwell_constraints(solver) -> None: + shape = (11, 11) + omega, dxes, epsilon = _waveguide_case(shape=shape, pml_cells=2) + mu_grid = numpy.ones((3, *shape)) + mu_grid[0] *= 1.1 + mu_grid[1] *= 0.9 + mu_grid[2] *= 1.05 + mu_grid[:, shape[0] // 2, shape[1] // 2] *= 1.03 + mu = vec(mu_grid) + basis, diagnostics = solver( + omega, + dxes, + epsilon, + mu=mu, + target_wavenumbers=[1.8 * omega], + modes_per_target=1, + candidate_margin=2, + ) + + beta = basis.wavenumbers[0] + electric = basis.electric_fields[0, 0] + magnetic = basis.magnetic_fields[0, 0] + electric_from_magnetic = waveguide_2d.h2e(beta, omega, dxes, epsilon) @ magnetic + magnetic_from_electric = waveguide_2d.e2h(beta, omega, dxes, mu) @ electric + assert numpy.linalg.norm(electric_from_magnetic - electric) / numpy.linalg.norm(electric) < 1e-10 + assert numpy.linalg.norm(magnetic_from_electric - magnetic) / numpy.linalg.norm(magnetic) < 1e-10 + + ex, ey, ez = numpy.split(electric, 3) + hx, hy, hz = numpy.split(magnetic, 3) + eps_x, eps_y, eps_z = numpy.split(epsilon, 3) + mu_x, mu_y, mu_z = numpy.split(mu, 3) + dfx, dfy = deriv_forward(dxes[0]) + dbx, dby = deriv_back(dxes[1]) + transverse_d = dbx @ (eps_x * ex) + dby @ (eps_y * ey) + longitudinal_d = -1j * beta * eps_z * ez + transverse_b = dfx @ (mu_x * hx) + dfy @ (mu_y * hy) + longitudinal_b = -1j * beta * mu_z * hz + divergence_d = numpy.linalg.norm(transverse_d + longitudinal_d) / ( + numpy.linalg.norm(transverse_d) + numpy.linalg.norm(longitudinal_d) + ) + divergence_b = numpy.linalg.norm(transverse_b + longitudinal_b) / ( + numpy.linalg.norm(transverse_b) + numpy.linalg.norm(longitudinal_b) + ) + assert divergence_d < 1e-10 + assert divergence_b < 1e-10 + + +@pytest.mark.parametrize( + 'solver', + [waveguide_2d.solve_modes_near_beta, waveguide_2d.solve_modes_near], + ids=['first_order', 'beta_squared'], + ) +def test_targeted_solver_is_scale_covariant(solver) -> None: + omega, dxes, epsilon = _waveguide_case() + scale = 2.7 + target = 1.8 * omega + reference, reference_diagnostics = solver( + omega, + dxes, + epsilon, + target_wavenumbers=[target], + modes_per_target=1, + candidate_margin=2, + ) + scaled_dxes = [[values * scale for values in family] for family in dxes] + scaled, scaled_diagnostics = solver( + omega / scale, + scaled_dxes, + epsilon, + target_wavenumbers=[target / scale], + modes_per_target=1, + candidate_margin=2, + ) + + assert_allclose(scaled.wavenumbers * scale, reference.wavenumbers, rtol=1e-9, atol=1e-11) + assert_allclose( + scaled_diagnostics.target_distances * scale, + reference_diagnostics.target_distances, + rtol=1e-9, + atol=1e-11, + ) + assert_allclose(scaled_diagnostics.powers, reference_diagnostics.powers, rtol=1e-9, atol=1e-11) + + +def test_beta_squared_evanescent_signed_branches_have_reactive_opposite_power() -> None: + shape = (7, 7) + omega = 1.2 + dxes = _grid(shape, spacing=0.4) + epsilon = vec(numpy.full((3, *shape), 2.0)) + mu = vec(numpy.full((3, *shape), 1.1)) + n_points = shape[0] * shape[1] + basis, diagnostics = waveguide_2d.solve_modes_near( + omega, + dxes, + epsilon, + mu=mu, + target_wavenumbers=[1j, -1j], + modes_per_target=1, + candidate_margin=2, + eigensolver_opts={ + 'v0': numpy.linspace(1.0, 2.0, 2 * n_points, dtype=complex), + 'ncv': 12, + }, + ) + + assert_allclose(basis.wavenumbers[0], -basis.wavenumbers[1], rtol=1e-10, atol=1e-11) + assert basis.wavenumbers[0].imag > 0 + assert basis.wavenumbers[1].imag < 0 + assert numpy.max(numpy.abs(basis.wavenumbers.real)) < 1e-10 + phase = numpy.vdot( + basis.electric_fields[0, 0, :2 * n_points], + basis.electric_fields[1, 0, :2 * n_points], + ) + phase /= abs(phase) + assert_allclose( + basis.electric_fields[0, 0, :2 * n_points], + basis.electric_fields[1, 0, :2 * n_points] / phase, + rtol=1e-10, + atol=1e-11, + ) + assert_allclose( + basis.electric_fields[0, 0, 2 * n_points:], + -basis.electric_fields[1, 0, 2 * n_points:] / phase, + rtol=1e-10, + atol=1e-11, + ) + assert_allclose( + basis.magnetic_fields[0, 0, :2 * n_points], + -basis.magnetic_fields[1, 0, :2 * n_points] / phase, + rtol=1e-10, + atol=1e-11, + ) + assert_allclose( + basis.magnetic_fields[0, 0, 2 * n_points:], + basis.magnetic_fields[1, 0, 2 * n_points:] / phase, + rtol=1e-10, + atol=1e-11, + ) + assert numpy.max(numpy.abs(diagnostics.powers.real)) < 1e-10 + assert numpy.min(numpy.abs(diagnostics.powers.imag)) > 1e-6 + assert_allclose( + diagnostics.powers[0], + -diagnostics.powers[1], + rtol=1e-10, + atol=1e-11, + ) + + +def test_targeted_mode_api_is_defined_in_waveguide_2d() -> None: + assert waveguide_2d.ModeBasis.__module__ == waveguide_2d.__name__ + assert waveguide_2d.ModeDiagnostics.__module__ == waveguide_2d.__name__ + assert waveguide_2d.solve_modes_near.__module__ == waveguide_2d.__name__ + assert waveguide_2d.solve_modes_near_beta.__module__ == waveguide_2d.__name__ + + +def test_beta_squared_signed_targets_share_solve_and_lift_opposite_branches() -> None: + omega, dxes, epsilon = _waveguide_case() + _field, beta = waveguide_2d.solve_mode(0, omega=omega, dxes=dxes, epsilon=epsilon) + backend_calls: list[tuple[tuple[int, int], complex]] = [] + + def counting_backend(operator, target, count, **options): + backend_calls.append((operator.shape, target)) + return scipy_eigs_near(operator, target, count, **options) + + target = 0.99 * beta + basis, diagnostics = waveguide_2d.solve_modes_near( + omega, + dxes, + epsilon, + target_wavenumbers=[target, -target], + modes_per_target=1, + candidate_margin=2, + eigensolver=counting_backend, + ) + + assert len(backend_calls) == 1 + n_points = dxes[0][0].size * dxes[0][1].size + assert backend_calls[0][0] == (2 * n_points, 2 * n_points) + assert_allclose(backend_calls[0][1], target ** 2) + assert_allclose(basis.wavenumbers[0], -basis.wavenumbers[1], rtol=1e-9, atol=1e-10) + phase = numpy.vdot( + basis.electric_fields[0, 0, :2 * n_points], + basis.electric_fields[1, 0, :2 * n_points], + ) + phase /= abs(phase) + assert_allclose( + basis.electric_fields[0, 0, :2 * n_points], + basis.electric_fields[1, 0, :2 * n_points] / phase, + rtol=1e-7, + atol=1e-8, + ) + assert_allclose( + basis.magnetic_fields[0, 0, :2 * n_points], + -basis.magnetic_fields[1, 0, :2 * n_points] / phase, + rtol=1e-7, + atol=1e-8, + ) + + +def test_beta_squared_basis_supports_tracking_normalization_and_eme() -> None: + omega, dxes, epsilon = _waveguide_case(shape=(13, 13)) + basis, diagnostics = waveguide_2d.solve_modes_near( + omega, + dxes, + epsilon, + target_wavenumbers=[1.8 * omega], + modes_per_target=1, + candidate_margin=2, + ) + normalized = waveguide_2d.power_normalize_modes(basis, [0], overlap_dxes=dxes) + tracked = waveguide_2d.track_mode_path([normalized, normalized], overlap_dxes=dxes) + normalized_diagnostics = waveguide_2d.compute_mode_diagnostics( + normalized, omega, dxes, epsilon, target_wavenumbers=[1.8 * omega], + ) + length = 0.7 + sections = eme.mode_sections_from_bases([0.0, length], tracked) + + transfer = eme.get_taper_abcd(sections, dxes=dxes).toarray() + expected = linalg.block_diag( + numpy.diag(numpy.exp(-1j * tracked[0].wavenumbers * length)), + numpy.diag(numpy.exp(+1j * tracked[0].wavenumbers * length)), + ) + assert_allclose(normalized_diagnostics.powers[0], 1.0, atol=1e-9) + assert_allclose(transfer, expected, atol=1e-9, rtol=1e-9) + + +def test_beta_squared_targeted_solver_rejects_zero_beta_lift() -> None: + omega, dxes, epsilon = _waveguide_case() + + def zero_backend(operator, _target, _count, **_options): + vector = numpy.ones((operator.shape[0], 1), dtype=complex) + return TargetedEigensolution(numpy.zeros(1, dtype=complex), vector, vector) + + with pytest.raises(RuntimeError, match='zero-beta modes cannot be lifted'): + waveguide_2d.solve_modes_near( + omega, + dxes, + epsilon, + target_wavenumbers=[0.0], + modes_per_target=1, + candidate_margin=0, + eigensolver=zero_backend, + ) + + +def test_invalid_targeted_backend_result_is_rejected() -> None: + omega, dxes, epsilon = _waveguide_case() + + def invalid_backend(operator, _target, _count): + return TargetedEigensolution( + numpy.asarray([1.0]), + numpy.ones((operator.shape[0] - 1, 1)), + numpy.ones((operator.shape[0] - 1, 1)), + ) + + with pytest.raises(ValueError, match='eigenvector shapes'): + waveguide_2d.solve_modes_near_beta( + omega, dxes, epsilon, + target_wavenumbers=[1.45 * omega], modes_per_target=1, eigensolver=invalid_backend, + ) + + +@pytest.mark.complete +def test_richer_complex_basis_reduces_driven_fdfd_monitor_residual() -> None: + omega = 2 * numpy.pi / 1.55 + shape = (7, 7) + dxes_2d = _grid(shape) + left = numpy.full((3, *shape), 1.45 ** 2) + right = left.copy() + left[:, 2:5, 3:4] = 2.0 ** 2 + right[:, 2:5, 2:5] = 2.0 ** 2 + _ss, diagnostics = waveguide_3d.get_s_fdfd_from_2d_ports( + left, right, + omega=omega, + dxes_2d=dxes_2d, + mode_numbers=[0], + axis=2, + pml_cells=4, + pml_buffer_cells=2, + source_monitor_cells=4, + monitor_interface_cells=4, + transverse_padding_cells=2, + transverse_pml_cells=2, + pml_epsilon_effective=1.45 ** 2, + matrix_solver_opts={'atol': 1e-10, 'rtol': 1e-8}, + return_diagnostics=True, + ) + monitor_index = diagnostics['left_monitor_slice'][2].start + e_field = diagnostics['electric_fields'][0][:, :, :, monitor_index].reshape(3, -1) + h_field = diagnostics['magnetic_fields'][0][:, :, :, monitor_index].reshape(3, -1) + solve_dxes = [[diagnostics['solve_dxes'][family][axis] for axis in (0, 1)] for family in range(2)] + overlap_dxes = [[diagnostics['dxes'][family][axis] for axis in (0, 1)] for family in range(2)] + epsilon = vec(diagnostics['epsilon'][:, :, :, monitor_index]) + + def reconstruction_error(modes_per_target: int) -> float: + basis, diagnostics = waveguide_2d.solve_modes_near_beta( + omega, + solve_dxes, + epsilon, + overlap_dxes=overlap_dxes, + target_wavenumbers=[-1.8 * omega, -1.45 * omega, 1.45 * omega, 1.8 * omega], + modes_per_target=modes_per_target, + candidate_margin=2, + eigensolver_opts={ + 'v0': numpy.linspace(1.0, 2.0, 4 * e_field.shape[1], dtype=complex), + 'ncv': 24, + }, + ) + coefficients = numpy.asarray([ + waveguide_2d.lorentz_overlap( + (dual_e, dual_h), (e_field.ravel(), h_field.ravel()), overlap_dxes, + ) + for dual_e, dual_h in zip( + basis.electric_fields[:, 1], basis.magnetic_fields[:, 1], strict=True, + ) + ]) + reconstructed_e = coefficients @ basis.electric_fields[:, 0] + reconstructed_h = coefficients @ basis.magnetic_fields[:, 0] + mask = numpy.tile(waveguide_2d.infer_physical_mask(solve_dxes).ravel(), 3) + residual = numpy.concatenate((e_field.ravel()[mask] - reconstructed_e[mask], h_field.ravel()[mask] - reconstructed_h[mask])) + reference = numpy.concatenate((e_field.ravel()[mask], h_field.ravel()[mask])) + return float(numpy.linalg.norm(residual) / numpy.linalg.norm(reference)) + + sparse_error = reconstruction_error(1) + rich_error = reconstruction_error(2) + assert rich_error < sparse_error + + +@pytest.mark.complete +def test_thin_core_taper_guided_loss_converges_with_basis_and_pml() -> None: + omega = 2 * numpy.pi / 1.55 + epsilon_cladding = 1.45 ** 2 + + def guided_loss(shape: int, pml_cells: int, modes_per_target: int) -> tuple[float, float]: + overlap_dxes = _grid((shape, shape)) + solve_dxes = [[values.copy() for values in family] for family in overlap_dxes] + for axis in (0, 1): + for polarity in (-1, +1): + solve_dxes = scpml.stretch_with_scpml( + solve_dxes, axis=axis, polarity=polarity, omega=omega, + epsilon_effective=epsilon_cladding, thickness=pml_cells, + ) + center = shape // 2 + bases = [] + for edge_fill in (0.30, 0.35, 0.40): + epsilon = numpy.full((3, shape, shape), epsilon_cladding) + epsilon[:, center - 1:center + 2, center] = 2.0 ** 2 + mixed = epsilon_cladding + edge_fill * (2.0 ** 2 - epsilon_cladding) + epsilon[:, center - 1:center + 2, center - 1] = mixed + epsilon[:, center - 1:center + 2, center + 1] = mixed + basis, diagnostics = waveguide_2d.solve_modes_near_beta( + omega, solve_dxes, vec(epsilon), overlap_dxes=overlap_dxes, + target_wavenumbers=[1.8 * omega, 1.45 * omega], + modes_per_target=modes_per_target, candidate_margin=2, + eigensolver_opts={ + 'v0': numpy.linspace(1.0, 2.0, 4 * shape * shape, dtype=complex), + }, + ) + bases.append(waveguide_2d.power_normalize_modes( + basis, [0], overlap_dxes=overlap_dxes, + )) + tracked = waveguide_2d.track_mode_path(bases, overlap_dxes=overlap_dxes) + scattering = eme.get_taper_s( + eme.mode_sections_from_bases([0.0, 5.0, 10.0], tracked), + dxes=overlap_dxes, + ) + mode_count = len(tracked[0]) + unresolved = float(1 - abs(scattering[0, 0]) ** 2 - abs(scattering[mode_count, 0]) ** 2) + # The finite PML eigenproblem can supply a slightly growing guided + # beta. Account for that input growth explicitly, rather than clipping + # S or attributing it to the cascade. Domain refinement must remove it. + integrated_imag_beta = numpy.trapezoid([basis.wavenumbers[0].imag for basis in tracked], [0.0, 5.0, 10.0]) + input_gain = max(0.0, float(numpy.expm1(2 * integrated_imag_beta))) + return unresolved, input_gain + + coarse_loss, coarse_gain = guided_loss(15, 3, 1) + refined_loss, refined_gain = guided_loss(19, 4, 2) + assert numpy.isfinite([coarse_loss, refined_loss, coarse_gain, refined_gain]).all() + assert -coarse_gain - 1e-9 <= coarse_loss <= 1 + assert -refined_gain - 1e-9 <= refined_loss <= 1 + assert refined_gain < coarse_gain + assert abs(coarse_loss - refined_loss) <= max(2e-5, 0.05 * abs(refined_loss)) diff --git a/meanas/test/test_waveguide_2d_numerics.py b/meanas/test/test_waveguide_2d_numerics.py index 16ac613..a7be517 100644 --- a/meanas/test/test_waveguide_2d_numerics.py +++ b/meanas/test/test_waveguide_2d_numerics.py @@ -286,3 +286,35 @@ def test_waveguide_2d_inner_product_trapezoid_branch_on_nonuniform_grid() -> Non assert abs(overlap_rect.imag) < 1e-10 assert abs(overlap_trap.imag) < 1e-10 assert abs(overlap_rect - overlap_trap) > 0.1 + + +def test_waveguide_2d_inner_product_uses_staggered_nonuniform_areas() -> None: + shape = (3, 4) + dxes = [ + [numpy.asarray([0.4, 0.7, 1.1]), numpy.asarray([0.3, 0.5, 0.8, 1.2])], + [numpy.asarray([0.6, 0.9, 1.3]), numpy.asarray([0.2, 0.45, 0.75, 1.0])], + ] + samples = numpy.arange(numpy.prod(shape), dtype=float).reshape(shape) + electric = vec(numpy.stack((1.0 + samples, 0.25 - 0.3 * samples, numpy.zeros(shape)))) + magnetic = vec(numpy.stack((-0.4 + 0.2 * samples, 2.0 - 0.1 * samples, numpy.zeros(shape)))) + ex, ey, _ez = numpy.split(electric, 3) + hx, hy, _hz = numpy.split(magnetic, 3) + ex = ex.reshape(shape) + ey = ey.reshape(shape) + hx = hx.reshape(shape) + hy = hy.reshape(shape) + + expected_rectangular = 0.5 * ( + numpy.sum(ex * hy * dxes[0][0][:, None] * dxes[1][1][None, :]) + - numpy.sum(ey * hx * dxes[1][0][:, None] * dxes[0][1][None, :]) + ) + expected_trapezoidal = 0.5 * ( + numpy.trapezoid(numpy.trapezoid(ex * hy, numpy.cumsum(dxes[1][1])), numpy.cumsum(dxes[0][0])) + - numpy.trapezoid(numpy.trapezoid(ey * hx, numpy.cumsum(dxes[0][1])), numpy.cumsum(dxes[1][0])) + ) + + assert_allclose(waveguide_2d.inner_product(electric, magnetic, dxes), expected_rectangular) + assert_allclose( + waveguide_2d.inner_product(electric, magnetic, dxes, trapezoid=True), + expected_trapezoidal, + ) diff --git a/pyproject.toml b/pyproject.toml index 7f1d6b4..e41fcec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ "License :: OSI Approved :: GNU Affero General Public License v3", "Topic :: Scientific/Engineering :: Physics", ] -requires-python = ">=3.11" +requires-python = ">=3.12" include = [ "LICENSE.md" ] diff --git a/uv.lock b/uv.lock index 08bad0c..866d816 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -requires-python = ">=3.11" +requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'emscripten'", @@ -60,22 +60,6 @@ version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705 }, - { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419 }, - { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901 }, - { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742 }, - { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061 }, - { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239 }, - { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173 }, - { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841 }, - { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304 }, - { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455 }, - { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036 }, - { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739 }, - { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277 }, - { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819 }, - { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281 }, - { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843 }, { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328 }, { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061 }, { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031 }, @@ -173,17 +157,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174 } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773 }, - { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149 }, - { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222 }, - { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234 }, - { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555 }, - { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238 }, - { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218 }, - { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867 }, - { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677 }, - { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234 }, - { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123 }, { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419 }, { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979 }, { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653 }, @@ -239,11 +212,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428 }, { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331 }, { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831 }, - { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809 }, - { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593 }, - { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202 }, - { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207 }, - { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315 }, ] [[package]] @@ -252,21 +220,6 @@ version = "7.13.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967 } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381 }, - { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880 }, - { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303 }, - { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218 }, - { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326 }, - { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267 }, - { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430 }, - { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017 }, - { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080 }, - { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843 }, - { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802 }, - { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707 }, - { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880 }, - { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816 }, - { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483 }, { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554 }, { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908 }, { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419 }, @@ -373,14 +326,6 @@ version = "4.61.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/33/f9/0e84d593c0e12244150280a630999835a64f2852276161b62a0f98318de0/fonttools-4.61.0.tar.gz", hash = "sha256:ec520a1f0c7758d7a858a00f090c1745f6cde6a7c5e76fb70ea4044a15f712e7", size = 3561884 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/be/5aa89cdddf2863d8afbdc19eb8ec5d8d35d40eeeb8e6cf52c5ff1c2dbd33/fonttools-4.61.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a32a16951cbf113d38f1dd8551b277b6e06e0f6f776fece0f99f746d739e1be3", size = 2847553 }, - { url = "https://files.pythonhosted.org/packages/0d/3e/6ff643b07cead1236a534f51291ae2981721cf419135af5b740c002a66dd/fonttools-4.61.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:328a9c227984bebaf69f3ac9062265f8f6acc7ddf2e4e344c63358579af0aa3d", size = 2388298 }, - { url = "https://files.pythonhosted.org/packages/c3/15/fca8dfbe7b482e6f240b1aad0ed7c6e2e75e7a28efa3d3a03b570617b5e5/fonttools-4.61.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f0bafc8a3b3749c69cc610e5aa3da832d39c2a37a68f03d18ec9a02ecaac04a", size = 5054133 }, - { url = "https://files.pythonhosted.org/packages/6a/a2/821c61c691b21fd09e07528a9a499cc2b075ac83ddb644aa16c9875a64bc/fonttools-4.61.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5ca59b7417d149cf24e4c1933c9f44b2957424fc03536f132346d5242e0ebe5", size = 5031410 }, - { url = "https://files.pythonhosted.org/packages/e8/f6/8b16339e93d03c732c8a23edefe3061b17a5f9107ddc47a3215ecd054cac/fonttools-4.61.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:df8cbce85cf482eb01f4551edca978c719f099c623277bda8332e5dbe7dba09d", size = 5030005 }, - { url = "https://files.pythonhosted.org/packages/ac/eb/d4e150427bdaa147755239c931bbce829a88149ade5bfd8a327afe565567/fonttools-4.61.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7fb5b84f48a6a733ca3d7f41aa9551908ccabe8669ffe79586560abcc00a9cfd", size = 5154026 }, - { url = "https://files.pythonhosted.org/packages/7f/5f/3dd00ce0dba6759943c707b1830af8c0bcf6f8f1a9fe46cb82e7ac2aaa74/fonttools-4.61.0-cp311-cp311-win32.whl", hash = "sha256:787ef9dfd1ea9fe49573c272412ae5f479d78e671981819538143bec65863865", size = 2276035 }, - { url = "https://files.pythonhosted.org/packages/4e/44/798c472f096ddf12955eddb98f4f7c906e7497695d04ce073ddf7161d134/fonttools-4.61.0-cp311-cp311-win_amd64.whl", hash = "sha256:14fafda386377b6131d9e448af42d0926bad47e038de0e5ba1d58c25d621f028", size = 2327290 }, { url = "https://files.pythonhosted.org/packages/00/5d/19e5939f773c7cb05480fe2e881d63870b63ee2b4bdb9a77d55b1d36c7b9/fonttools-4.61.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e24a1565c4e57111ec7f4915f8981ecbb61adf66a55f378fdc00e206059fcfef", size = 2846930 }, { url = "https://files.pythonhosted.org/packages/25/b2/0658faf66f705293bd7e739a4f038302d188d424926be9c59bdad945664b/fonttools-4.61.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2bfacb5351303cae9f072ccf3fc6ecb437a6f359c0606bae4b1ab6715201d87", size = 2383016 }, { url = "https://files.pythonhosted.org/packages/29/a3/1fa90b95b690f0d7541f48850adc40e9019374d896c1b8148d15012b2458/fonttools-4.61.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0bdcf2e29d65c26299cc3d502f4612365e8b90a939f46cd92d037b6cb7bb544a", size = 4949425 }, @@ -495,19 +440,6 @@ version = "1.4.9" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167 }, - { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579 }, - { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309 }, - { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596 }, - { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548 }, - { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618 }, - { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437 }, - { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742 }, - { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810 }, - { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579 }, - { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071 }, - { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840 }, - { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159 }, { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686 }, { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460 }, { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952 }, @@ -572,11 +504,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835 }, { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988 }, { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260 }, - { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104 }, - { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592 }, - { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281 }, - { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009 }, - { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929 }, ] [[package]] @@ -594,17 +521,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631 }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058 }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287 }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940 }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887 }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692 }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471 }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923 }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572 }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077 }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876 }, { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 }, { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 }, { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 }, @@ -679,13 +595,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215 }, - { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625 }, - { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614 }, - { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997 }, - { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825 }, - { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090 }, - { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377 }, { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453 }, { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321 }, { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944 }, @@ -721,9 +630,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011 }, { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801 }, { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560 }, - { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198 }, - { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817 }, - { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867 }, ] [[package]] @@ -942,17 +848,6 @@ version = "2.3.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950 } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/77/84dd1d2e34d7e2792a236ba180b5e8fcc1e3e414e761ce0253f63d7f572e/numpy-2.3.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de5672f4a7b200c15a4127042170a694d4df43c992948f5e1af57f0174beed10", size = 17034641 }, - { url = "https://files.pythonhosted.org/packages/2a/ea/25e26fa5837106cde46ae7d0b667e20f69cbbc0efd64cba8221411ab26ae/numpy-2.3.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:acfd89508504a19ed06ef963ad544ec6664518c863436306153e13e94605c218", size = 12528324 }, - { url = "https://files.pythonhosted.org/packages/4d/1a/e85f0eea4cf03d6a0228f5c0256b53f2df4bc794706e7df019fc622e47f1/numpy-2.3.5-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ffe22d2b05504f786c867c8395de703937f934272eb67586817b46188b4ded6d", size = 5356872 }, - { url = "https://files.pythonhosted.org/packages/5c/bb/35ef04afd567f4c989c2060cde39211e4ac5357155c1833bcd1166055c61/numpy-2.3.5-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:872a5cf366aec6bb1147336480fef14c9164b154aeb6542327de4970282cd2f5", size = 6893148 }, - { url = "https://files.pythonhosted.org/packages/f2/2b/05bbeb06e2dff5eab512dfc678b1cc5ee94d8ac5956a0885c64b6b26252b/numpy-2.3.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3095bdb8dd297e5920b010e96134ed91d852d81d490e787beca7e35ae1d89cf7", size = 14557282 }, - { url = "https://files.pythonhosted.org/packages/65/fb/2b23769462b34398d9326081fad5655198fcf18966fcb1f1e49db44fbf31/numpy-2.3.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cba086a43d54ca804ce711b2a940b16e452807acebe7852ff327f1ecd49b0d4", size = 16897903 }, - { url = "https://files.pythonhosted.org/packages/ac/14/085f4cf05fc3f1e8aa95e85404e984ffca9b2275a5dc2b1aae18a67538b8/numpy-2.3.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6cf9b429b21df6b99f4dee7a1218b8b7ffbbe7df8764dc0bd60ce8a0708fed1e", size = 16341672 }, - { url = "https://files.pythonhosted.org/packages/6f/3b/1f73994904142b2aa290449b3bb99772477b5fd94d787093e4f24f5af763/numpy-2.3.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:396084a36abdb603546b119d96528c2f6263921c50df3c8fd7cb28873a237748", size = 18838896 }, - { url = "https://files.pythonhosted.org/packages/cd/b9/cf6649b2124f288309ffc353070792caf42ad69047dcc60da85ee85fea58/numpy-2.3.5-cp311-cp311-win32.whl", hash = "sha256:b0c7088a73aef3d687c4deef8452a3ac7c1be4e29ed8bf3b366c8111128ac60c", size = 6563608 }, - { url = "https://files.pythonhosted.org/packages/aa/44/9fe81ae1dcc29c531843852e2874080dc441338574ccc4306b39e2ff6e59/numpy-2.3.5-cp311-cp311-win_amd64.whl", hash = "sha256:a414504bef8945eae5f2d7cb7be2d4af77c5d1cb5e20b296c2c25b61dff2900c", size = 13078442 }, - { url = "https://files.pythonhosted.org/packages/6d/a7/f99a41553d2da82a20a2f22e93c94f928e4490bb447c9ff3c4ff230581d3/numpy-2.3.5-cp311-cp311-win_arm64.whl", hash = "sha256:0cd00b7b36e35398fa2d16af7b907b65304ef8bb4817a550e06e5012929830fa", size = 10458555 }, { url = "https://files.pythonhosted.org/packages/44/37/e669fe6cbb2b96c62f6bbedc6a81c0f3b7362f6a59230b23caa673a85721/numpy-2.3.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:74ae7b798248fe62021dbf3c914245ad45d1a6b0cb4a29ecb4b31d0bfbc4cc3e", size = 16733873 }, { url = "https://files.pythonhosted.org/packages/c5/65/df0db6c097892c9380851ab9e44b52d4f7ba576b833996e0080181c0c439/numpy-2.3.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee3888d9ff7c14604052b2ca5535a30216aa0a58e948cdd3eeb8d3415f638769", size = 12259838 }, { url = "https://files.pythonhosted.org/packages/5b/e1/1ee06e70eb2136797abe847d386e7c0e830b67ad1d43f364dd04fa50d338/numpy-2.3.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:612a95a17655e213502f60cfb9bf9408efdc9eb1d5f50535cc6eb365d11b42b5", size = 5088378 }, @@ -1008,13 +903,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/41/851c4b4082402d9ea860c3626db5d5df47164a712cb23b54be028b184c1c/numpy-2.3.5-cp314-cp314t-win32.whl", hash = "sha256:93eebbcf1aafdf7e2ddd44c2923e2672e1010bddc014138b229e49725b4d6be5", size = 6479806 }, { url = "https://files.pythonhosted.org/packages/90/30/d48bde1dfd93332fa557cff1972fbc039e055a52021fbef4c2c4b1eefd17/numpy-2.3.5-cp314-cp314t-win_amd64.whl", hash = "sha256:c8a9958e88b65c3b27e22ca2a076311636850b612d6bbfb76e8d156aacde2aaf", size = 13105760 }, { url = "https://files.pythonhosted.org/packages/2d/fd/4b5eb0b3e888d86aee4d198c23acec7d214baaf17ea93c1adec94c9518b9/numpy-2.3.5-cp314-cp314t-win_arm64.whl", hash = "sha256:6203fdf9f3dc5bdaed7319ad8698e685c7a3be10819f41d32a0723e611733b42", size = 10545459 }, - { url = "https://files.pythonhosted.org/packages/c6/65/f9dea8e109371ade9c782b4e4756a82edf9d3366bca495d84d79859a0b79/numpy-2.3.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f0963b55cdd70fad460fa4c1341f12f976bb26cb66021a5580329bd498988310", size = 16910689 }, - { url = "https://files.pythonhosted.org/packages/00/4f/edb00032a8fb92ec0a679d3830368355da91a69cab6f3e9c21b64d0bb986/numpy-2.3.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f4255143f5160d0de972d28c8f9665d882b5f61309d8362fdd3e103cf7bf010c", size = 12457053 }, - { url = "https://files.pythonhosted.org/packages/16/a4/e8a53b5abd500a63836a29ebe145fc1ab1f2eefe1cfe59276020373ae0aa/numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:a4b9159734b326535f4dd01d947f919c6eefd2d9827466a696c44ced82dfbc18", size = 5285635 }, - { url = "https://files.pythonhosted.org/packages/a3/2f/37eeb9014d9c8b3e9c55bc599c68263ca44fdbc12a93e45a21d1d56df737/numpy-2.3.5-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2feae0d2c91d46e59fcd62784a3a83b3fb677fead592ce51b5a6fbb4f95965ff", size = 6801770 }, - { url = "https://files.pythonhosted.org/packages/7d/e4/68d2f474df2cb671b2b6c2986a02e520671295647dad82484cde80ca427b/numpy-2.3.5-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffac52f28a7849ad7576293c0cb7b9f08304e8f7d738a8cb8a90ec4c55a998eb", size = 14391768 }, - { url = "https://files.pythonhosted.org/packages/b8/50/94ccd8a2b141cb50651fddd4f6a48874acb3c91c8f0842b08a6afc4b0b21/numpy-2.3.5-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63c0e9e7eea69588479ebf4a8a270d5ac22763cc5854e9a7eae952a3908103f7", size = 16729263 }, - { url = "https://files.pythonhosted.org/packages/2d/ee/346fa473e666fe14c52fcdd19ec2424157290a032d4c41f98127bfb31ac7/numpy-2.3.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f16417ec91f12f814b10bafe79ef77e70113a2f5f7018640e7425ff979253425", size = 12967213 }, ] [[package]] @@ -1046,14 +934,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855 } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/35/6411db530c618e0e0005187e35aa02ce60ae4c4c4d206964a2f978217c27/pandas-3.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a727a73cbdba2f7458dc82449e2315899d5140b449015d822f515749a46cbbe0", size = 10326926 }, - { url = "https://files.pythonhosted.org/packages/c4/d3/b7da1d5d7dbdc5ef52ed7debd2b484313b832982266905315dad5a0bf0b1/pandas-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dbbd4aa20ca51e63b53bbde6a0fa4254b1aaabb74d2f542df7a7959feb1d760c", size = 9926987 }, - { url = "https://files.pythonhosted.org/packages/52/77/9b1c2d6070b5dbe239a7bc889e21bfa58720793fb902d1e070695d87c6d0/pandas-3.0.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:339dda302bd8369dedeae979cb750e484d549b563c3f54f3922cb8ff4978c5eb", size = 10757067 }, - { url = "https://files.pythonhosted.org/packages/20/17/ec40d981705654853726e7ac9aea9ddbb4a5d9cf54d8472222f4f3de06c2/pandas-3.0.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61c2fd96d72b983a9891b2598f286befd4ad262161a609c92dc1652544b46b76", size = 11258787 }, - { url = "https://files.pythonhosted.org/packages/90/e3/3f1126d43d3702ca8773871a81c9f15122a1f412342cc56284ffda5b1f70/pandas-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c934008c733b8bbea273ea308b73b3156f0181e5b72960790b09c18a2794fe1e", size = 11771616 }, - { url = "https://files.pythonhosted.org/packages/2e/cf/0f4e268e1f5062e44a6bda9f925806721cd4c95c2b808a4c82ebe914f96b/pandas-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:60a80bb4feacbef5e1447a3f82c33209c8b7e07f28d805cfd1fb951e5cb443aa", size = 12337623 }, - { url = "https://files.pythonhosted.org/packages/44/a0/97a6339859d4acb2536efb24feb6708e82f7d33b2ed7e036f2983fcced82/pandas-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:ed72cb3f45190874eb579c64fa92d9df74e98fd63e2be7f62bce5ace0ade61df", size = 9897372 }, - { url = "https://files.pythonhosted.org/packages/8f/eb/781516b808a99ddf288143cec46b342b3016c3414d137da1fdc3290d8860/pandas-3.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:f12b1a9e332c01e09510586f8ca9b108fd631fd656af82e452d7315ef6df5f9f", size = 9154922 }, { url = "https://files.pythonhosted.org/packages/f3/b0/c20bd4d6d3f736e6bd6b55794e9cd0a617b858eaad27c8f410ea05d953b7/pandas-3.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18", size = 10347921 }, { url = "https://files.pythonhosted.org/packages/35/d0/4831af68ce30cc2d03c697bea8450e3225a835ef497d0d70f31b8cdde965/pandas-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14", size = 9888127 }, { url = "https://files.pythonhosted.org/packages/61/a9/16ea9346e1fc4a96e2896242d9bc674764fb9049b0044c0132502f7a771e/pandas-3.0.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d", size = 10399577 }, @@ -1110,17 +990,6 @@ version = "12.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798 }, - { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589 }, - { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472 }, - { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887 }, - { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964 }, - { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756 }, - { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075 }, - { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955 }, - { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440 }, - { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256 }, - { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025 }, { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377 }, { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343 }, { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981 }, @@ -1182,13 +1051,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045 }, { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282 }, { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630 }, - { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068 }, - { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994 }, - { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639 }, - { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839 }, - { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505 }, - { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654 }, - { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850 }, ] [[package]] @@ -1274,15 +1136,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 }, { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, @@ -1399,16 +1252,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/0a/ca/d8ace4f98322d01abcd52d381134344bf7b431eba7ed8b42bdea5a3c2ac9/scipy-1.16.3.tar.gz", hash = "sha256:01e87659402762f43bd2fee13370553a17ada367d42e7487800bf2916535aecb", size = 30597883 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/5f/6f37d7439de1455ce9c5a556b8d1db0979f03a796c030bafdf08d35b7bf9/scipy-1.16.3-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:40be6cf99e68b6c4321e9f8782e7d5ff8265af28ef2cd56e9c9b2638fa08ad97", size = 36630881 }, - { url = "https://files.pythonhosted.org/packages/7c/89/d70e9f628749b7e4db2aa4cd89735502ff3f08f7b9b27d2e799485987cd9/scipy-1.16.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:8be1ca9170fcb6223cc7c27f4305d680ded114a1567c0bd2bfcbf947d1b17511", size = 28941012 }, - { url = "https://files.pythonhosted.org/packages/a8/a8/0e7a9a6872a923505dbdf6bb93451edcac120363131c19013044a1e7cb0c/scipy-1.16.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:bea0a62734d20d67608660f69dcda23e7f90fb4ca20974ab80b6ed40df87a005", size = 20931935 }, - { url = "https://files.pythonhosted.org/packages/bd/c7/020fb72bd79ad798e4dbe53938543ecb96b3a9ac3fe274b7189e23e27353/scipy-1.16.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:2a207a6ce9c24f1951241f4693ede2d393f59c07abc159b2cb2be980820e01fb", size = 23534466 }, - { url = "https://files.pythonhosted.org/packages/be/a0/668c4609ce6dbf2f948e167836ccaf897f95fb63fa231c87da7558a374cd/scipy-1.16.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:532fb5ad6a87e9e9cd9c959b106b73145a03f04c7d57ea3e6f6bb60b86ab0876", size = 33593618 }, - { url = "https://files.pythonhosted.org/packages/ca/6e/8942461cf2636cdae083e3eb72622a7fbbfa5cf559c7d13ab250a5dbdc01/scipy-1.16.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0151a0749efeaaab78711c78422d413c583b8cdd2011a3c1d6c794938ee9fdb2", size = 35899798 }, - { url = "https://files.pythonhosted.org/packages/79/e8/d0f33590364cdbd67f28ce79368b373889faa4ee959588beddf6daef9abe/scipy-1.16.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7180967113560cca57418a7bc719e30366b47959dd845a93206fbed693c867e", size = 36226154 }, - { url = "https://files.pythonhosted.org/packages/39/c1/1903de608c0c924a1749c590064e65810f8046e437aba6be365abc4f7557/scipy-1.16.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:deb3841c925eeddb6afc1e4e4a45e418d19ec7b87c5df177695224078e8ec733", size = 38878540 }, - { url = "https://files.pythonhosted.org/packages/f1/d0/22ec7036ba0b0a35bccb7f25ab407382ed34af0b111475eb301c16f8a2e5/scipy-1.16.3-cp311-cp311-win_amd64.whl", hash = "sha256:53c3844d527213631e886621df5695d35e4f6a75f620dca412bcd292f6b87d78", size = 38722107 }, - { url = "https://files.pythonhosted.org/packages/7b/60/8a00e5a524bb3bf8898db1650d350f50e6cffb9d7a491c561dc9826c7515/scipy-1.16.3-cp311-cp311-win_arm64.whl", hash = "sha256:9452781bd879b14b6f055b26643703551320aa8d79ae064a71df55c00286a184", size = 25506272 }, { url = "https://files.pythonhosted.org/packages/40/41/5bf55c3f386b1643812f3a5674edf74b26184378ef0f3e7c7a09a7e2ca7f/scipy-1.16.3-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:81fc5827606858cf71446a5e98715ba0e11f0dbc83d71c7409d05486592a45d6", size = 36659043 }, { url = "https://files.pythonhosted.org/packages/1e/0f/65582071948cfc45d43e9870bf7ca5f0e0684e165d7c9ef4e50d783073eb/scipy-1.16.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:c97176013d404c7346bf57874eaac5187d969293bf40497140b0a2b2b7482e07", size = 28898986 }, { url = "https://files.pythonhosted.org/packages/96/5e/36bf3f0ac298187d1ceadde9051177d6a4fe4d507e8f59067dc9dd39e650/scipy-1.16.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2b71d93c8a9936046866acebc915e2af2e292b883ed6e2cbe5c34beb094b82d9", size = 20889814 }, @@ -1512,9 +1355,6 @@ version = "6.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393 }, - { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392 }, - { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019 }, { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471 }, { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449 }, { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054 },