[numerics] fix modal scattering and Yee-grid updates

Integrate targeted complex mode bases, solve both interface incidences together, and cascade sampled taper S matrices. Add frozen-basis evaluation, explicit overlap measures, reproducible starts, shared 2D-to-3D conversion, PML controls, and lower-allocation derivatives.

Validation: 1629 tests passed, 584 skipped.
This commit is contained in:
Forgejo Actions 2026-10-18 23:07:47 -07:00
commit 153536ee91
17 changed files with 3598 additions and 2312 deletions

View file

@ -11,7 +11,7 @@ import scipy.sparse.linalg as spalg
from scipy.optimize import linear_sum_assignment from scipy.optimize import linear_sum_assignment
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True, eq=False)
class TargetedEigensolution: class TargetedEigensolution:
"""Paired right and transpose-left eigenvectors near a spectral target. """Paired right and transpose-left eigenvectors near a spectral target.
@ -24,8 +24,11 @@ class TargetedEigensolution:
""" """
eigenvalues: NDArray[numpy.complex128] eigenvalues: NDArray[numpy.complex128]
right_eigenvectors: NDArray[numpy.complex128] right_eigenvectors: NDArray[numpy.complex128] = dataclasses.field(repr=False)
left_eigenvectors: NDArray[numpy.complex128] left_eigenvectors: NDArray[numpy.complex128] = dataclasses.field(repr=False)
type TargetedEigensolver = Callable[..., TargetedEigensolution]
def scipy_eigs_near( def scipy_eigs_near(
@ -144,6 +147,8 @@ def power_iteration(
operator: sparse.spmatrix, operator: sparse.spmatrix,
guess_vector: NDArray[numpy.complex128] | None = None, guess_vector: NDArray[numpy.complex128] | None = None,
iterations: int = 20, iterations: int = 20,
*,
rng: numpy.random.Generator | int | None = None,
) -> tuple[complex, NDArray[numpy.complex128]]: ) -> tuple[complex, NDArray[numpy.complex128]]:
""" """
Use power iteration to estimate the dominant eigenvector of a matrix. Use power iteration to estimate the dominant eigenvector of a matrix.
@ -152,15 +157,18 @@ def power_iteration(
operator: Matrix to analyze. operator: Matrix to analyze.
guess_vector: Starting point for the eigenvector. Default is a randomly chosen vector. guess_vector: Starting point for the eigenvector. Default is a randomly chosen vector.
iterations: Number of iterations to perform. Default 20. iterations: Number of iterations to perform. Default 20.
rng: Generator or seed used only when ``guess_vector`` is omitted.
Returns: Returns:
(Largest-magnitude eigenvalue, Corresponding eigenvector estimate) (Largest-magnitude eigenvalue, Corresponding eigenvector estimate)
""" """
rng = numpy.random.default_rng()
if guess_vector is None: if guess_vector is None:
rng = numpy.random.default_rng(rng)
v = rng.random(operator.shape[0]) + 1j * rng.random(operator.shape[0]) v = rng.random(operator.shape[0]) + 1j * rng.random(operator.shape[0])
else: 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): for _ in range(iterations):
v = operator @ v v = operator @ v
@ -234,6 +242,9 @@ def signed_eigensolve(
operator: sparse.spmatrix | spalg.LinearOperator, operator: sparse.spmatrix | spalg.LinearOperator,
how_many: int, how_many: int,
negative: bool = False, negative: bool = False,
*,
rng: numpy.random.Generator | int | None = None,
v0: ArrayLike | None = None,
) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]: ) -> tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]:
""" """
Find the largest-magnitude positive-only (or negative-only) eigenvalues and 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. how_many: How many eigenvalues to find.
negative: Whether to find negative-only eigenvalues. negative: Whether to find negative-only eigenvalues.
Default False (positive only). Default False (positive only).
rng: Optional generator or seed for reproducible initialization.
v0: Finite nonzero starting vector, taking precedence over ``rng``.
Returns: Returns:
(sorted list of eigenvalues, 2D ndarray of corresponding eigenvectors) (sorted list of eigenvalues, 2D ndarray of corresponding eigenvectors)
`eigenvectors[:, k]` corresponds to the k-th eigenvalue `eigenvectors[:, k]` corresponds to the k-th eigenvalue
""" """
# Use power iteration to estimate the dominant eigenvector # 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 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, shifted_operator = operator + spalg.LinearOperator(shape=operator.shape,
matvec=lambda v: shift * v) 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 eigenvalues = shifted_eigenvalues - shift
k = eigenvalues.argsort() k = eigenvalues.argsort()

View file

@ -96,7 +96,6 @@ from . import (
functional as functional, functional as functional,
scpml as scpml, scpml as scpml,
waveguide_2d as waveguide_2d, waveguide_2d as waveguide_2d,
waveguide_2d_complex as waveguide_2d_complex,
waveguide_3d as waveguide_3d, waveguide_3d as waveguide_3d,
) )
# from . import farfield, bloch TODO # from . import farfield, bloch TODO

View file

@ -9,13 +9,13 @@ expected to supply compatible `(E, H)` modal field pairs from
The returned matrices follow the usual port ordering: The returned matrices follow the usual port ordering:
- `get_tr(...)` returns `(T, R)` for left-incident modes. - `get_tr(...)` returns `(T, R)` for left-incident modes.
- `get_abcd(...)` returns the 2-port block transfer matrix built from the two - `get_abcd(...)` returns the 2-port block transfer matrix built from the
directional `T/R` solves. interface continuity solve.
- `get_s(...)` returns the full block scattering matrix - `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 / - `get_taper_abcd(...)` and `get_taper_s(...)` build the same transfer /
scattering objects for sampled continuously varying sections using local-mode scattering objects for sampled tapers using midpoint interfaces and stable
CMT. Redheffer cascading.
This module is intentionally a thin library layer rather than an integrated This module is intentionally a thin library layer rather than an integrated
simulation suite. It provides the overlap algebra that downstream users can 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 collections.abc import Sequence
from typing import Literal
import dataclasses import dataclasses
import operator import operator
import numpy import numpy
@ -31,14 +32,13 @@ from scipy import linalg
from scipy import sparse from scipy import sparse
from ..fdmath import dx_lists2_t, vcfdfield2 from ..fdmath import dx_lists2_t, vcfdfield2
from .waveguide_2d import inner_product from .waveguide_2d import ModeBasis, inner_product, lorentz_overlap
from .waveguide_2d_complex import ComplexModeBasis
type wavenumber_seq = Sequence[complex] | NDArray[numpy.complexfloating] | NDArray[numpy.floating] type wavenumber_seq = Sequence[complex] | NDArray[numpy.complexfloating] | NDArray[numpy.floating]
type mode_seq = Sequence[Sequence[vcfdfield2]] type mode_seq = Sequence[Sequence[vcfdfield2]]
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True, eq=False)
class ModeSection: class ModeSection:
""" """
Local modal basis at one longitudinal sample of a continuously varying guide. Local modal basis at one longitudinal sample of a continuously varying guide.
@ -63,12 +63,12 @@ class ModeSection:
""" """
z: float z: float
modes: mode_seq modes: mode_seq = dataclasses.field(repr=False)
wavenumbers: wavenumber_seq 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 backward_wavenumbers: wavenumber_seq | None = None
dual_modes: mode_seq | None = None dual_modes: mode_seq | None = dataclasses.field(default=None, repr=False)
dual_backward_modes: mode_seq | None = None dual_backward_modes: mode_seq | None = dataclasses.field(default=None, repr=False)
wavenumber_matrix: NDArray[numpy.complex128] | None = None wavenumber_matrix: NDArray[numpy.complex128] | None = None
backward_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]]]: ) -> list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]:
"""Copy a modal sequence into complex NumPy array views.""" """Copy a modal sequence into complex NumPy array views."""
return [ 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 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( def _phase_align_modes(
previous: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]], previous: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]],
current: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]], current: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]],
dxes: dx_lists2_t, dxes: dx_lists2_t,
previous_dual: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]] | None = None, 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]]: ) -> tuple[list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]], NDArray[numpy.complex128]]:
"""Align current modal phases and return their diagonal basis transform.""" """Align current modal phases and return their diagonal basis transform."""
aligned = [] aligned = []
scales = [] scales = []
test_modes = previous if previous_dual is None else previous_dual 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)): 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) overlap = lorentz_overlap(test_mode, current_mode, dxes, measure=measure)
self_overlap = _lorentz_overlap(test_mode, previous_mode, dxes) self_overlap = lorentz_overlap(test_mode, previous_mode, dxes, measure=measure)
if overlap == 0: if overlap == 0:
raise ValueError(f'cannot phase-track mode {index}: adjacent section overlap is zero') raise ValueError(f'cannot phase-track mode {index}: adjacent section overlap is zero')
if self_overlap == 0: if self_overlap == 0:
@ -314,12 +288,15 @@ def _section_branches(
def _validate_taper_sections( def _validate_taper_sections(
sections: Sequence[ModeSection], sections: Sequence[ModeSection],
dxes: dx_lists2_t, dxes: dx_lists2_t,
*,
measure: Literal['physical', 'stretched'] = 'stretched',
) -> tuple[ ) -> tuple[
NDArray[numpy.float64], NDArray[numpy.float64],
list[list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]], list[list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]],
list[list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]], list[list[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]]],
list[NDArray[numpy.complex128]], list[NDArray[numpy.complex128]],
int, int,
list[NDArray[numpy.complex128]],
]: ]:
"""Validate, phase-align, and assemble all taper branch data.""" """Validate, phase-align, and assemble all taper branch data."""
if len(sections) < 2: if len(sections) < 2:
@ -351,10 +328,12 @@ def _validate_taper_sections(
if not (numpy.diff(z_array) > 0).all(): if not (numpy.diff(z_array) > 0).all():
raise ValueError('taper section z coordinates must be strictly increasing') 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)): for index in range(1, len(branch_modes)):
branch_modes[index], transform = _phase_align_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) inverse = numpy.linalg.inv(transform)
branch_dual_modes[index] = [ branch_dual_modes[index] = [
(e_field * inverse[row, row], h_field * inverse[row, row]) (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 branch_wavenumbers[index] = inverse @ branch_wavenumbers[index] @ transform
assert expected_count is not None 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( def _interface_s(
left_modes: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]], left: mode_seq,
left_dual_modes: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]], right: mode_seq,
right_modes: Sequence[tuple[NDArray[numpy.complex128], NDArray[numpy.complex128]]], left_dual: mode_seq,
left_wavenumbers: NDArray[numpy.complex128], right_dual: mode_seq,
right_wavenumbers: NDArray[numpy.complex128],
dz: float,
dxes: dx_lists2_t, dxes: dx_lists2_t,
*,
formulation: Literal['mixed', 'galerkin'] = 'mixed',
measure: Literal['physical', 'stretched'] = 'stretched',
) -> NDArray[numpy.complex128]: ) -> NDArray[numpy.complex128]:
"""Build the local coupled-mode generator for one sampled interval.""" """Solve projected tangential continuity for both incidences together.
mode_count = len(left_modes)
gram = numpy.zeros((mode_count, mode_count), dtype=complex)
derivative_overlap = numpy.zeros((mode_count, mode_count), dtype=complex)
for row, left_row_mode in enumerate(left_dual_modes): Branches are ordered [forward, backward]. Mixed testing uses left electric
for col, left_col_mode in enumerate(left_modes): duals on H continuity and right magnetic duals on E continuity. In contrast,
gram[row, col] = _lorentz_overlap(left_row_mode, left_col_mode, dxes) Galerkin tests both equations against the left dual basis.
for col, (left_col_mode, right_col_mode) in enumerate(zip(left_modes, right_modes, strict=True)): """
derivative = ( nl, nr = len(left) // 2, len(right) // 2
(right_col_mode[0] - left_col_mode[0]) / dz, if formulation not in ('mixed', 'galerkin'):
(right_col_mode[1] - left_col_mode[1]) / dz, 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]
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 _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]:
"""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,
) )
derivative_overlap[row, col] = _lorentz_derivative_overlap(left_row_mode, derivative, dxes) return _postprocess_s(ss, force_nogain, force_reciprocal)
coupling = numpy.linalg.pinv(gram) @ derivative_overlap
propagation = -1j * 0.5 * (left_wavenumbers + right_wavenumbers)
return propagation - coupling
def _abcd_to_s(
abcd: NDArray[numpy.complex128],
n_modes: int,
) -> 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]])
def get_tr( def get_tr(
@ -419,61 +542,23 @@ def get_tr(
ehRs: mode_seq, ehRs: mode_seq,
wavenumbers_R: wavenumber_seq, wavenumbers_R: wavenumber_seq,
dxes: dx_lists2_t, dxes: dx_lists2_t,
*,
dual_ehLs: mode_seq | None = None, 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]]: ) -> 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. ss = get_s(ehLs, wavenumbers_L, ehRs, wavenumbers_R, dxes,
dual_ehLs=dual_ehLs, dual_ehRs=dual_ehRs,
Args: formulation=formulation, measure=measure)
ehLs: Left-port modes as `(E, H)` field pairs. n = len(ehLs)
wavenumbers_L: Propagation constants for `ehLs`. return ss[n:, :n], ss[:n, :n]
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
def get_abcd( def get_abcd(
@ -481,175 +566,201 @@ def get_abcd(
wavenumbers_L: wavenumber_seq, wavenumbers_L: wavenumber_seq,
ehRs: mode_seq, ehRs: mode_seq,
wavenumbers_R: wavenumber_seq, wavenumbers_R: wavenumber_seq,
dxes: dx_lists2_t,
*,
dual_ehLs: mode_seq | None = None, dual_ehLs: mode_seq | None = None,
dual_ehRs: mode_seq | None = None, dual_ehRs: mode_seq | None = None,
**kwargs, formulation: Literal['mixed', 'galerkin'] = 'mixed',
measure: Literal['physical', 'stretched'] = 'stretched',
) -> sparse.sparray: ) -> 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) z_coords: NDArray[numpy.float64]
t21i = numpy.linalg.pinv(t21) interfaces: NDArray[numpy.complex128] = dataclasses.field(repr=False)
A = t12 - r21 @ t21i @ r12 wavenumber_matrices: NDArray[numpy.complex128] = dataclasses.field(repr=False)
B = r21 @ t21i backward_wavenumber_matrices: NDArray[numpy.complex128] = dataclasses.field(repr=False)
C = -t21i @ r12 basis_transforms: NDArray[numpy.complex128] = dataclasses.field(repr=False)
D = t21i reciprocal_branches: tuple[bool, ...]
return sparse.block_array(
[ def __post_init__(self) -> None:
[sparse.csr_array(A), sparse.csr_array(B)], for field in dataclasses.fields(self):
[sparse.csr_array(C), sparse.csr_array(D)], if field.name == 'reciprocal_branches':
], object.__setattr__(self, field.name, tuple(self.reciprocal_branches))
format='csr', 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( def prepare_taper(
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(
sections: Sequence[ModeSection], sections: Sequence[ModeSection],
dxes: dx_lists2_t, dxes: dx_lists2_t,
*, *,
rtol: float = 1e-7, formulation: Literal['mixed', 'galerkin'] = 'mixed',
atol: float = 1e-9, measure: Literal['physical', 'stretched'] = 'stretched',
max_step: float | None = None, ) -> PreparedTaper:
) -> sparse.sparray: """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 = [
The taper is represented by local modal bases sampled at increasing `z` _interface_s(modes[i], modes[i + 1], duals[i], duals[i + 1], dxes,
coordinates. Adjacent modal phases are tracked with the same non-conjugated formulation=formulation, measure=measure)
Lorentz/Poynting overlap used by the abrupt-interface helpers, then each for i in range(len(sections) - 1)
interval is propagated with a finite-difference local-mode CMT generator. ]
If a `ModeSection` supplies dual / adjoint modes, those modes are used for return PreparedTaper(
the CMT projection. This supports leaky or radiative mode sets whose natural z, numpy.asarray(interfaces), numpy.asarray([b[:n, :n] for b in beta]),
projection basis is biorthogonal rather than self-projected. numpy.asarray([b[n:, n:] for b in beta]), numpy.asarray(transforms),
tuple(section.backward_modes is None for section in sections),
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.
Returns:
Sparse block transfer matrix ordered as `[forward, backward]`.
"""
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_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)
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)
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
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.
"""
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
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)])
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( def get_taper_s(
sections: Sequence[ModeSection], sections: Sequence[ModeSection],
dxes: dx_lists2_t, dxes: dx_lists2_t,
*, *,
formulation: Literal['mixed', 'galerkin'] = 'mixed',
measure: Literal['physical', 'stretched'] = 'stretched',
force_nogain: bool = False, force_nogain: bool = False,
force_reciprocal: bool = False, force_reciprocal: bool = False,
**kwargs,
) -> NDArray[numpy.complex128]: ) -> 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) return sparse.csr_array(s_to_abcd(get_taper_s(sections, dxes, formulation=formulation, measure=measure)))
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
def mode_sections_from_bases( def mode_sections_from_bases(
z_coords: Sequence[float], z_coords: Sequence[float],
bases: Sequence[ComplexModeBasis], bases: Sequence[ModeBasis],
*, *,
mode_indices: Sequence[int] | None = None, mode_indices: Sequence[int] | None = None,
backward_bases: Sequence[ComplexModeBasis] | None = None, backward_bases: Sequence[ModeBasis] | None = None,
coupling_rtol: float = 1e-10, coupling_rtol: float = 1e-10,
) -> tuple[ModeSection, ...]: ) -> tuple[ModeSection, ...]:
"""Convert tracked complex-mode bases into EME taper sections. """Convert tracked complex-mode bases into EME taper sections.
@ -728,8 +839,14 @@ def mode_sections_from_bases(
if numpy.any(indices >= len(basis)): if numpy.any(indices >= len(basis)):
raise ValueError(f'mode_indices are out of range for bases[{section_index}]') 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}]') validate_invariant_subset(basis.wavenumber_matrix, len(basis), f'bases[{section_index}]')
modes = [basis.modes[index] for index in indices] modes = [
dual_modes = [basis.dual_modes[index] for index in indices] (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] wavenumbers = basis.wavenumbers[indices]
wavenumber_matrix = basis.wavenumber_matrix[numpy.ix_(indices, indices)] wavenumber_matrix = basis.wavenumber_matrix[numpy.ix_(indices, indices)]
if backward_bases is None: if backward_bases is None:
@ -754,10 +871,16 @@ def mode_sections_from_bases(
z=float(z_coord), z=float(z_coord),
modes=modes, modes=modes,
wavenumbers=wavenumbers, 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], backward_wavenumbers=backward_basis.wavenumbers[indices],
dual_modes=dual_modes, 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, wavenumber_matrix=wavenumber_matrix,
backward_wavenumber_matrix=backward_basis.wavenumber_matrix[numpy.ix_(indices, indices)], backward_wavenumber_matrix=backward_basis.wavenumber_matrix[numpy.ix_(indices, indices)],
)) ))

View file

@ -38,132 +38,146 @@ def prepare_s_function(
return s_factor 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( def uniform_grid_scpml(
shape: Sequence[int], shape: Sequence[int],
thicknesses: Sequence[int], thicknesses: Sequence[int],
omega: float, omega: float,
epsilon_effective: float = 1.0, epsilon_effective: float = 1.0,
s_function: s_function_t | None = None, s_function: s_function_t | None = None,
) -> list[list[NDArray[numpy.float64]]]: *,
""" kappa_function: s_function_t | None = None,
Create dx arrays for a uniform grid with a cell width of 1 and a pml. 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: ``max_imag_ratio`` optionally bounds abs(Im(s))/Re(s) after frequency
shape: Shape of the grid, including the PMLs (which are 2*thicknesses thick) scaling. A value of 1 is a useful mode-solver starting point, not a proof
thicknesses: `[th_x, th_y, th_z]` against spectral artifacts. Converge domain size, thickness, and strength;
Thickness of the PML in each direction. reducing absorption can increase boundary reflections. The general
Both polarities are added. scattering profile remains uncapped by default.
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`.
""" """
_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: if s_function is None:
s_function = prepare_s_function() s_function = prepare_s_function()
result = [[numpy.ones(n) for n in shape] for _ in range(2)]
shape = tuple(shape) correction = numpy.sqrt(epsilon_effective) * numpy.real(omega)
thicknesses = tuple(thicknesses) for axis, (n, thickness) in enumerate(zip(shape, thicknesses, strict=True)):
if not thickness:
# Normalized distance to nearest boundary continue
def ll(u: NDArray[numpy.float64], n: int, t: int) -> NDArray[numpy.float64]: for family, offset in enumerate((0.0, 0.5)):
return ((t - u).clip(0) + (u - (n - t)).clip(0)) / t position = numpy.arange(n, dtype=float) + offset
depth = ((thickness - position).clip(0) + (position - (n - thickness)).clip(0)) / thickness
dx_a = [numpy.array(numpy.inf)] * 3 factors = _stretch_factors(depth, correction, s_function, kappa_function, max_imag_ratio)
dx_b = [numpy.array(numpy.inf)] * 3 result[family][axis] = numpy.where(depth > 0, factors, 1.0)
return result
# 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]
def stretch_with_scpml( def stretch_with_scpml(
dxes: list[list[NDArray[numpy.float64]]], dxes: list[list[NDArray]],
axis: int, axis: int,
polarity: int, polarity: int,
omega: float, omega: float,
epsilon_effective: float = 1.0, epsilon_effective: float = 1.0,
thickness: int = 10, thickness: int = 10,
s_function: s_function_t | None = None, s_function: s_function_t | None = None,
) -> list[list[NDArray[numpy.float64]]]: *,
""" kappa_function: s_function_t | None = None,
Stretch dxes to contain a stretched-coordinate PML (SCPML) in one direction along one axis. max_imag_ratio: float | None = None,
copy: bool = True,
) -> list[list[NDArray]]:
"""Add SCPML to one face, returning independent widths by default.
Args: Args:
dxes: Grid parameters `[dx_e, dx_h]` as described in `meanas.fdmath.types` dxes: Yee widths [dx_e, dx_h]; two or three spatial axes are supported.
axis: axis to stretch (0=x, 1=y, 2=z) axis: Axis normal to the absorbing face.
polarity: direction to stretch (-1 for -ve, +1 for +ve) polarity: -1 selects the low-index face; +1 selects the high-index face.
omega: Angular frequency for the simulation omega: Positive real angular frequency.
epsilon_effective: Effective epsilon of the PML. Match this to the material at the epsilon_effective: Positive effective permittivity at the boundary.
edge of your grid. Default 1. thickness: Number of cells on this face; zero is a no-op.
thickness: number of cells to use for pml (default 10) s_function: Imaginary grading at normalized depths, from prepare_s_function.
s_function: Created by `prepare_s_function(...)`, allowing customization kappa_function: Optional dimensionless real stretch profile, >= 1.
of pml parameters. Default uses `prepare_s_function()` with no parameters. 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: Coordinates and physical PML thickness are obtained from the real input
Complex cell widths (dx_lists_mut) as discussed in `meanas.fdmath.types`. electric widths. Multiple nonoverlapping faces can be added successively.
Multiple calls to this function may be necessary if multiple absorpbing boundaries are needed. 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: if s_function is None:
s_function = prepare_s_function() s_function = prepare_s_function()
da, db = (numpy.array(family[axis], dtype=complex, copy=True) for family in widths)
dx_ai = dxes[0][axis].astype(complex) if not numpy.isfinite(da).all() or not numpy.isfinite(db).all() or (da.real <= 0).any() or (db.real <= 0).any():
dx_bi = dxes[1][axis].astype(complex) raise ValueError('cell widths must be finite with positive real parts')
position = numpy.concatenate(([0.0], da.real.cumsum()))
if thickness == 0: electric = (position[:-1] + position[1:]) / 2
dxes[0][axis] = dx_ai magnetic = position[:-1]
dxes[1][axis] = dx_bi if polarity == -1:
return dxes selected = slice(0, thickness)
bound = position[thickness]
pos = numpy.hstack((0, dx_ai.cumsum())) length = bound - position[0]
pos_a = (pos[:-1] + pos[1:]) / 2 depths = [(bound - coordinates[selected]) / length for coordinates in (electric, magnetic)]
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)
else: else:
# back pml selected = slice(-thickness, None)
bound = pos[-thickness - 1] bound = position[-thickness - 1]
d = pos[-1] - bound length = position[-1] - bound
depths = [(coordinates[selected] - bound) / length for coordinates in (electric, magnetic)]
def l_d(x: NDArray[numpy.float64]) -> NDArray[numpy.float64]: correction = length * numpy.sqrt(epsilon_effective) * numpy.real(omega)
return (x - bound) / (pos[-1] - bound) 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)
slc = slice(-thickness, None) widths[0][axis], widths[1][axis] = da, db
return widths
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

File diff suppressed because it is too large Load diff

View file

@ -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)

View file

@ -21,7 +21,7 @@ The intended workflow is:
That same convention controls which side of the selected slice is used for the That same convention controls which side of the selected slice is used for the
overlap window and how the expanded field is phased. 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 warnings
import operator import operator
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
@ -78,40 +78,86 @@ def solve_mode(
The returned fields are normalized through the `waveguide_2d` The returned fields are normalized through the `waveguide_2d`
normalization convention before being expanded back to 3D. 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: if mu is None:
mu = numpy.ones_like(epsilon) mu = numpy.ones_like(epsilon)
elif mu.shape != epsilon.shape:
slices = tuple(slices) raise ValueError('mu must match epsilon')
#
# Solve the 2D problem in the specified plane
#
# Define rotation to set z as propagation direction
order = numpy.roll(range(3), 2 - axis) order = numpy.roll(range(3), 2 - axis)
reverse_order = numpy.roll(range(3), axis - 2) reverse = numpy.roll(range(3), axis - 2)
dx_prop = 0.5 * sum(dx[axis][slices[axis]].sum() for dx in dxes)
# Find dx in propagation direction if not numpy.isfinite(dx_prop) or dx_prop == 0:
dxab_forward = numpy.array([dx[order[2]][slices[order[2]]] for dx in dxes]) raise ValueError('propagation cell width must be finite and nonzero')
dx_prop = 0.5 * dxab_forward.sum() args: dict[str, Any] = {
# Reduce to 2D and solve the 2D problem
args_2d: dict[str, Any] = {
'omega': omega, 'omega': omega,
'dxes': [[dx[i][slices[i]] for i in order[:2]] for dx in dxes], '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]), 'epsilon': vec([epsilon[i][slices].transpose(order) for i in order]),
'mu': vec([mu[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. # Correct wavenumber to account for numerical dispersion.
wavenumber = 2 / dx_prop * numpy.arcsin(wavenumber_2d * dx_prop / 2) wavenumber = 2 / dx_prop * numpy.arcsin(wavenumber_2d * dx_prop / 2)
shape = [d.size for d in args_2d['dxes'][0]] 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) e = unvec(ve, shape)
h = unvec(vh, shape) h = unvec(vh, shape)
@ -163,6 +209,8 @@ def compute_source(
polarity: Propagation direction (+1 for +ve, -1 for -ve) polarity: Propagation direction (+1 for +ve, -1 for -ve)
slices: `epsilon[tuple(slices)]` is used to select the portion of the grid to use slices: `epsilon[tuple(slices)]` is used to select the portion of the grid to use
as the waveguide cross-section. `slices[axis]` should select only one item. as the waveguide cross-section. `slices[axis]` 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) mu: Magnetic permeability (default 1 everywhere)
Returns: Returns:
@ -587,7 +635,7 @@ def get_s_fdfd(
""" """
Extract a two-port guided-mode scattering matrix from local 3D FDFD solves. 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 reference planes at the supplied monitor slices. Each column is normalized
by the incident modal amplitude measured on the corresponding input monitor. by the incident modal amplitude measured on the corresponding input monitor.
""" """

View file

@ -13,52 +13,61 @@ from numpy import floating, complexfloating
from .types import fdfield, fdfield_updater_t 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( def deriv_forward(
dx_e: Sequence[NDArray[floating | complexfloating]] | None = None, dx_e: Sequence[NDArray[floating | complexfloating]] | None = None,
) -> tuple[fdfield_updater_t, fdfield_updater_t, fdfield_updater_t]: ) -> tuple[fdfield_updater_t, fdfield_updater_t, fdfield_updater_t]:
""" """Forward periodic derivatives divided by electric Yee widths.
Utility operators for taking discretized derivatives (backward variant).
Args: Each callable accepts ``derivative(field, *, out=None)``. An output buffer
dx_e: Lists of cell sizes for all axes must match the field shape and must not alias the field or width arrays.
`[[dx_0, dx_1, ...], [dy_0, dy_1, ...], ...]`. Slice-based subtraction avoids a full-size roll temporary.
Returns:
List of functions for taking forward derivatives along each axis.
""" """
if dx_e is not None: return tuple(_derivative(axis, True, None if dx_e is None else dx_e[axis]) for axis in range(3))
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
def deriv_back( def deriv_back(
dx_h: Sequence[NDArray[floating | complexfloating]] | None = None, dx_h: Sequence[NDArray[floating | complexfloating]] | None = None,
) -> tuple[fdfield_updater_t, fdfield_updater_t, fdfield_updater_t]: ) -> tuple[fdfield_updater_t, fdfield_updater_t, fdfield_updater_t]:
""" """Backward periodic derivatives divided by magnetic Yee widths.
Utility operators for taking discretized derivatives (forward variant).
Args: Callables accept the same non-aliasing ``out`` buffer as deriv_forward.
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.
""" """
if dx_h is not None: return tuple(_derivative(axis, False, None if dx_h is None else dx_h[axis]) for axis in range(3))
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
TT = TypeVar('TT', bound='NDArray[floating | complexfloating]') TT = TypeVar('TT', bound='NDArray[floating | complexfloating]')

View file

@ -41,6 +41,7 @@ def cpml_params(
m: float = 3.5, m: float = 3.5,
ma: float = 1, ma: float = 1,
cfs_alpha: float = 0, cfs_alpha: float = 0,
dx: float = 1.0,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Construct the parameter block for one CPML face. Construct the parameter block for one CPML face.
@ -57,6 +58,9 @@ def cpml_params(
m: Polynomial grading exponent for `sigma` and `kappa`. m: Polynomial grading exponent for `sigma` and `kappa`.
ma: Polynomial grading exponent for the complex-frequency shift `alpha`. ma: Polynomial grading exponent for the complex-frequency shift `alpha`.
cfs_alpha: Maximum complex-frequency shift parameter. 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: Returns:
Dictionary with: Dictionary with:
@ -78,7 +82,10 @@ def cpml_params(
if epsilon_eff <= 0: if epsilon_eff <= 0:
raise ValueError('epsilon_eff must be positive') 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) kappa_max = numpy.sqrt(epsilon_eff * mu_eff)
alpha_max = cfs_alpha alpha_max = cfs_alpha
@ -152,7 +159,7 @@ def updates_with_cpml(
The closures retain the CPML auxiliary state internally. 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]) Dbx, Dby, Dbz = deriv_back(dxes[1])
psi_E: list[list[tuple[Any, Any]]] = [[(None, None) for _ in range(2)] for _ in range(3)] psi_E: list[list[tuple[Any, Any]]] = [[(None, None) for _ in range(2)] for _ in range(3)]

View file

@ -2,7 +2,6 @@ from typing import cast
import numpy import numpy
import pytest import pytest
from scipy import sparse
from ..fdmath import vec from ..fdmath import vec
from ..fdfd import eme, waveguide_2d, waveguide_3d, waveguide_cyl 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 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]: 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]) 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() left_modes, right_modes = _mode_sets()
transmission, reflection = eme.get_tr( 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 reflection.shape == (2, 2)
assert numpy.isfinite(transmission).all() assert numpy.isfinite(transmission).all()
assert numpy.isfinite(reflection).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: 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_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() left_modes, right_modes = _mode_sets()
t12, r12 = eme.get_tr(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, dxes=DXES) ss = eme.get_s(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, DXES)
t21, r21 = eme.get_tr(right_modes, WAVENUMBERS_R, left_modes, WAVENUMBERS_L, dxes=DXES) transfer = eme.get_abcd(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, DXES).toarray()
t21_inv = numpy.linalg.pinv(t21) incoming = numpy.array([1.0, .3j, -.4j, .2])
outgoing = ss @ incoming
expected = numpy.block([ state_left = numpy.concatenate((incoming[:2], outgoing[:2]))
[t12 - r21 @ t21_inv @ r12, r21 @ t21_inv], state_right = numpy.concatenate((outgoing[2:], incoming[2:]))
[-t21_inv @ r12, t21_inv], assert_close(transfer @ state_left, state_right, atol=1e-11)
])
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)
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() left_modes, right_modes = _mode_sets()
t12, r12 = eme.get_tr(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, dxes=DXES) transmission, reflection = eme.get_tr(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, DXES)
t21, r21 = eme.get_tr(right_modes, WAVENUMBERS_R, left_modes, WAVENUMBERS_L, dxes=DXES) ss = eme.get_s(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, DXES)
expected = numpy.block([[r12, t12], [t21, r21]])
ss = eme.get_s(left_modes, WAVENUMBERS_L, right_modes, WAVENUMBERS_R, dxes=DXES)
assert ss.shape == (4, 4) assert ss.shape == (4, 4)
assert numpy.isfinite(ss).all() assert_close(ss[:2, :2], reflection, atol=1e-11)
assert_close(ss, expected) assert_close(ss[2:, :2], transmission, atol=1e-11)
def test_get_s_force_nogain_caps_singular_values(monkeypatch) -> None: 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() modes, wavenumbers = _dummy_modes()
plain_s = eme.get_s(modes, wavenumbers, modes, wavenumbers, DXES)
plain_s = eme.get_s(modes, wavenumbers, modes, wavenumbers) clipped_s = eme.get_s(modes, wavenumbers, modes, wavenumbers, DXES, force_nogain=True)
clipped_s = eme.get_s(modes, wavenumbers, modes, wavenumbers, 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])
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()
def test_get_s_force_reciprocal_symmetrizes_output(monkeypatch) -> None: def test_get_s_force_reciprocal_symmetrizes_output(monkeypatch) -> None:
left = numpy.array([1.0, 0.5]) raw = numpy.arange(16, dtype=float).reshape(4, 4)
right = numpy.array([0.9, 0.4]) monkeypatch.setattr(eme, '_interface_s', lambda *args, **kwargs: raw.copy())
modes, _wavenumbers = _dummy_modes() modes, wavenumbers = _dummy_modes()
ss = eme.get_s(modes, wavenumbers, modes, wavenumbers, DXES, force_reciprocal=True)
monkeypatch.setattr(eme, 'get_tr', _nonsymmetric_tr(left)) assert_close(ss, .5 * (raw + raw.T))
ss = eme.get_s(modes, left, modes, right, force_reciprocal=True)
assert_close(ss, ss.T)
def test_get_s_force_nogain_and_reciprocal_returns_finite_output(monkeypatch) -> None: 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() modes, wavenumbers = _dummy_modes()
ss = eme.get_s(modes, wavenumbers, modes, wavenumbers, force_nogain=True, force_reciprocal=True) ss = eme.get_s(modes, wavenumbers, modes, wavenumbers, DXES, force_nogain=True, force_reciprocal=True)
assert_close(ss, ss.T, atol=1e-14)
assert ss.shape == (4, 4)
assert numpy.isfinite(ss).all()
assert_close(ss, ss.T)
assert (numpy.linalg.svd(ss, compute_uv=False) <= 1.0 + 1e-12).all() 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 t12 = aa - bb * cc / dd
return numpy.array( return numpy.array(
[ [
[r12, t12], [r12, t21],
[t21, r21], [t12, r21],
], ],
dtype=complex, 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)) _mode_1, beta_1 = _build_uniform_mode(float(n1))
quarter_wave_length = numpy.pi / (2 * numpy.real(beta_1)) 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) ss = _abcd_to_s(stack_abcd)
assert ss.shape == (2, 2) 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) interface_12 = _interface_abcd(n1, n2)
_mode_1, beta_1 = _build_uniform_mode(float(n1)) _mode_1, beta_1 = _build_uniform_mode(float(n1))
quarter_wave_length = numpy.pi / (2 * numpy.real(beta_1)) 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_reflection = abs(abrupt[0, 0])
abrupt_transmission = abs(abrupt[1, 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) _mode_1, beta_1 = _build_uniform_mode(n1)
half_wave_length = numpy.pi / numpy.real(beta_1) 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) ss = _abcd_to_s(stack_abcd)
assert ss.shape == (2, 2) 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) stack_abcd = numpy.eye(2, dtype=complex)
for _ in range(periods): for _ in range(periods):
stack_abcd = stack_abcd @ interface_lh @ _propagation_abcd(beta_h, quarter_wave_high) stack_abcd = _propagation_abcd(beta_l, quarter_wave_low) @ interface_hl @ _propagation_abcd(beta_h, quarter_wave_high) @ interface_lh @ stack_abcd
stack_abcd = stack_abcd @ interface_hl @ _propagation_abcd(beta_l, quarter_wave_low)
ss = _abcd_to_s(stack_abcd) ss = _abcd_to_s(stack_abcd)
expected = _expected_bragg_reflector_s(n_low, n_high, periods) expected = _expected_bragg_reflector_s(n_low, n_high, periods)

View file

@ -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: def test_stretch_with_scpml_only_modifies_requested_front_edge() -> None:
s_function = scpml.prepare_s_function(ln_R=-12.0, m=3.0) 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)] 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[0][0][2:], 1.0)
assert_close(stretched[1][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: def test_stretch_with_scpml_only_modifies_requested_back_edge() -> None:
s_function = scpml.prepare_s_function(ln_R=-12.0, m=3.0) 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)] 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[0][0][:4], 1.0)
assert_close(stretched[1][0][:4], 1.0) assert_close(stretched[1][0][:4], 1.0)

View file

@ -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)

View file

@ -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))

File diff suppressed because it is too large Load diff

View file

@ -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_rect.imag) < 1e-10
assert abs(overlap_trap.imag) < 1e-10 assert abs(overlap_trap.imag) < 1e-10
assert abs(overlap_rect - overlap_trap) > 0.1 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,
)

View file

@ -33,7 +33,7 @@ classifiers = [
"License :: OSI Approved :: GNU Affero General Public License v3", "License :: OSI Approved :: GNU Affero General Public License v3",
"Topic :: Scientific/Engineering :: Physics", "Topic :: Scientific/Engineering :: Physics",
] ]
requires-python = ">=3.11" requires-python = ">=3.12"
include = [ include = [
"LICENSE.md" "LICENSE.md"
] ]

162
uv.lock generated
View file

@ -1,5 +1,5 @@
version = 1 version = 1
requires-python = ">=3.11" requires-python = ">=3.12"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform == 'emscripten'",
@ -60,22 +60,6 @@ version = "3.4.7"
source = { registry = "https://pypi.org/simple" } 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 } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271 }
wheels = [ 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/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/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 }, { 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 } sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174 }
wheels = [ 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/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/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 }, { 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/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/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/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]] [[package]]
@ -252,21 +220,6 @@ version = "7.13.5"
source = { registry = "https://pypi.org/simple" } 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 } sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967 }
wheels = [ 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/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/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 }, { 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" } 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 } sdist = { url = "https://files.pythonhosted.org/packages/33/f9/0e84d593c0e12244150280a630999835a64f2852276161b62a0f98318de0/fonttools-4.61.0.tar.gz", hash = "sha256:ec520a1f0c7758d7a858a00f090c1745f6cde6a7c5e76fb70ea4044a15f712e7", size = 3561884 }
wheels = [ 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/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/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 }, { 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" } 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 } sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564 }
wheels = [ 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/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/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 }, { 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/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/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/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]] [[package]]
@ -594,17 +521,6 @@ version = "3.0.3"
source = { registry = "https://pypi.org/simple" } 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 } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 }
wheels = [ 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/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/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 }, { 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 } sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269 }
wheels = [ 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/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/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 }, { 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/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/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/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]] [[package]]
@ -942,17 +848,6 @@ version = "2.3.5"
source = { registry = "https://pypi.org/simple" } 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 } sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950 }
wheels = [ 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/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/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 }, { 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/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/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/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]] [[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 } sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855 }
wheels = [ 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/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/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 }, { 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" } 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 } sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828 }
wheels = [ 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/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/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 }, { 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/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/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/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]] [[package]]
@ -1274,15 +1136,6 @@ version = "6.0.3"
source = { registry = "https://pypi.org/simple" } 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 } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
wheels = [ 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/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/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 }, { 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 } sdist = { url = "https://files.pythonhosted.org/packages/0a/ca/d8ace4f98322d01abcd52d381134344bf7b431eba7ed8b42bdea5a3c2ac9/scipy-1.16.3.tar.gz", hash = "sha256:01e87659402762f43bd2fee13370553a17ada367d42e7487800bf2916535aecb", size = 30597883 }
wheels = [ 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/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/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 }, { 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" } 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 } sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 }
wheels = [ 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/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/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 }, { 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 },