diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 376130e..009a790 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -64,19 +64,7 @@ jobs: run: uv python install ${{ matrix.python-version }} - name: Build wheel - shell: bash - run: | - extra_args=() - if [[ "${{ runner.os }}" == "Linux" ]]; then - extra_args+=(--auditwheel repair) - fi - - uv tool run --from "maturin>=1.7,<2" maturin build \ - --release \ - --out dist \ - --interpreter "$(uv python find ${{ matrix.python-version }})" \ - --compatibility pypi \ - "${extra_args[@]}" + run: uv build --wheel - name: Smoke-test wheel shell: bash @@ -120,8 +108,7 @@ jobs: PY python scripts/check_release_metadata.py python scripts/check_dist_artifacts.py dist \ - --require-cpython 3.10 3.11 3.12 3.13 \ - --require-platform macosx manylinux win + --allow-pure-python twine check dist/* - name: Upload checked distributions diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9f4fd9e..c903694 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,9 +19,6 @@ jobs: with: persist-credentials: false - - name: Install Rust tooling - run: rustup component add rustfmt clippy - - name: Install uv uses: astral-sh/setup-uv@v5 with: @@ -33,12 +30,6 @@ jobs: - name: Install dependencies run: uv sync --all-extras - - name: Check Rust formatting - run: cargo fmt --all -- --check - - - name: Run Rust clippy - run: cargo clippy --all-targets --all-features -- -D warnings - - name: Check Python formatting run: uv run ruff format --check . @@ -73,18 +64,12 @@ jobs: - name: Install dependencies run: uv sync --all-extras - - name: Build extension - run: uv run maturin develop - - name: Check release metadata run: uv run python scripts/check_release_metadata.py - name: Run Python tests run: uv run pytest -m "not slow" --cov=micromode --cov-report=xml --cov-report=term-missing - - name: Run Rust tests - run: cargo test --no-default-features - - name: Build distributions if: matrix.python-version == '3.13' run: uv build @@ -165,15 +150,9 @@ jobs: - name: Install dependencies run: uv sync --all-extras - - name: Build extension - run: uv run maturin develop - - name: Run Python tests run: uv run pytest - - name: Run Rust tests - run: cargo test --no-default-features - test-windows: name: Windows portable tests needs: lint @@ -193,15 +172,9 @@ jobs: - name: Install dependencies run: uv sync --all-extras - - name: Build extension - run: uv run maturin develop - - name: Run Python tests run: uv run pytest -m "not slow" - - name: Run Rust tests - run: cargo test --no-default-features - - name: Build distributions run: uv build diff --git a/CHANGELOG.md b/CHANGELOG.md index ce3bdd3..2ed5854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,7 @@ Initial alpha release candidate. - Added grid-first Python API for rasterized mode solving. -- Added Rust sparse shift-invert solver backend. -- Added a portable native Rust sparse eigensolver path with AMD ordering, packed - LU solves, adaptive Arnoldi stopping, and no external solver-stack dependency. +- Added sparse shift-invert solver support. - Added 2D cross-section solves and 1D slice solves. - Added scalar, diagonal anisotropic, and tensor material grids. - Added six-component field reconstruction. diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 4800e51..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,371 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "amd" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a679e001575697a3bd195813feb57a4718ecc08dc194944015cbc5f6213c2b96" -dependencies = [ - "num-traits", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "matrixmultiply" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" -dependencies = [ - "autocfg", - "rawpointer", -] - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "micromode-core" -version = "0.1.0-alpha.4" -dependencies = [ - "amd", - "nalgebra", - "num-complex", - "pyo3", - "rlu", -] - -[[package]] -name = "nalgebra" -version = "0.33.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d43ddcacf343185dfd6de2ee786d9e8b1c2301622afab66b6c73baf9882abfd" -dependencies = [ - "approx", - "matrixmultiply", - "nalgebra-macros", - "num-complex", - "num-rational", - "num-traits", - "simba", - "typenum", -] - -[[package]] -name = "nalgebra-macros" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "pyo3" -version = "0.23.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7778bffd85cf38175ac1f545509665d0b9b92a198ca7941f131f85f7a4f9a872" -dependencies = [ - "cfg-if", - "indoc", - "libc", - "memoffset", - "once_cell", - "portable-atomic", - "pyo3-build-config", - "pyo3-ffi", - "pyo3-macros", - "unindent", -] - -[[package]] -name = "pyo3-build-config" -version = "0.23.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f6cbe86ef3bf18998d9df6e0f3fc1050a8c5efa409bf712e661a4366e010fb" -dependencies = [ - "once_cell", - "target-lexicon", -] - -[[package]] -name = "pyo3-ffi" -version = "0.23.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f1b4c431c0bb1c8fb0a338709859eed0d030ff6daa34368d3b152a63dfdd8d" -dependencies = [ - "libc", - "pyo3-build-config", -] - -[[package]] -name = "pyo3-macros" -version = "0.23.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbc2201328f63c4710f68abdf653c89d8dbc2858b88c5d88b0ff38a75288a9da" -dependencies = [ - "proc-macro2", - "pyo3-macros-backend", - "quote", - "syn", -] - -[[package]] -name = "pyo3-macros-backend" -version = "0.23.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fca6726ad0f3da9c9de093d6f116a93c1a38e417ed73bf138472cf4064f72028" -dependencies = [ - "heck", - "proc-macro2", - "pyo3-build-config", - "quote", - "syn", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rawpointer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" - -[[package]] -name = "rlu" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3995d5221cae7acbb7a629fd9a2065024acdf49f851eb7af64150affb8941" -dependencies = [ - "amd", - "anyhow", - "num-complex", - "num-traits", - "spsolve", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "safe_arch" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "simba" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" -dependencies = [ - "approx", - "num-complex", - "num-traits", - "paste", - "wide", -] - -[[package]] -name = "spsolve" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07a64f5d8060c8f312252da7537ac7520e0c1b3d6a9a5827df0b1555c8cad9ba" -dependencies = [ - "anyhow", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "target-lexicon" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - -[[package]] -name = "typenum" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unindent" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" - -[[package]] -name = "wide" -version = "0.7.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" -dependencies = [ - "bytemuck", - "safe_arch", -] diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 00edb90..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,38 +0,0 @@ -[package] -name = "micromode-core" -version = "0.1.0-alpha.4" -edition = "2021" -description = "Rust core for the MicroMode photonics mode solver." -license = "Apache-2.0" -repository = "https://github.com/QuentinWach/micromode" -readme = "README.md" -keywords = ["fdfd", "mode-solver", "photonics", "waveguide"] -categories = ["science"] - -[lib] -name = "micromode_core" -crate-type = ["cdylib", "rlib"] - -[[bin]] -name = "eigensolver-backend-benchmark" -path = "benchmarks/eigensolver_backend_benchmark.rs" - -[[bin]] -name = "eigensolver-phase-benchmark" -path = "benchmarks/eigensolver_phase_benchmark.rs" - -[dependencies] -amd = "0.2" -nalgebra = "0.33" -num-complex = "0.4" -pyo3 = { version = "0.23.5", optional = true } -rlu = "0.7" - -[features] -default = [] -python = ["pyo3"] -extension-module = ["python", "pyo3/extension-module"] - -[lints.clippy] -needless_range_loop = "allow" -too_many_arguments = "allow" diff --git a/README.md b/README.md index e1c0297..12a6dc0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # micromode -An **electromagnetic mode solver** using the **[FDFD method](https://en.wikipedia.org/wiki/Finite-difference_frequency-domain_method)** on a **[rectilinear Yee-grid](https://en.wikipedia.org/wiki/Finite-difference_time-domain_method)**, written in native **[Rust](https://rust-lang.org/)**. +An **electromagnetic mode solver** using the **[FDFD method](https://en.wikipedia.org/wiki/Finite-difference_frequency-domain_method)** on a **[rectilinear Yee-grid](https://en.wikipedia.org/wiki/Finite-difference_time-domain_method)**. +MicroMode is a transparent, grid-first SciPy mode solver for rasterized +photonics workflows. ```bash pip install micromode @@ -15,15 +17,25 @@ pip install micromode ## Why Use It? -- **Grid-first API**: pass arrays directly, with no required geometry model. -- **Fast**, portable Rust sparse backend: one production solve path. -- **Practical** outputs: fields, `n_eff`, `k_eff`, mode area, polarization fractions, - Lorentz overlaps, plotting, dataframe export, and HDF5 save/load. +- **Grid-first API**: pass already-rasterized material tensors and grid edges + directly, with no required CAD or geometry model. +- **Readable SciPy implementation**: sparse operators are assembled in Python + and solved with SciPy/ARPACK, so the numerical path can be inspected by users + who do not want to trust a custom native solver. +- **Small integration surface**: MicroMode is the solver piece you embed after + geometry has already been rasterized by an FDTD, FEM, or custom photonics + pipeline. - **Tensor-aware**: supports scalar, diagonal anisotropic, and full tensor material - grids. + grids, plus transformed angle and bend solves. +- **Practical outputs**: coordinate-aware fields, `n_eff`, `k_eff`, mode area, + polarization fractions, Lorentz overlaps, plotting, dataframe export, and + HDF5 save/load. - Works for both **2D cross sections and 1D slices**. -You give it a material grid. It returns guided modes: effective indices, six-component fields, polarization metrics, mode area, overlaps, diagnostics, plots, and HDF5 output. MicroMode is intentionally not a CAD or geometry package. It is the solver piece you use after geometry has already been rasterized onto a mode-plane grid. +MicroMode is intentionally not a full simulation platform or geometry package. +That is the point: you give it a mode-plane material grid, and it returns guided +modes with the diagnostics and result helpers needed by downstream simulation +workflows. _Micromode is the **default mode solver** in the [BEAMZ FDTD engine](https://github.com/beamzorg/beamz)._ @@ -78,7 +90,7 @@ uv run --extra dev python examples/tidy3d_modal_sources_monitors.py The SOI hybridization example sweeps the width of a 220 nm silicon ridge and solves several modes at each step. It shows how nearby modes exchange character as the geometry changes by plotting effective index and TE fraction across the -sweep, then rendering representative field profiles. +sweep in separate figures, then rendering representative field profiles. ```bash uv run --extra dev python examples/soi_hybridization_sweep.py @@ -97,30 +109,13 @@ the public solver controls are summarized in [docs/mode-solver-methods.md](docs/ ## Solver -MicroMode is designed to make high-performance mode solving available without -requiring users to install external solver stacks. The production backend is a -**portable Rust [sparse](https://en.wikipedia.org/wiki/Sparse_matrix) -[shift-invert](https://en.wikipedia.org/wiki/Preconditioner#Spectral_transformation) -[eigensolver](https://en.wikipedia.org/wiki/Eigenvalues_and_eigenvectors)**, so -source installs and wheels do **not** depend on -[ARPACK](https://en.wikipedia.org/wiki/ARPACK), -[UMFPACK](https://en.wikipedia.org/wiki/UMFPACK), -[SuiteSparse](https://en.wikipedia.org/wiki/SuiteSparse), -[BLAS](https://en.wikipedia.org/wiki/Basic_Linear_Algebra_Subprograms)/ -[LAPACK](https://en.wikipedia.org/wiki/LAPACK), or a Fortran compiler. -That matters for simulation workflows that need to run in CI, notebooks, -container images, FDTD plugins, and cross-platform design tools. - -The native solver is **not a dense fallback**. It uses -[sparse](https://en.wikipedia.org/wiki/Sparse_matrix) -[finite-difference](https://en.wikipedia.org/wiki/Finite_difference_method) -operators throughout, applies -[AMD fill-reducing ordering](https://en.wikipedia.org/wiki/Minimum_degree_algorithm) -before sparse [LU factorization](https://en.wikipedia.org/wiki/LU_decomposition), -stores LU factors in a packed format for repeated triangular solves, and runs an -[Arnoldi iteration](https://en.wikipedia.org/wiki/Arnoldi_iteration) targeted -around the requested effective index. The Arnoldi stage uses -**shift-invert**, adaptive -[Ritz-pair](https://en.wikipedia.org/wiki/Ritz_method) checkpointing, early -stopping once requested modes are stable, and selective Ritz vector -reconstruction so work is spent on the modes that will actually be returned. +MicroMode assembles the finite-difference sparse operators in Python and solves +the shift-invert eigenproblems with +[SciPy/ARPACK](https://docs.scipy.org/doc/scipy/reference/sparse.linalg.html). +That makes the numerical method easier for academic users to inspect and +debug, while still using a trusted sparse eigensolver stack. + +This means MicroMode is differentiated by workflow and inspectability rather +than by a custom solver engine: it is built for users who already have +permittivity on a Yee grid and want a focused, auditable mode-solver component +that fits cleanly into a larger photonics pipeline. diff --git a/benchmarks/compare_mode_solver_fixtures.py b/benchmarks/compare_mode_solver_fixtures.py index 70611fa..2a33e08 100644 --- a/benchmarks/compare_mode_solver_fixtures.py +++ b/benchmarks/compare_mode_solver_fixtures.py @@ -1,3 +1,5 @@ +"""CLI and helpers for comparing committed mode-solver fixtures against MicroMode.""" + from __future__ import annotations import argparse @@ -28,6 +30,7 @@ def parse_args() -> argparse.Namespace: + """Parse fixture-comparison CLI options.""" parser = argparse.ArgumentParser(description="Inspect committed mode-solver reference fixtures.") parser.add_argument( "--suite", @@ -72,6 +75,7 @@ def parse_args() -> argparse.Namespace: def main() -> None: + """Inspect fixtures and optionally run local comparisons.""" args = parse_args() fixture_root = args.fixture_root or (DEFAULT_FIXTURE_ROOT / args.suite) manifest = read_json(manifest_path(fixture_root)) @@ -85,7 +89,6 @@ def main() -> None: report = { "fixture_root": str(fixture_root), - "backend": "rust_sparse" if args.run_local else None, "cases": [], "summary": {"pass": 0, "fail": 0, "unsupported": 0, "not_run": 0}, } @@ -105,7 +108,7 @@ def main() -> None: status = {"status": "not_run", "summary": "local solve not requested"} if args.run_local: status = _compare_local_case(fixture_root, entry) - print(f" local rust_sparse: {status['status']}: {status['summary']}") + print(f" local scipy: {status['status']}: {status['summary']}") if status["failed"]: failures += 1 if status.get("support") == "production" and status["status"] != "pass": @@ -126,6 +129,7 @@ def main() -> None: def _compare_local_case(root: Path, entry: dict) -> dict: + """Run one reconstructable fixture recipe through MicroMode and compare outputs.""" case_id = entry["case_id"] try: import micromode as sm @@ -146,9 +150,6 @@ def _compare_local_case(root: Path, entry: dict) -> dict: support = recipe.get("support", "production") if recipe.get("unsupported"): return _status("unsupported", recipe["unsupported"], support=support) - if tuple(recipe.get("num_pml", (0, 0))) != (0, 0): - return _status("unsupported", "local Rust comparison does not support PML", support=support) - tangent_dims = tuple(dim for dim in ("x", "y", "z") if dim in ref_ex.dims and ref_ex.sizes.get(dim, 0) > 1) if len(tangent_dims) != 2: return _status("unsupported", f"expected two raster dimensions, got {tangent_dims}", support=support) @@ -277,7 +278,7 @@ def _compare_local_case(root: Path, entry: dict) -> dict: "direction": "-", "dmin_pmc": (False, True), "trim_edges": ((1, 1), (0, 0)), - "backend_tolerances": {"rust_sparse": 1e-5}, + "n_tolerance": 1e-5, "sort_order": "ascending", "krylov_dim": 64, }, @@ -291,7 +292,7 @@ def _compare_local_case(root: Path, entry: dict) -> dict: }, "pml_cross_section_y": { "support": "future_fixture_harness", - "unsupported": "PML comparison is not implemented for the local Rust fixture harness", + "unsupported": "PML comparison is not implemented for the local fixture harness", }, "custom_cartesian_left": { "support": "metadata_missing", @@ -325,6 +326,7 @@ def _compare_local_case(root: Path, entry: dict) -> dict: def _solver_edges_from_field_coords( edges: tuple[np.ndarray, np.ndarray], recipe: dict ) -> tuple[np.ndarray, np.ndarray]: + """Derive solver edge coordinates from fixture field coordinates.""" dmin_pmc = tuple(bool(value) for value in recipe.get("dmin_pmc", (False, False))) trim_edges = tuple(recipe.get("trim_edges", ((0, 0), (0, 0)))) out = [] @@ -358,6 +360,7 @@ def _solve_recipe( normal_dim: str, normal_coord: float, ): + """Solve all frequencies described by a local fixture recipe.""" freqs = tuple(float(freq) for freq in ref_n.coords["f"].values) if recipe.get("solve_each_frequency"): rows = [] @@ -428,6 +431,7 @@ def _solve_recipe_for_freq( normal_dim: str, normal_coord: float, ): + """Solve one fixture recipe for one frequency or frequency tuple.""" freqs = (float(freq),) if np.isscalar(freq) else tuple(float(value) for value in freq) centers = tuple((axis_edges[:-1] + axis_edges[1:]) / 2 for axis_edges in edges) eps_xx, eps_yy, eps_zz = _eps_components_from_recipe(recipe, edges, centers, tangent_dims, freqs[0], sm) @@ -464,6 +468,7 @@ def _eps_components_from_recipe( freq: float, sm, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Rasterize diagonal epsilon components at Yee sample locations.""" if recipe.get("yee_staggered", True): return ( _eps_from_recipe(recipe, (coords[0], edges[1][:-1]), tangent_dims, freq, sm), @@ -481,6 +486,7 @@ def _eps_from_recipe( freq: float, sm, ) -> np.ndarray: + """Rasterize primitive boxes and circles onto one component grid.""" grids = np.meshgrid(*coords, indexing="ij") eps = np.full(tuple(len(coord) for coord in coords), recipe.get("clad_eps", 1.0), dtype=np.complex128) for box in recipe.get("boxes", ()): @@ -506,6 +512,7 @@ def _eps_from_recipe( def _reorder_modes(values: np.ndarray, recipe: dict) -> np.ndarray: + """Apply fixture-specific mode ordering before comparison.""" if recipe.get("sort_order") != "ascending": return values order = np.argsort(values.real, axis=1) @@ -513,6 +520,7 @@ def _reorder_modes(values: np.ndarray, recipe: dict) -> np.ndarray: def _reorder_field_modes(values: np.ndarray, recipe: dict) -> np.ndarray: + """Apply fixture-specific field ordering for diagnostic overlap checks.""" if recipe.get("sort_order") != "ascending": return values # Field reordering is only used for a coarse overlap diagnostic; n sorting is authoritative. @@ -520,14 +528,16 @@ def _reorder_field_modes(values: np.ndarray, recipe: dict) -> np.ndarray: def _status(status: str, summary: str, **details) -> dict: + """Build a normalized status dictionary for report output.""" return {"status": status, "failed": status == "fail", "summary": summary, **details} def _n_tolerance(entry: dict, recipe: dict | None = None) -> float: + """Resolve the effective-index tolerance for one fixture case.""" if recipe is not None: - backend_tolerance = recipe.get("backend_tolerances", {}).get("rust_sparse") - if backend_tolerance is not None: - return float(backend_tolerance) + recipe_tolerance = recipe.get("n_tolerance") + if recipe_tolerance is not None: + return float(recipe_tolerance) return float( entry.get("tolerances", {}).get( "n_complex_atol", diff --git a/benchmarks/compare_tidy3d_backends.py b/benchmarks/compare_tidy3d_backends.py new file mode 100644 index 0000000..3cd6d32 --- /dev/null +++ b/benchmarks/compare_tidy3d_backends.py @@ -0,0 +1,268 @@ +"""Benchmark MicroMode against equivalent Tidy3D mode-solver setups.""" + +from __future__ import annotations + +import argparse +import json +import time +from dataclasses import dataclass +from pathlib import Path + +import numpy as np + +import micromode as mm + + +@dataclass(frozen=True) +class BenchmarkCase: + """Configuration for one backend comparison problem.""" + + case_id: str + description: str + ny: int + nz: int + num_modes: int = 2 + target_neff: float = 2.5 + krylov_dim: int = 64 + num_pml: tuple[int, int] = (0, 0) + problem: str = "strip" + + +PRESETS = { + "quick": ( + BenchmarkCase("strip_60x40", "SOI strip waveguide, coarse", 60, 40, krylov_dim=48), + BenchmarkCase("strip_120x80", "SOI strip waveguide", 120, 80, krylov_dim=48), + BenchmarkCase("strip_pml_120x80", "SOI strip waveguide with mode PML", 120, 80, krylov_dim=48, num_pml=(8, 8)), + BenchmarkCase( + "slot_120x80", "Silicon slot waveguide, fundamental", 120, 80, num_modes=1, krylov_dim=48, problem="slot" + ), + ), + "large": ( + BenchmarkCase("strip_60x40", "SOI strip waveguide, coarse", 60, 40, krylov_dim=48), + BenchmarkCase("strip_120x80", "SOI strip waveguide", 120, 80, krylov_dim=48), + BenchmarkCase("strip_240x160", "SOI strip waveguide, large", 240, 160, krylov_dim=64), + BenchmarkCase("strip_480x320", "SOI strip waveguide, very large", 480, 320, krylov_dim=64), + BenchmarkCase( + "slot_120x80", "Silicon slot waveguide, fundamental", 120, 80, num_modes=1, krylov_dim=48, problem="slot" + ), + ), +} + +WIDTH_Y = 3.0 +WIDTH_Z = 2.0 +WAVELENGTH_UM = 1.55 +N_SI = 3.48 +N_SIO2 = 1.45 +N_AIR = 1.0 + + +def main() -> None: + """Run selected backend-comparison cases and print a markdown table.""" + args = parse_args() + cases = list(PRESETS[args.preset]) + rows = [run_case(case, profile_source=args.profile_source) for case in cases] + print(markdown_table(rows)) + if args.output is not None: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(rows, indent=2) + "\n", encoding="utf-8") + print(f"Wrote {args.output}") + + +def parse_args() -> argparse.Namespace: + """Parse backend benchmark CLI options.""" + parser = argparse.ArgumentParser(description="Compare MicroMode SciPy and Tidy3D local solves.") + parser.add_argument("--preset", choices=tuple(PRESETS), default="quick") + parser.add_argument( + "--profile-source", + choices=("tidy3d", "analytic"), + default="tidy3d", + help="Use Tidy3D's exact local solver grid/epsilon profile, or MicroMode's independent analytic raster.", + ) + parser.add_argument("--output", type=Path, default=Path("tmp/tidy3d_solver_benchmark.json")) + return parser.parse_args() + + +def run_case(case: BenchmarkCase, *, profile_source: str) -> dict[str, object]: + """Execute one benchmark case for MicroMode and Tidy3D.""" + tidy3d_solver = make_tidy3d_solver(case) + materials = ( + micromode_materials_from_tidy3d_solver(tidy3d_solver, case) + if profile_source == "tidy3d" + else micromode_materials(case) + ) + row: dict[str, object] = { + "case_id": case.case_id, + "description": case.description, + "grid": f"{case.ny}x{case.nz}", + "cells": case.ny * case.nz, + "profile_source": profile_source, + } + scipy_seconds, scipy_neff = time_micromode(case, materials) + tidy3d_seconds, tidy3d_neff = time_tidy3d(tidy3d_solver) + + row.update( + { + "scipy_seconds": scipy_seconds, + "tidy3d_seconds": tidy3d_seconds, + "scipy_n_eff": scipy_neff.tolist(), + "tidy3d_n_eff": tidy3d_neff.tolist(), + "scipy_tidy3d_max_abs_neff": max_abs_delta(scipy_neff, tidy3d_neff), + } + ) + print( + f"{case.case_id}: scipy={scipy_seconds:.3f}s tidy3d={tidy3d_seconds:.3f}s " + f"delta_tidy3d={row['scipy_tidy3d_max_abs_neff']:.3e}", + flush=True, + ) + return row + + +def time_micromode(case: BenchmarkCase, materials: mm.Materials) -> tuple[float, np.ndarray]: + """Time the MicroMode solve for a prepared material grid.""" + start = time.perf_counter() + data = mm.solve_modes( + material_grid=materials, + wavelength=WAVELENGTH_UM, + num_modes=case.num_modes, + target_neff=case.target_neff, + krylov_dim=case.krylov_dim, + pml=mm.PmlSpec(num_cells=case.num_pml), + ) + return time.perf_counter() - start, np.asarray(data.n_eff.values[0], dtype=float) + + +def time_tidy3d(solver) -> tuple[float, np.ndarray]: + """Time a Tidy3D mode solve.""" + start = time.perf_counter() + data = solver.solve() + return time.perf_counter() - start, np.asarray(data.n_eff.values[0], dtype=float) + + +def make_tidy3d_solver(case: BenchmarkCase): + """Construct a Tidy3D mode solver for one benchmark case.""" + try: + import tidy3d as td + from tidy3d.plugins.mode import ModeSolver + except ImportError as exc: # pragma: no cover - benchmark dependency only. + raise SystemExit("Install Tidy3D for this benchmark: uv run --with tidy3d ...") from exc + + structures = tidy3d_structures(td, case.problem) + dl = min(WIDTH_Y / case.ny, WIDTH_Z / case.nz) + freq = td.C_0 / WAVELENGTH_UM + sim = td.Simulation( + size=(0.1, WIDTH_Y, WIDTH_Z), + grid_spec=td.GridSpec.uniform(dl=dl), + run_time=1e-12, + structures=structures, + boundary_spec=td.BoundarySpec.all_sides(boundary=td.PECBoundary()), + ) + plane = td.Box(center=(0.0, 0.0, 0.0), size=(0.0, WIDTH_Y, WIDTH_Z)) + solver = ModeSolver( + simulation=sim, + plane=plane, + mode_spec=td.ModeSpec(num_modes=case.num_modes, target_neff=case.target_neff, num_pml=case.num_pml), + freqs=[freq], + ) + return solver + + +def micromode_materials_from_tidy3d_solver(solver, case: BenchmarkCase) -> mm.Materials: + """Rasterize Tidy3D solver materials into a MicroMode grid.""" + eps = np.asarray(solver._solver_eps(tidy3d_frequency()), dtype=np.complex128) + grid = solver._solver_grid + return mm.Materials.from_components( + x_edges=np.asarray(grid.boundaries.y, dtype=float), + y_edges=np.asarray(grid.boundaries.z, dtype=float), + normal_axis=2, + eps_xx=eps[0], + eps_xy=eps[1], + eps_xz=eps[2], + eps_yx=eps[3], + eps_yy=eps[4], + eps_yz=eps[5], + eps_zx=eps[6], + eps_zy=eps[7], + eps_zz=eps[8], + ) + + +def tidy3d_frequency() -> float: + """Return the benchmark frequency in Hz.""" + import tidy3d as td + + return float(td.C_0 / WAVELENGTH_UM) + + +def micromode_materials(case: BenchmarkCase) -> mm.Materials: + """Build a direct MicroMode material grid for one benchmark case.""" + y_edges = np.linspace(-0.5 * WIDTH_Y, 0.5 * WIDTH_Y, case.ny + 1) + z_edges = np.linspace(-0.5 * WIDTH_Z, 0.5 * WIDTH_Z, case.nz + 1) + y = 0.5 * (y_edges[:-1] + y_edges[1:]) + z = 0.5 * (z_edges[:-1] + z_edges[1:]) + yy, zz = np.meshgrid(y, z, indexing="ij") + eps = np.where(zz < 0.0, N_SIO2**2, N_AIR**2).astype(np.complex128) + if case.problem == "strip": + eps[(np.abs(yy) <= 0.225) & (zz >= 0.0) & (zz <= 0.22)] = N_SI**2 + elif case.problem == "slot": + left = (yy >= -0.29) & (yy <= -0.09) & (zz >= 0.0) & (zz <= 0.22) + right = (yy >= 0.09) & (yy <= 0.29) & (zz >= 0.0) & (zz <= 0.22) + eps[left | right] = N_SI**2 + else: + raise ValueError(f"unknown problem: {case.problem}") + return mm.Materials.from_diagonal(eps_xx=eps, x_edges=y_edges, y_edges=z_edges, normal_axis=0) + + +def tidy3d_structures(td, problem: str): + """Return Tidy3D geometry structures for one benchmark problem.""" + structures = [ + td.Structure( + geometry=td.Box(center=(0.0, 0.0, -0.5001), size=(td.inf, td.inf, 1.0002)), + medium=td.Medium(permittivity=N_SIO2**2), + ) + ] + if problem == "strip": + structures.append( + td.Structure( + geometry=td.Box(center=(0.0, 0.0, 0.11), size=(td.inf, 0.45, 0.22)), + medium=td.Medium(permittivity=N_SI**2), + ) + ) + elif problem == "slot": + for y_center in (-0.19, 0.19): + structures.append( + td.Structure( + geometry=td.Box(center=(0.0, y_center, 0.11), size=(td.inf, 0.20, 0.22)), + medium=td.Medium(permittivity=N_SI**2), + ) + ) + else: + raise ValueError(f"unknown problem: {problem}") + return structures + + +def max_abs_delta(left: np.ndarray, right: np.ndarray) -> float: + """Return the maximum absolute difference between sorted mode arrays.""" + count = min(left.size, right.size) + if count == 0: + return float("nan") + return float(np.max(np.abs(left[:count] - right[:count]))) + + +def markdown_table(rows: list[dict[str, object]]) -> str: + """Format benchmark rows as a markdown table.""" + header = ( + "| Problem | Grid | MicroMode SciPy (s) | Tidy3D local (s) | max abs Δn_eff SciPy/Tidy3D |\n" + "|---|---:|---:|---:|---:|" + ) + lines = [header] + for row in rows: + lines.append( + f"| {row['description']} | {row['grid']} | {row['scipy_seconds']:.3f} | " + f"{row['tidy3d_seconds']:.3f} | " + f"{row['scipy_tidy3d_max_abs_neff']:.3e} |" + ) + return "\n".join(lines) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/eigensolver_backend_benchmark.rs b/benchmarks/eigensolver_backend_benchmark.rs deleted file mode 100644 index d59542e..0000000 --- a/benchmarks/eigensolver_backend_benchmark.rs +++ /dev/null @@ -1,91 +0,0 @@ -use std::hint::black_box; -use std::time::{Duration, Instant}; - -use micromode_core::eigensolve::{ - selected_sparse_shift_invert_native_eigenpairs, Eigenpair, ShiftInvertOptions, -}; - -mod eigensolver_benchmark_problem; -use eigensolver_benchmark_problem::{parse_f64_arg, parse_grids, parse_usize_arg, strip_operator}; - -fn main() -> Result<(), String> { - let grids = parse_grids(); - let repeats = parse_usize_arg("--repeats").unwrap_or(5); - let krylov_dim = parse_usize_arg("--krylov-dim").unwrap_or(40); - let num_modes = parse_usize_arg("--num-modes").unwrap_or(2); - let target_neff = parse_f64_arg("--target-neff").unwrap_or(2.5); - - println!("backend,grid,operator_size,operator_nnz,repeats,best_ms,mean_ms,max_residual"); - for (nx, ny) in grids { - let (mat, guess) = strip_operator(nx, ny, target_neff); - let options = ShiftInvertOptions { - krylov_dim, - tolerance: 1e-10, - }; - - let native = benchmark_backend(repeats, || { - selected_sparse_shift_invert_native_eigenpairs( - &mat, - num_modes, - guess, - None, - options.clone(), - ) - })?; - - print_row("native", nx, ny, mat.rows, mat.nnz(), repeats, &native); - } - Ok(()) -} - -struct BenchResult { - durations: Vec, - pairs: Vec, -} - -fn benchmark_backend(repeats: usize, mut solve: F) -> Result -where - F: FnMut() -> Result, String>, -{ - let warmup = solve()?; - black_box(&warmup); - - let mut durations = Vec::with_capacity(repeats); - let mut pairs = Vec::new(); - for _ in 0..repeats { - let start = Instant::now(); - pairs = solve()?; - durations.push(start.elapsed()); - black_box(&pairs); - } - Ok(BenchResult { durations, pairs }) -} - -fn print_row( - backend: &str, - nx: usize, - ny: usize, - operator_size: usize, - operator_nnz: usize, - repeats: usize, - result: &BenchResult, -) { - let best = result.durations.iter().min().copied().unwrap_or_default(); - let mean = result - .durations - .iter() - .map(Duration::as_secs_f64) - .sum::() - / repeats as f64; - let max_residual = result - .pairs - .iter() - .map(|pair| pair.residual) - .fold(0.0, f64::max); - println!( - "{backend},{nx}x{ny},{operator_size},{operator_nnz},{repeats},{:.3},{:.3},{:.3e}", - best.as_secs_f64() * 1000.0, - mean * 1000.0, - max_residual, - ); -} diff --git a/benchmarks/eigensolver_benchmark_problem.rs b/benchmarks/eigensolver_benchmark_problem.rs deleted file mode 100644 index 983373b..0000000 --- a/benchmarks/eigensolver_benchmark_problem.rs +++ /dev/null @@ -1,111 +0,0 @@ -use micromode_core::derivatives::{self, Tensor3}; -use micromode_core::operators::assemble_sparse_diagonal_operators; -use micromode_core::sparse_matrix::SparseMatrix; -use num_complex::Complex64; - -pub fn strip_operator(nx: usize, ny: usize, target_neff: f64) -> (SparseMatrix, Complex64) { - let x_edges = linspace(-1.2, 1.2, nx + 1); - let y_edges = linspace(-0.8, 0.8, ny + 1); - let dlf_x = steps(&x_edges); - let dlf_y = steps(&y_edges); - let dlb_x = dlf_x.clone(); - let dlb_y = dlf_y.clone(); - let derivatives = derivatives::create_d_matrices_sparse( - (nx, ny), - (&dlf_x, &dlf_y), - (&dlb_x, &dlb_y), - (false, false), - ); - let eps = strip_tensor(nx, ny, &x_edges, &y_edges); - let mu = uniform_tensor(nx * ny, 1.0); - let operators = assemble_sparse_diagonal_operators(&eps, &mu, &derivatives); - let guess = Complex64::new(-(target_neff * target_neff), 0.0); - (operators.mat, guess) -} - -pub fn parse_grids() -> Vec<(usize, usize)> { - let args = std::env::args().collect::>(); - let mut grids = Vec::new(); - let mut index = 0; - while index < args.len() { - if args[index] == "--grid" { - if let Some(value) = args.get(index + 1) { - if let Some(grid) = parse_grid(value) { - grids.push(grid); - } - } - index += 1; - } - index += 1; - } - if grids.is_empty() { - vec![(10, 8), (16, 12), (24, 18)] - } else { - grids - } -} - -pub fn parse_usize_arg(name: &str) -> Option { - parse_arg(name).and_then(|value| value.parse().ok()) -} - -pub fn parse_f64_arg(name: &str) -> Option { - parse_arg(name).and_then(|value| value.parse().ok()) -} - -fn strip_tensor(nx: usize, ny: usize, x_edges: &[f64], y_edges: &[f64]) -> Tensor3 { - let mut tensor = uniform_tensor(nx * ny, 1.44 * 1.44); - for ix in 0..nx { - let x = 0.5 * (x_edges[ix] + x_edges[ix + 1]); - for iy in 0..ny { - let y = 0.5 * (y_edges[iy] + y_edges[iy + 1]); - let eps = if x.abs() <= 0.25 && y.abs() <= 0.11 { - 3.48 * 3.48 - } else { - 1.44 * 1.44 - }; - let index = ix * ny + iy; - for component in 0..3 { - tensor[component][component][index] = Complex64::new(eps, 0.0); - } - } - } - tensor -} - -fn uniform_tensor(n: usize, diagonal: f64) -> Tensor3 { - let mut tensor: Tensor3 = std::array::from_fn(|_| std::array::from_fn(|_| Vec::new())); - for row in 0..3 { - for col in 0..3 { - tensor[row][col] = vec![Complex64::new(0.0, 0.0); n]; - } - } - for component in 0..3 { - tensor[component][component] = vec![Complex64::new(diagonal, 0.0); n]; - } - tensor -} - -fn linspace(start: f64, stop: f64, len: usize) -> Vec { - let step = (stop - start) / (len - 1) as f64; - (0..len).map(|index| start + step * index as f64).collect() -} - -fn steps(edges: &[f64]) -> Vec { - edges - .windows(2) - .map(|window| window[1] - window[0]) - .collect() -} - -fn parse_grid(value: &str) -> Option<(usize, usize)> { - let (left, right) = value.split_once('x')?; - Some((left.parse().ok()?, right.parse().ok()?)) -} - -fn parse_arg(name: &str) -> Option { - let args = std::env::args().collect::>(); - args.windows(2) - .find(|window| window[0] == name) - .map(|window| window[1].clone()) -} diff --git a/benchmarks/eigensolver_phase_benchmark.rs b/benchmarks/eigensolver_phase_benchmark.rs deleted file mode 100644 index 3f6717b..0000000 --- a/benchmarks/eigensolver_phase_benchmark.rs +++ /dev/null @@ -1,99 +0,0 @@ -use std::hint::black_box; -use std::time::{Duration, Instant}; - -use micromode_core::eigensolve::{ - profile_sparse_shift_invert_native_eigenpairs, ShiftInvertOptions, ShiftInvertProfile, -}; - -mod eigensolver_benchmark_problem; -use eigensolver_benchmark_problem::{parse_f64_arg, parse_grids, parse_usize_arg, strip_operator}; - -fn main() -> Result<(), String> { - let grids = parse_grids(); - let repeats = parse_usize_arg("--repeats").unwrap_or(3); - let krylov_dim = parse_usize_arg("--krylov-dim").unwrap_or(56); - let num_modes = parse_usize_arg("--num-modes").unwrap_or(2); - let target_neff = parse_f64_arg("--target-neff").unwrap_or(2.5); - - println!( - "grid,operator_size,operator_nnz,repeat,assembly_ms,total_ms,shift_diagonal_ms,amd_ordering_ms,lu_factorization_ms,lu_packing_ms,linear_solves_ms,arnoldi_orthogonalization_ms,hessenberg_eigensolve_ms,ritz_reconstruction_ms,residuals_ms,sorting_ms,solve_calls,arnoldi_steps,candidate_count,returned_pairs,lu_l_nnz,lu_u_nnz,lu_fill_ratio,max_residual" - ); - for (nx, ny) in grids { - let (warm_mat, warm_guess) = strip_operator(nx, ny, target_neff); - let warm_profile = profile_sparse_shift_invert_native_eigenpairs( - &warm_mat, - num_modes, - warm_guess, - None, - ShiftInvertOptions { - krylov_dim, - tolerance: 1e-10, - }, - )?; - black_box(&warm_profile); - - for repeat in 0..repeats { - let assembly_start = Instant::now(); - let (mat, guess) = strip_operator(nx, ny, target_neff); - let assembly = assembly_start.elapsed(); - let profile = profile_sparse_shift_invert_native_eigenpairs( - &mat, - num_modes, - guess, - None, - ShiftInvertOptions { - krylov_dim, - tolerance: 1e-10, - }, - )?; - black_box(&profile); - print_row(nx, ny, repeat, mat.rows, mat.nnz(), assembly, &profile); - } - } - Ok(()) -} - -fn print_row( - nx: usize, - ny: usize, - repeat: usize, - operator_size: usize, - operator_nnz: usize, - assembly: Duration, - profile: &ShiftInvertProfile, -) { - let lu_nnz = profile.lu_l_nnz + profile.lu_u_nnz; - let fill_ratio = lu_nnz as f64 / operator_nnz.max(1) as f64; - println!( - "{}x{},{},{},{},{:.3},{:.3},{:.3},{:.3},{:.3},{:.3},{:.3},{:.3},{:.3},{:.3},{:.3},{:.3},{},{},{},{},{},{},{:.3},{:.3e}", - nx, - ny, - operator_size, - operator_nnz, - repeat, - ms(assembly), - ms(profile.total), - ms(profile.shift_diagonal), - ms(profile.amd_ordering), - ms(profile.lu_factorization), - ms(profile.lu_packing), - ms(profile.linear_solves), - ms(profile.arnoldi_orthogonalization), - ms(profile.hessenberg_eigensolve), - ms(profile.ritz_reconstruction), - ms(profile.residuals), - ms(profile.sorting), - profile.solve_calls, - profile.arnoldi_steps, - profile.candidate_count, - profile.returned_pairs, - profile.lu_l_nnz, - profile.lu_u_nnz, - fill_ratio, - profile.max_residual, - ); -} - -fn ms(duration: Duration) -> f64 { - duration.as_secs_f64() * 1000.0 -} diff --git a/benchmarks/micromode_backend_benchmark.py b/benchmarks/micromode_solver_benchmark.py similarity index 86% rename from benchmarks/micromode_backend_benchmark.py rename to benchmarks/micromode_solver_benchmark.py index 68693ad..0b8e16f 100644 --- a/benchmarks/micromode_backend_benchmark.py +++ b/benchmarks/micromode_solver_benchmark.py @@ -1,3 +1,5 @@ +"""Time MicroMode solves and record sparse-operator diagnostics.""" + from __future__ import annotations import argparse @@ -11,6 +13,7 @@ def main() -> None: + """Run timing cases and write a JSON benchmark report.""" args = parse_args() rows = [] grids = args.grid or ["20x14", "32x22", "48x32"] @@ -34,8 +37,8 @@ def main() -> None: "cells": nx * ny, "repeat": repeat, "seconds": elapsed, - "backend": run_info["backend"], - "backend_kind": run_info["backend_kind"], + "solver": run_info["backend"], + "solver_kind": run_info["backend_kind"], "operator_size": run_info["operator_size"], "operator_nnz": run_info["operator_nnz"], "max_residual": float(np.max(run_info["residuals"])), @@ -44,7 +47,7 @@ def main() -> None: rows.append(row) print( f"{nx:4d}x{ny:<4d} repeat={repeat} " - f"{elapsed:7.3f}s backend={row['backend']} " + f"{elapsed:7.3f}s solver={row['solver']} " f"max_res={row['max_residual']:.2e}" ) @@ -55,6 +58,7 @@ def main() -> None: def parse_args() -> argparse.Namespace: + """Parse solver benchmark CLI options.""" parser = argparse.ArgumentParser(description="Benchmark MicroMode sparse solves over grid sizes.") parser.add_argument( "--grid", @@ -68,11 +72,12 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--krylov-dim", type=int, default=40) parser.add_argument("--wavelength", type=float, default=1.55) parser.add_argument("--num-pml", type=int, nargs=2, metavar=("NX", "NY"), default=(0, 0)) - parser.add_argument("--output", type=Path, default=Path("tmp/micromode_backend_benchmark.json")) + parser.add_argument("--output", type=Path, default=Path("tmp/micromode_solver_benchmark.json")) return parser.parse_args() def parse_grid_sizes(values: list[str]) -> list[tuple[int, int]]: + """Parse grid-size strings like 20x14 into integer pairs.""" sizes = [] for value in values: left, sep, right = value.lower().partition("x") @@ -83,6 +88,7 @@ def parse_grid_sizes(values: list[str]) -> list[tuple[int, int]]: def strip_materials(*, nx: int, ny: int) -> mm.Materials: + """Build a simple strip-waveguide material grid for timing.""" x_edges = np.linspace(-1.2, 1.2, nx + 1) y_edges = np.linspace(-0.8, 0.8, ny + 1) x = 0.5 * (x_edges[:-1] + x_edges[1:]) diff --git a/benchmarks/mode_solver/README.md b/benchmarks/mode_solver/README.md index c5d1233..30bc089 100644 --- a/benchmarks/mode_solver/README.md +++ b/benchmarks/mode_solver/README.md @@ -18,11 +18,12 @@ Run the local raster solver against every fixture that has enough neutral metada the mode plane: ```bash -uv run python benchmarks/compare_mode_solver_fixtures.py --suite extended --run-local --report-json tmp/reference_fixture_validation_rust_sparse.json +uv run python benchmarks/compare_mode_solver_fixtures.py \ + --suite extended \ + --run-local \ + --report-json tmp/reference_fixture_validation_scipy.json ``` -Local fixture validation uses the Rust sparse backend. - Local validation reports each case as `pass`, `fail`, or `unsupported`. `fail` means the local solver ran but exceeded the fixture tolerance; `unsupported` means the case is explicitly classified as outside the current production target, blocked by missing neutral raster metadata, or not yet @@ -52,13 +53,13 @@ waveguides, cubic and polynomial interpolation, and non-default mode sorting met angle-plus-bend transforms still need a validated tensorial reference run before a fixture is committed. -## Backend Timing +## Solver Timing -Use the backend benchmark to track solve time and sparse-operator diagnostics across grid sizes: +Use the solver benchmark to track solve time and sparse-operator diagnostics across grid sizes: ```bash -uv run python benchmarks/micromode_backend_benchmark.py --grid 20x14 --grid 32x22 +uv run python benchmarks/micromode_solver_benchmark.py --grid 20x14 --grid 32x22 ``` -The benchmark uses MicroMode directly, writes JSON to `tmp/` by default, and records backend name, +The benchmark uses MicroMode directly, writes JSON to `tmp/` by default, and records solver name, operator size, nonzero count, residuals, elapsed time, and solved effective indices. diff --git a/benchmarks/mode_solver/fixtures.py b/benchmarks/mode_solver/fixtures.py index 501c328..051da7d 100644 --- a/benchmarks/mode_solver/fixtures.py +++ b/benchmarks/mode_solver/fixtures.py @@ -1,3 +1,5 @@ +"""Shared helpers for reading committed mode-solver reference fixtures.""" + from __future__ import annotations import hashlib @@ -15,22 +17,27 @@ def case_dir(root: Path, case_id: str) -> Path: + """Return the directory containing one fixture case.""" return root / case_id def data_path(root: Path, case_id: str) -> Path: + """Return the HDF5 path for one fixture case.""" return case_dir(root, case_id) / "mode_data.hdf5" def summary_path(root: Path, case_id: str) -> Path: + """Return the JSON summary path for one fixture case.""" return case_dir(root, case_id) / "summary.json" def manifest_path(root: Path) -> Path: + """Return the manifest path for a fixture suite.""" return root / "manifest.json" def sha256_file(path: Path) -> str: + """Return the SHA-256 digest for a file.""" digest = hashlib.sha256() with path.open("rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): @@ -39,14 +46,17 @@ def sha256_file(path: Path) -> str: def read_json(path: Path) -> dict[str, Any]: + """Read a JSON file into a dictionary.""" return json.loads(path.read_text()) def write_json(path: Path, payload: dict[str, Any]) -> None: + """Write a dictionary as stable, pretty JSON.""" path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") def iter_manifest_entries(root: Path) -> tuple[dict[str, Any], ...]: + """Return all case entries from a suite manifest.""" return tuple(read_json(manifest_path(root))["cases"]) @@ -65,6 +75,7 @@ def load_data_array(path: Path, name: str) -> xr.DataArray: def _read_xarray_group(group: Any) -> xr.DataArray: + """Read a legacy xarray-style HDF5 group.""" if _XARRAY_VALUE_NAME not in group: raise KeyError(f"HDF5 group {group.name!r} is missing {_XARRAY_VALUE_NAME!r}") values = group[_XARRAY_VALUE_NAME][()] @@ -75,6 +86,7 @@ def _read_xarray_group(group: Any) -> xr.DataArray: def _infer_dims(shape: tuple[int, ...], coords: dict[str, np.ndarray]) -> tuple[str, ...]: + """Infer dimension names from HDF5 coordinate datasets.""" dims = tuple(dim for dim in _PREFERRED_DIMS if dim in coords) if len(dims) == len(shape) and all(len(coords[dim]) == size for dim, size in zip(dims, shape, strict=True)): return dims @@ -86,6 +98,7 @@ def _infer_dims(shape: tuple[int, ...], coords: dict[str, np.ndarray]) -> tuple[ def phase_aligned_relative_error(golden: np.ndarray, actual: np.ndarray) -> tuple[float, float]: + """Compare complex arrays after removing a global phase offset.""" g = np.asarray(golden).reshape(-1) a = np.asarray(actual).reshape(-1) norm_g = float(np.linalg.norm(g)) diff --git a/docs/assets/hybridization_sweep.png b/docs/assets/hybridization_sweep.png index 195b7b2..036ed63 100644 Binary files a/docs/assets/hybridization_sweep.png and b/docs/assets/hybridization_sweep.png differ diff --git a/docs/backend-trust.md b/docs/backend-trust.md new file mode 100644 index 0000000..fa12e24 --- /dev/null +++ b/docs/backend-trust.md @@ -0,0 +1,54 @@ +# Solver Trust Model + +MicroMode uses one solver implementation: a Python/SciPy sparse mode solver. +The finite-difference operators are assembled in inspectable Python code and +the eigenproblems are solved with SciPy/ARPACK. + +## Supported Scope + +The SciPy solver covers the core solve families: + +- diagonal scalar or diagonal-anisotropic material grids, +- full tensor material grids, +- angle and bend transforms that route through the tensorial operator, +- PML stretching, +- one-dimensional slices, because they are padded into ordinary mode-plane + grids before solver dispatch. + +## What Is Tested + +The test suite checks representative grids for: + +- returned complex effective indices, +- sparse operator size and nonzero count, +- unit-power normalization diagnostics, +- Lorentz orthogonality diagnostics. + +The covered cases include diagonal grids, PML, full-tensor/off-diagonal grids, +and transformed grids. + +Run the focused solver checks with: + +```bash +uv run pytest tests/test_micromode_api.py +``` + +## Reading The Code + +The relevant files are: + +- `python/micromode/scipy_reference.py`: Python/SciPy implementation of the + diagonal and tensorial sparse paths. +- `python/micromode/raster.py`: public solve orchestration and `Result` + wrapping. + +The intended trust chain is: inspect the Python/SciPy implementation, then run +the fixture and API tests to verify behavior on representative mode-solver +cases. + +## External References + +MicroMode keeps Tidy3D-oriented examples and fixtures because Tidy3D is a +recognizable reference point for photonics users. See the Tidy3D example in +`examples/` and the committed mode-solver fixture harness for comparison +infrastructure. diff --git a/docs/mode-solver-methods.md b/docs/mode-solver-methods.md index d144284..98a383d 100644 --- a/docs/mode-solver-methods.md +++ b/docs/mode-solver-methods.md @@ -2,8 +2,8 @@ `solve_modes(...)` is the main entry point. It validates the `Materials` grid, resolves frequencies or wavelengths, builds the Yee derivative matrices, chooses -the diagonal or tensorial Rust backend, solves one frequency at a time, and -returns a coordinate-aware `Result`. +the sparse formulation, solves one frequency at a time, and returns a +coordinate-aware `Result`. ## Material Grids @@ -33,7 +33,8 @@ returns a coordinate-aware `Result`. ## Eigenpair Selection -Internally, eigenpairs are selected with sparse shift-invert Arnoldi [1, 2]. +The SciPy solver selects eigenpairs with sparse shift-invert SciPy/ARPACK [1, +2]. For a matrix \(A\) and shift \(\sigma\), Arnoldi is applied to $$ @@ -43,7 +44,7 @@ $$ $$ where \(\theta\) is a Ritz value of the inverse-shifted operator. The diagonal -backend uses \(\sigma=-\texttt{target_neff}^2\); the tensorial backend uses +formulation uses \(\sigma=-\texttt{target_neff}^2\); the tensorial formulation uses \(\sigma=\texttt{target_neff}\). Returned modes are sorted by decreasing real effective index, normalized to diff --git a/docs/physics-model.md b/docs/physics-model.md index 9c36980..51dd378 100644 --- a/docs/physics-model.md +++ b/docs/physics-model.md @@ -37,8 +37,8 @@ finite-difference frequency-domain method on a regular Yee grid [2]. ## Discretization -The Rust kernels use relative material tensors $\epsilon_r(x,y)$, -$\mu_r(x,y)$ and scale transverse derivatives by $1/k_0$, so the sparse +The solver uses relative material tensors $\epsilon_r(x,y)$, +$\mu_r(x,y)$ and scales transverse derivatives by $1/k_0$, so the sparse operators are dimensionless. On the local Yee grid, the four derivative matrices are diff --git a/docs/release.md b/docs/release.md index 699f875..6fbe625 100644 --- a/docs/release.md +++ b/docs/release.md @@ -23,9 +23,7 @@ Run these before tagging: ```bash uv sync --all-extras -env -u CONDA_PREFIX uv run maturin develop uv run pytest --cov=micromode --cov-report=xml --cov-report=term-missing -cargo test uv build uv run twine check dist/* ./scripts/smoke_dist.sh @@ -58,14 +56,14 @@ python -m venv /tmp/micromode-testpypi ## PyPI Release 1. Update `CHANGELOG.md`. -2. Confirm `pyproject.toml`, `Cargo.toml`, and `CHANGELOG.md` all use the - same new release version. PyPI filenames are immutable, so never reuse a - version that has reached PyPI. +2. Confirm `pyproject.toml` and `CHANGELOG.md` both use the same new release + version. PyPI filenames are immutable, so never reuse a version that has + reached PyPI. 3. Commit the release changes. 4. Tag the release with the same version from `pyproject.toml`: ```bash -git tag v0.1.0a3 +git tag v$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") git push origin main --tags ``` @@ -74,5 +72,5 @@ checks metadata, and publishes to PyPI using Trusted Publishing. ## Current Wheel Scope -The release workflow builds Linux, macOS, and Windows wheels for Python 3.10 -through 3.13. +The release workflow builds a pure-Python wheel and source distribution for +Python 3.10 through 3.13. diff --git a/examples/README.md b/examples/README.md index a5d0021..1ec42ec 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,7 +18,7 @@ Available cases: - `rib_grid`: slab plus ridge grid. - `circular_rod_grid`: circular dielectric inclusion rasterized on the grid. - `anisotropic_tensor_grid`: full tensor permittivity grid with off-diagonal terms. -- `angled_bent_grid`: diagonal grid solved through the Rust tensorial angle/bend transform path. +- `angled_bent_grid`: diagonal grid solved through the tensorial angle/bend transform path. Render selected cases with: @@ -35,7 +35,7 @@ uv run --extra dev python examples/soi_hybridization_sweep.py ``` That example sweeps a 220 nm fully etched SOI ridge width and writes the -effective-index and TE-fraction plots to +effective-index, TE-fraction, and field-profile plots to `examples/soi_hybridization_outputs/`. Recreate the Tidy3D modal sources/monitors mode plot: diff --git a/examples/material_grid_demos.py b/examples/material_grid_demos.py index 9df4431..c2fe0c2 100644 --- a/examples/material_grid_demos.py +++ b/examples/material_grid_demos.py @@ -1,3 +1,5 @@ +"""Render several standalone material-grid demonstrations for MicroMode.""" + from __future__ import annotations import argparse @@ -23,6 +25,8 @@ @dataclass(frozen=True) class GridDemo: + """Definition of one material-grid demonstration case.""" + key: str title: str description: str @@ -37,6 +41,7 @@ class GridDemo: def main() -> None: + """Run the example script from parsed command-line options.""" args = parse_args() selected = select_demos(args.cases) args.output_dir.mkdir(parents=True, exist_ok=True) @@ -67,6 +72,7 @@ def main() -> None: def parse_args() -> argparse.Namespace: + """Parse command-line options for the example script.""" parser = argparse.ArgumentParser(description="Run pure Materials MicroMode demos.") parser.add_argument( "--output-dir", @@ -85,6 +91,7 @@ def parse_args() -> argparse.Namespace: def select_demos(case_keys: Sequence[str] | None) -> list[GridDemo]: + """Return the selected demo cases in display order.""" demos = grid_demos() if not case_keys: return demos @@ -97,6 +104,7 @@ def select_demos(case_keys: Sequence[str] | None) -> list[GridDemo]: def grid_demos() -> list[GridDemo]: + """Return all available material-grid demo definitions.""" return [ GridDemo( key="strip_grid", @@ -133,7 +141,7 @@ def grid_demos() -> list[GridDemo]: GridDemo( key="anisotropic_tensor_grid", title="Full Tensor Anisotropic Grid", - description="Diagonal anisotropy plus off-diagonal epsilon terms solved by Rust sparse.", + description="Diagonal anisotropy plus off-diagonal epsilon terms solved by the sparse tensorial path.", make_material_grid=make_anisotropic_tensor_grid, target_neff=2.0, num_modes=1, @@ -142,7 +150,7 @@ def grid_demos() -> list[GridDemo]: GridDemo( key="angled_bent_grid", title="Angled And Bent Grid Solve", - description="A diagonal grid transformed through the Rust tensorial angle/bend path.", + description="A diagonal grid transformed through the tensorial angle/bend path.", make_material_grid=make_strip_grid, target_neff=2.5, num_modes=1, @@ -156,6 +164,7 @@ def grid_demos() -> list[GridDemo]: def make_strip_grid() -> tuple[sm.Materials, np.ndarray]: + """Build a rectangular strip-waveguide demo grid.""" x_edges, y_edges, xx, yy = demo_grid(nx=42, ny=30) eps = np.full(xx.shape, SIO2_EPS, dtype=np.complex128) eps[(np.abs(xx) <= 0.25) & (np.abs(yy) <= 0.11)] = SI_EPS @@ -163,6 +172,7 @@ def make_strip_grid() -> tuple[sm.Materials, np.ndarray]: def make_slot_grid() -> tuple[sm.Materials, np.ndarray]: + """Build a slot-waveguide demo grid.""" x_edges, y_edges, xx, yy = demo_grid(nx=42, ny=30) eps = np.full(xx.shape, SIO2_EPS, dtype=np.complex128) rail = np.abs(yy) <= 0.11 @@ -173,6 +183,7 @@ def make_slot_grid() -> tuple[sm.Materials, np.ndarray]: def make_rib_grid() -> tuple[sm.Materials, np.ndarray]: + """Build a rib-waveguide demo grid.""" x_edges, y_edges, xx, yy = demo_grid(nx=46, ny=30) eps = np.full(xx.shape, SIO2_EPS, dtype=np.complex128) slab = (np.abs(xx) <= 0.72) & (yy >= -0.16) & (yy <= -0.05) @@ -182,6 +193,7 @@ def make_rib_grid() -> tuple[sm.Materials, np.ndarray]: def make_circular_rod_grid() -> tuple[sm.Materials, np.ndarray]: + """Build a circular dielectric rod demo grid.""" x_edges, y_edges, xx, yy = demo_grid(nx=42, ny=34, width=2.2, height=1.8) eps = np.full(xx.shape, AIR_EPS, dtype=np.complex128) eps[xx**2 + yy**2 <= 0.32**2] = 2.6**2 @@ -189,6 +201,7 @@ def make_circular_rod_grid() -> tuple[sm.Materials, np.ndarray]: def make_anisotropic_tensor_grid() -> tuple[sm.Materials, np.ndarray]: + """Build a tensor-material demo grid.""" x_edges, y_edges, xx, yy = demo_grid(nx=28, ny=22, width=2.0, height=1.4) core = (np.abs(xx) <= 0.32) & (np.abs(yy) <= 0.16) eps_xx = np.full(xx.shape, SIO2_EPS, dtype=np.complex128) @@ -220,6 +233,7 @@ def demo_grid( width: float = 2.4, height: float = 1.6, ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Build a rectangular grid with one or more filled boxes.""" x_edges = np.linspace(-width / 2, width / 2, nx + 1) y_edges = np.linspace(-height / 2, height / 2, ny + 1) x = 0.5 * (x_edges[:-1] + x_edges[1:]) @@ -235,6 +249,7 @@ def plot_demo( data: sm.Result, output_path: Path, ) -> None: + """Plot one demo result and save it.""" mode_count = int(data.n_complex.shape[1]) columns = ("eps_xx", "|E|", "Re(Ex)", "Re(Ey)", "Re(Ez)") fig, axes = plt.subplots( @@ -277,6 +292,7 @@ def plot_demo( def component_image( data: sm.Result, component: str, mode_index: int ) -> tuple[tuple[str, str], tuple[np.ndarray, np.ndarray], np.ndarray]: + """Extract one field component image for plotting.""" field = data.field_components[component] image = field.isel(f=0, mode_index=mode_index).squeeze(drop=True) spatial_dims = tuple(dim for dim in ("x", "y", "z") if dim in image.dims and image.sizes[dim] > 1) @@ -291,6 +307,7 @@ def component_image( def electric_magnitude_image( data: sm.Result, mode_index: int ) -> tuple[tuple[str, str], tuple[np.ndarray, np.ndarray], np.ndarray]: + """Compute electric-field magnitude for one mode image.""" dims, coords, ex = component_image(data, "Ex", mode_index) magnitude_squared = np.abs(ex) ** 2 for component in ("Ey", "Ez"): @@ -311,6 +328,7 @@ def draw_image( cmap: str, symmetric: bool, ): + """Draw a field image on an axes object with consistent scaling.""" x, y = coords dx = float(np.median(np.diff(x))) if len(x) > 1 else 1.0 dy = float(np.median(np.diff(y))) if len(y) > 1 else 1.0 @@ -345,6 +363,7 @@ def draw_image( def plot_eps_contours(ax: Axes, coords: tuple[np.ndarray, np.ndarray], eps: np.ndarray) -> None: + """Overlay material-index contours on a field plot.""" values = np.asarray(eps.real, dtype=float) if np.nanmax(values) - np.nanmin(values) < 1e-12: return diff --git a/examples/ridge_waveguide_readme.py b/examples/ridge_waveguide_readme.py index 0d1f39c..f806197 100644 --- a/examples/ridge_waveguide_readme.py +++ b/examples/ridge_waveguide_readme.py @@ -26,6 +26,7 @@ def publication_style() -> dict[str, object]: + """Return matplotlib rcParams for generated example figures.""" return { "font.family": "DejaVu Sans", "font.size": 9, @@ -46,6 +47,7 @@ def publication_style() -> dict[str, object]: def main() -> None: + """Run the example script from parsed command-line options.""" parser = argparse.ArgumentParser(description="Solve and plot a rasterized angled slab waveguide.") parser.add_argument("--step", type=float, default=0.02, help="Grid step in microns.") parser.add_argument("--subpixels", type=int, default=7, help="Subpixel samples per axis for material averaging.") @@ -102,6 +104,7 @@ def ridge_waveguide_materials(step: float = 0.02, subpixels: int = 7) -> tuple[m def centered_edges(*, width: float, step: float) -> np.ndarray: + """Return evenly spaced cell edges centered on zero.""" if step <= 0.0: raise ValueError("step must be positive") cells = round(width / step) @@ -111,6 +114,7 @@ def centered_edges(*, width: float, step: float) -> np.ndarray: def offset_edges(*, lower: float, upper: float, step: float) -> np.ndarray: + """Return evenly spaced cell edges covering an explicit interval.""" if step <= 0.0: raise ValueError("step must be positive") if upper <= lower: @@ -127,6 +131,7 @@ def subpixel_centers( *, subpixels: int, ) -> tuple[np.ndarray, np.ndarray]: + """Return subpixel sample coordinates inside each solver cell.""" if subpixels <= 0: raise ValueError("subpixels must be positive") offsets = (np.arange(subpixels, dtype=float) + 0.5) / subpixels @@ -139,6 +144,7 @@ def subpixel_centers( def plot_index(materials: mm.Materials, eps: np.ndarray, path: Path) -> None: + """Plot the material index profile.""" x_edges = np.asarray(materials.grid.x_edges, dtype=float) y_edges = np.asarray(materials.grid.y_edges, dtype=float) x = 0.5 * (x_edges[:-1] + x_edges[1:]) @@ -168,6 +174,7 @@ def plot_index(materials: mm.Materials, eps: np.ndarray, path: Path) -> None: def plot_modes(materials: mm.Materials, eps: np.ndarray, data: mm.Result, path: Path) -> None: + """Plot representative mode fields.""" components = ("Ex", "Ey", "Hx", "Hy") x_edges = np.asarray(materials.grid.x_edges, dtype=float) y_edges = np.asarray(materials.grid.y_edges, dtype=float) @@ -245,6 +252,7 @@ def plot_readme_figure(materials: mm.Materials, eps: np.ndarray, data: mm.Result def draw_material_outline(ax, x: np.ndarray, y: np.ndarray, eps: np.ndarray, **kwargs) -> None: + """Draw an epsilon contour outlining the waveguide material.""" if "color" in kwargs: kwargs["colors"] = kwargs.pop("color") if "linewidth" in kwargs: @@ -254,17 +262,20 @@ def draw_material_outline(ax, x: np.ndarray, y: np.ndarray, eps: np.ndarray, **k def format_complex(value: complex, *, precision: int) -> str: + """Format a complex number for compact plot annotations.""" sign = "+" if value.imag >= 0.0 else "-" return f"{value.real:.{precision}f}{sign}{abs(value.imag):.{precision}g}i" def normalize_signed(values: np.ndarray) -> np.ndarray: + """Normalize signed field data to unit peak magnitude.""" values = np.asarray(values, dtype=float) scale = max(float(np.nanmax(np.abs(values))), np.finfo(float).eps) return values / scale def save_figure(fig, path: Path) -> None: + """Write a figure to disk and close it.""" fig.savefig(path) fig.savefig(path.with_suffix(".pdf")) fig.savefig(path.with_suffix(".svg")) diff --git a/examples/soi_hybridization_sweep.py b/examples/soi_hybridization_sweep.py index a650785..a7d56e3 100644 --- a/examples/soi_hybridization_sweep.py +++ b/examples/soi_hybridization_sweep.py @@ -1,3 +1,5 @@ +"""Sweep SOI ridge width and visualize mode hybridization.""" + from __future__ import annotations import argparse @@ -23,6 +25,7 @@ def publication_style() -> dict[str, object]: + """Return matplotlib rcParams for generated example figures.""" return { "font.family": "DejaVu Sans", "font.size": 9, @@ -43,6 +46,7 @@ def publication_style() -> dict[str, object]: def main() -> None: + """Run the example script from parsed command-line options.""" args = parse_args() args.output_dir.mkdir(parents=True, exist_ok=True) widths = np.arange(args.width_start, args.width_stop + 0.5 * args.width_step, args.width_step) @@ -74,13 +78,16 @@ def main() -> None: sweep = mm.Sweep(values=widths, results=sorted_results, parameter_name="width_um") summary = write_summary(args.output_dir / "summary.json", sweep, args) plot_sweep(args.output_dir / "hybridization_sweep.png", sweep) + plot_te_fraction(args.output_dir / "hybridization_te_fraction.png", sweep) plot_profiles(args.output_dir / "hybridization_profiles.png", widths, sorted_results, eps_grids) print(f"Wrote {args.output_dir / 'hybridization_sweep.png'}") + print(f"Wrote {args.output_dir / 'hybridization_te_fraction.png'}") print(f"Wrote {args.output_dir / 'hybridization_profiles.png'}") print(f"Wrote {summary}") def parse_args() -> argparse.Namespace: + """Parse command-line options for the example script.""" parser = argparse.ArgumentParser(description="Run a MicroMode SOI mode-hybridization sweep.") parser.add_argument( "--output-dir", @@ -103,6 +110,7 @@ def parse_args() -> argparse.Namespace: def centered_edges(width: float, step: float) -> np.ndarray: + """Return evenly spaced cell edges centered on zero.""" half_cells = int(np.ceil(width / (2 * step))) return np.arange(-half_cells, half_cells + 1, dtype=float) * step @@ -114,6 +122,7 @@ def soi_ridge_materials( y_edges: np.ndarray, film_thickness: float, ) -> tuple[mm.Materials, np.ndarray]: + """Rasterize an SOI ridge for a requested top width.""" x = 0.5 * (x_edges[:-1] + x_edges[1:]) y = 0.5 * (y_edges[:-1] + y_edges[1:]) _xx, yy = np.meshgrid(x, y, indexing="ij") @@ -139,12 +148,14 @@ def rectangle_fill_fraction( y_min: float, y_max: float, ) -> np.ndarray: + """Compute cell fill fractions for a rectangle.""" x_overlap = interval_fill_fraction(x_edges, x_min, x_max) y_overlap = interval_fill_fraction(y_edges, y_min, y_max) return np.outer(x_overlap, y_overlap) def interval_fill_fraction(edges: np.ndarray, lower: float, upper: float) -> np.ndarray: + """Compute one-dimensional interval overlap fractions.""" cell_lower = edges[:-1] cell_upper = edges[1:] overlap = np.clip(np.minimum(cell_upper, upper) - np.maximum(cell_lower, lower), 0.0, None) @@ -153,6 +164,7 @@ def interval_fill_fraction(edges: np.ndarray, lower: float, upper: float) -> np. def write_summary(path: Path, sweep: mm.Sweep, args: argparse.Namespace) -> Path: + """Write a JSON summary of the sweep result.""" payload = { "wavelength_um": WAVELENGTH_UM, "n_si": N_SI, @@ -195,56 +207,61 @@ def sort_result_by_neff(result: mm.Result) -> mm.Result: def plot_sweep(path: Path, sweep: mm.Sweep) -> None: - pol = sweep.pol_fraction + """Plot effective index across the width sweep.""" with plt.rc_context(publication_style()): - fig, axes = plt.subplots( - 1, - 2, - figsize=(7.2, 3.0), - constrained_layout=True, - gridspec_kw={"width_ratios": (1.08, 1.0)}, - ) + fig, ax = plt.subplots(figsize=(4.2, 3.0), constrained_layout=True) colors = [MODE_COLORS[index % len(MODE_COLORS)] for index in range(sweep.num_modes)] plotted_modes = list(range(min(4, sweep.num_modes))) line_width = 2.025 for mode_index in plotted_modes: x_smooth, y_smooth = smooth_line(sweep.values, sweep.n_eff[:, mode_index]) - axes[0].plot( + ax.plot( x_smooth, y_smooth, color=colors[mode_index], linewidth=line_width, label=f"mode {mode_index}", ) - axes[0].set_xlabel("ridge width (um)") - axes[0].set_ylabel("effective index") - axes[0].grid(color="#d9dde3", linewidth=0.55) - axes[0].legend(ncol=2, frameon=False, handlelength=1.8, columnspacing=1.1) + ax.set_xlabel("ridge width (um)") + ax.set_ylabel("effective index") + ax.set_xlim(float(sweep.values[0]), float(sweep.values[-1])) + ax.margins(x=0) + ax.grid(color="#d9dde3", linewidth=0.55) + ax.legend(ncol=2, frameon=False, handlelength=1.8, columnspacing=1.1) + + save_figure(fig, path) + plt.close(fig) + +def plot_te_fraction(path: Path, sweep: mm.Sweep) -> None: + """Plot TE fraction across the width sweep.""" + pol = sweep.pol_fraction + with plt.rc_context(publication_style()): + fig, ax = plt.subplots(figsize=(4.2, 3.0), constrained_layout=True) + colors = [MODE_COLORS[index % len(MODE_COLORS)] for index in range(sweep.num_modes)] + line_width = 2.025 highlighted = [index for index in (0, 1, 2, 3) if index < sweep.num_modes] if not highlighted: highlighted = [0] for mode_index in highlighted: x_smooth, y_smooth = smooth_line(sweep.values, pol["te"][:, mode_index], y_limits=(0.0, 1.0)) - axes[1].plot( + ax.plot( x_smooth, y_smooth, linewidth=line_width, color=colors[mode_index], label=f"mode {mode_index}", ) - axes[1].set_xlabel("ridge width (um)") - axes[1].set_ylabel("TE fraction") - axes[1].set_ylim(-0.04, 1.04) - axes[1].grid(color="#d9dde3", linewidth=0.55) - - for ax in axes: - ax.set_xlim(float(sweep.values[0]), float(sweep.values[-1])) - ax.margins(x=0) + ax.set_xlabel("ridge width (um)") + ax.set_ylabel("TE fraction") + ax.set_xlim(float(sweep.values[0]), float(sweep.values[-1])) + ax.set_ylim(-0.04, 1.04) + ax.margins(x=0) + ax.grid(color="#d9dde3", linewidth=0.55) mode_handles = [Line2D([0], [0], color=colors[index], lw=2.4) for index in highlighted] - axes[1].legend( + ax.legend( mode_handles, [f"mode {index}" for index in highlighted], frameon=False, @@ -292,6 +309,7 @@ def smooth_line( def pchip_slopes(x: np.ndarray, y: np.ndarray) -> np.ndarray: + """Compute monotone cubic Hermite slopes.""" h = np.diff(x) delta = np.diff(y) / h slopes = np.zeros_like(y) @@ -310,6 +328,7 @@ def pchip_slopes(x: np.ndarray, y: np.ndarray) -> np.ndarray: def pchip_endpoint_slope(h0: float, h1: float, delta0: float, delta1: float) -> float: + """Compute a shape-preserving endpoint slope.""" slope = ((2.0 * h0 + h1) * delta0 - h0 * delta1) / (h0 + h1) if np.sign(slope) != np.sign(delta0): return 0.0 @@ -319,6 +338,7 @@ def pchip_endpoint_slope(h0: float, h1: float, delta0: float, delta1: float) -> def evaluate_hermite(x: np.ndarray, y: np.ndarray, slopes: np.ndarray, x_dense: np.ndarray) -> np.ndarray: + """Evaluate cubic Hermite segments on a dense grid.""" interval_indices = np.searchsorted(x, x_dense, side="right") - 1 interval_indices = np.clip(interval_indices, 0, len(x) - 2) x0 = x[interval_indices] @@ -337,6 +357,7 @@ def evaluate_hermite(x: np.ndarray, y: np.ndarray, slopes: np.ndarray, x_dense: def plot_profiles(path: Path, widths: np.ndarray, results: tuple[mm.Result, ...], eps_grids: list[np.ndarray]) -> None: + """Plot selected field profiles from a sweep.""" selected_indices = unique_nearest_indices(widths, [0.5, 1.0, 2.0]) rows = len(selected_indices) columns = ("index", "mode0_abs", "mode0_ex", "mode1_abs", "mode1_ex") @@ -422,6 +443,7 @@ def plot_profiles(path: Path, widths: np.ndarray, results: tuple[mm.Result, ...] def unique_nearest_indices(values: np.ndarray, targets: list[float]) -> list[int]: + """Return unique indices nearest requested target values.""" indices = [] for target in targets: index = int(np.argmin(np.abs(values - target))) @@ -435,6 +457,7 @@ def component_image( component: str, mode_index: int, ) -> tuple[tuple[str, str], tuple[np.ndarray, np.ndarray], np.ndarray]: + """Extract one field component image for plotting.""" image = result.field_components[component].isel(f=0, mode_index=mode_index).squeeze(drop=True) spatial_dims = tuple(dim for dim in ("x", "y", "z") if dim in image.dims and image.sizes[dim] > 1) if len(spatial_dims) != 2: @@ -448,6 +471,7 @@ def electric_magnitude_image( result: mm.Result, mode_index: int, ) -> tuple[tuple[str, str], tuple[np.ndarray, np.ndarray], np.ndarray]: + """Compute electric-field magnitude for one mode image.""" dims, coords, ex = component_image(result, "Ex", mode_index) magnitude_squared = np.abs(ex) ** 2 for component in ("Ey", "Ez"): @@ -470,6 +494,7 @@ def draw_image( value_limits: tuple[float, float] | None = None, interpolation: str = "nearest", ): + """Draw a field image on an axes object with consistent scaling.""" x, y = coords dx = float(np.median(np.diff(x))) if len(x) > 1 else 1.0 dy = float(np.median(np.diff(y))) if len(y) > 1 else 1.0 @@ -495,18 +520,21 @@ def draw_image( def normalize_positive(values: np.ndarray) -> np.ndarray: + """Normalize nonnegative image data to unit peak magnitude.""" values = np.asarray(values, dtype=float) scale = max(float(np.nanmax(np.abs(values))), np.finfo(float).eps) return values / scale def normalize_signed(values: np.ndarray) -> np.ndarray: + """Normalize signed field data to unit peak magnitude.""" values = np.asarray(values, dtype=float) scale = max(float(np.nanmax(np.abs(values))), np.finfo(float).eps) return values / scale def plot_eps_contours(ax, coords: tuple[np.ndarray, np.ndarray], eps: np.ndarray) -> None: + """Overlay material-index contours on a field plot.""" values = np.asarray(eps.real, dtype=float) if np.nanmax(values) - np.nanmin(values) < 1e-12: return @@ -517,6 +545,7 @@ def plot_eps_contours(ax, coords: tuple[np.ndarray, np.ndarray], eps: np.ndarray def save_figure(fig, path: Path) -> None: + """Write a figure to disk and close it.""" fig.savefig(path) fig.savefig(path.with_suffix(".pdf")) fig.savefig(path.with_suffix(".svg")) diff --git a/examples/tidy3d_modal_sources_monitors.py b/examples/tidy3d_modal_sources_monitors.py index 887a773..893a324 100644 --- a/examples/tidy3d_modal_sources_monitors.py +++ b/examples/tidy3d_modal_sources_monitors.py @@ -32,6 +32,7 @@ def main() -> None: + """Run the example script from parsed command-line options.""" args = parse_args() args.output_dir.mkdir(parents=True, exist_ok=True) @@ -52,6 +53,7 @@ def main() -> None: def parse_args() -> argparse.Namespace: + """Parse command-line options for the example script.""" parser = argparse.ArgumentParser(description="Recreate the Tidy3D modal sources/monitors mode plot.") parser.add_argument( "--output-dir", @@ -91,6 +93,7 @@ def tidy3d_waveguide_materials(*, y_step: float, z_step: float) -> tuple[mm.Mate def centered_edges(*, width: float, step: float) -> np.ndarray: + """Return evenly spaced cell edges centered on zero.""" if step <= 0.0: raise ValueError("step must be positive") cells = round(width / step) @@ -100,6 +103,7 @@ def centered_edges(*, width: float, step: float) -> np.ndarray: def plot_tidy3d_mode_fields(materials: mm.Materials, eps: np.ndarray, data: mm.Result, path: Path) -> None: + """Plot Tidy3D-style modal field panels.""" y_edges = np.asarray(materials.grid.x_edges, dtype=float) z_edges = np.asarray(materials.grid.y_edges, dtype=float) y = 0.5 * (y_edges[:-1] + y_edges[1:]) @@ -135,17 +139,20 @@ def plot_tidy3d_mode_fields(materials: mm.Materials, eps: np.ndarray, data: mm.R def field_abs(data: mm.Result, *, component: str, mode_index: int) -> np.ndarray: + """Return the magnitude image for one field component and mode.""" field = data.field_components[component].isel(f=0, mode_index=mode_index).squeeze(drop=True) return np.abs(np.asarray(field.transpose("y", "z").values)) def draw_material_context(ax, y: np.ndarray, z: np.ndarray, eps: np.ndarray) -> None: + """Draw material outlines for the Tidy3D-style example.""" ax.axhline(0.0, color="#111111", linewidth=0.85, alpha=0.35) silicon_level = 0.5 * (N_AIR**2 + N_SI**2) ax.contour(y, z, eps.real.T, levels=[silicon_level], colors="#2f2f2f", linewidths=1.0, alpha=0.75) def publication_style() -> dict[str, object]: + """Return matplotlib rcParams for generated example figures.""" return { "font.family": "DejaVu Sans", "font.size": 10, @@ -161,6 +168,7 @@ def publication_style() -> dict[str, object]: def save_figure(fig, path: Path) -> None: + """Write a figure to disk and close it.""" fig.savefig(path) fig.savefig(path.with_suffix(".pdf")) fig.savefig(path.with_suffix(".svg")) diff --git a/pyproject.toml b/pyproject.toml index a0b9d69..3ae5d42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "micromode" version = "0.1.0a4" -description = "Rust-backed photonics mode solver with a small Python API." +description = "SciPy-based photonics mode solver with a small Python API." readme = "README.md" requires-python = ">=3.10,<3.14" license = "Apache-2.0" @@ -24,7 +24,6 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", - "Programming Language :: Rust", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", @@ -35,6 +34,7 @@ dependencies = [ "h5py>=3.10,<4.0", "matplotlib>=3.8,<4.0", "numpy>=2.2.6,<2.5.0", + "scipy>=1.11,<2.0", "xarray>=2023.08,<2026.5.0", ] @@ -47,7 +47,6 @@ Changelog = "https://github.com/QuentinWach/micromode/blob/main/CHANGELOG.md" [project.optional-dependencies] dev = [ - "maturin>=1.7,<2", "packaging>=24.2", "pkginfo>=1.12.1.2", "pyright>=1.1.390,<2", @@ -60,17 +59,17 @@ dev = [ ] [build-system] -requires = ["maturin>=1.7,<2"] -build-backend = "maturin" +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" [tool.uv] package = true -[tool.maturin] -module-name = "micromode._core" -python-source = "python" -features = ["extension-module"] -auditwheel = "skip" +[tool.setuptools.package-dir] +"" = "python" + +[tool.setuptools.packages.find] +where = ["python"] [tool.pytest.ini_options] addopts = "--assert=plain" diff --git a/python/micromode/__init__.py b/python/micromode/__init__.py index a076754..4a1590a 100644 --- a/python/micromode/__init__.py +++ b/python/micromode/__init__.py @@ -1,11 +1,17 @@ +"""Public package exports for the MicroMode Python API.""" + from __future__ import annotations -from ._rust import C_0, EPSILON_0 +# Re-export the small public API from package root so users do not need to know +# the internal module split. +from .constants import C_0, EPSILON_0 from .models import BoundarySpec, Grid, Materials, PmlSpec, Spec from .raster import solve_grid, solve_modes, solve_slice from .result import Result, overlap from .sweep import Sweep, track_modes_by_overlap +# Keep __all__ explicit so documentation and static analysis show the intended +# public surface. __all__ = [ "C_0", "EPSILON_0", diff --git a/python/micromode/_rust.py b/python/micromode/_rust.py deleted file mode 100644 index f713f71..0000000 --- a/python/micromode/_rust.py +++ /dev/null @@ -1,253 +0,0 @@ -from __future__ import annotations - -import numpy as np - -# Thin Python wrapper around the PyO3 extension. The public API works with -# NumPy arrays and dictionaries; the extension boundary only accepts plain -# Python lists of real/imag pairs, so the helpers below perform that conversion -# in one place. - -C_0 = 2.997_924_58e14 -EPSILON_0 = 8.854187812800384e-18 -SparseSolveResult = tuple[np.ndarray, list[np.ndarray], dict[str, object]] - - -try: - from ._core import ( - solve_diagonal_sparse_py as _solve_diagonal_sparse, - ) - from ._core import ( - solve_tensorial_sparse_py as _solve_tensorial_sparse, - ) -except Exception: # pragma: no cover - exercised when extension is not built locally. - _solve_diagonal_sparse = None - _solve_tensorial_sparse = None - - -def solve_diagonal_sparse( - *, - eps_tensor: np.ndarray, - mu_tensor: np.ndarray, - dlf: tuple[np.ndarray, np.ndarray], - dlb: tuple[np.ndarray, np.ndarray], - num_modes: int, - neff_guess: float, - direction: str, - derivative_scale: float | None = None, - omega: float | None = None, - num_pml: tuple[int, int] = (0, 0), - pml_profile: dict[str, float | int] | None = None, - dmin_pml: tuple[bool, bool] = (True, True), - dmin_pmc: tuple[bool, bool] = (False, False), - krylov_dim: int = 32, - initial_vector: np.ndarray | None = None, -) -> SparseSolveResult: - """Run the Rust sparse diagonal mode solver for a prepared 2D Yee grid.""" - # This is the normal path for diagonal material grids. The returned fields - # are still flattened by mode/component; `raster.py` restores grid axes. - if _solve_diagonal_sparse is None: - raise RuntimeError("Rust extension is not built; sparse diagonal solver is unavailable") - if eps_tensor.shape != mu_tensor.shape or eps_tensor.shape[:2] != (3, 3): - raise ValueError("eps_tensor and mu_tensor must both have shape (3, 3, N)") - nx = len(dlf[0]) - ny = len(dlf[1]) - expected_n = nx * ny - if eps_tensor.shape[-1] != expected_n: - raise ValueError("tensor length must match len(dlf[0]) * len(dlf[1])") - - pml_profile = _default_pml_profile(pml_profile) - ( - n_pairs, - field_pairs, - residuals, - power_norms, - lorentz_norms, - lorentz_orthogonality_error, - backend, - operator_size, - operator_nnz, - ) = _solve_diagonal_sparse( - nx, - ny, - dlf[0].astype(float).tolist(), - dlf[1].astype(float).tolist(), - dlb[0].astype(float).tolist(), - dlb[1].astype(float).tolist(), - dmin_pmc[0], - dmin_pmc[1], - _tensor_payload(eps_tensor), - _tensor_payload(mu_tensor), - num_modes, - neff_guess, - direction, - derivative_scale, - int(num_pml[0]), - int(num_pml[1]), - float(pml_profile["sigma_max"]), - float(pml_profile["kappa_min"]), - float(pml_profile["kappa_max"]), - int(pml_profile["order"]), - bool(dmin_pml[0]), - bool(dmin_pml[1]), - omega, - int(krylov_dim), - None if initial_vector is None else _complex_vector_payload(initial_vector), - ) - n_complex = _pairs_to_complex(n_pairs) - fields = [_pairs_to_complex(component) for component in field_pairs] - return ( - n_complex, - fields, - _solver_info( - residuals, - power_norms, - lorentz_norms, - lorentz_orthogonality_error, - backend, - operator_size, - operator_nnz, - ), - ) - - -def solve_tensorial_sparse( - *, - eps_tensor: np.ndarray, - mu_tensor: np.ndarray, - dlf: tuple[np.ndarray, np.ndarray], - dlb: tuple[np.ndarray, np.ndarray], - num_modes: int, - neff_guess: float, - direction: str, - derivative_scale: float | None = None, - omega: float | None = None, - num_pml: tuple[int, int] = (0, 0), - pml_profile: dict[str, float | int] | None = None, - dmin_pml: tuple[bool, bool] = (True, True), - dmin_pmc: tuple[bool, bool] = (False, False), - krylov_dim: int = 32, - initial_vector: np.ndarray | None = None, -) -> SparseSolveResult: - """Run the Rust sparse full-tensor mode solver for a prepared 2D Yee grid.""" - # Full tensor material grids, angled solves, and bent solves use this path - # because coordinate transforms can introduce off-diagonal eps/mu terms. - if _solve_tensorial_sparse is None: - raise RuntimeError("Rust extension is not built; sparse tensorial solver is unavailable") - if eps_tensor.shape != mu_tensor.shape or eps_tensor.shape[:2] != (3, 3): - raise ValueError("eps_tensor and mu_tensor must both have shape (3, 3, N)") - nx = len(dlf[0]) - ny = len(dlf[1]) - expected_n = nx * ny - if eps_tensor.shape[-1] != expected_n: - raise ValueError("tensor length must match len(dlf[0]) * len(dlf[1])") - - pml_profile = _default_pml_profile(pml_profile) - ( - n_pairs, - field_pairs, - residuals, - power_norms, - lorentz_norms, - lorentz_orthogonality_error, - backend, - operator_size, - operator_nnz, - ) = _solve_tensorial_sparse( - nx, - ny, - dlf[0].astype(float).tolist(), - dlf[1].astype(float).tolist(), - dlb[0].astype(float).tolist(), - dlb[1].astype(float).tolist(), - dmin_pmc[0], - dmin_pmc[1], - _tensor_payload(eps_tensor), - _tensor_payload(mu_tensor), - num_modes, - neff_guess, - direction, - derivative_scale, - int(num_pml[0]), - int(num_pml[1]), - float(pml_profile["sigma_max"]), - float(pml_profile["kappa_min"]), - float(pml_profile["kappa_max"]), - int(pml_profile["order"]), - bool(dmin_pml[0]), - bool(dmin_pml[1]), - omega, - int(krylov_dim), - None if initial_vector is None else _complex_vector_payload(initial_vector), - ) - n_complex = _pairs_to_complex(n_pairs) - fields = [_pairs_to_complex(component) for component in field_pairs] - return ( - n_complex, - fields, - _solver_info( - residuals, - power_norms, - lorentz_norms, - lorentz_orthogonality_error, - backend, - operator_size, - operator_nnz, - ), - ) - - -def _default_pml_profile(profile: dict[str, float | int] | None) -> dict[str, float | int]: - defaults: dict[str, float | int] = { - "sigma_max": 2.0, - "kappa_min": 1.0, - "kappa_max": 3.0, - "order": 3, - } - if profile is not None: - defaults.update(profile) - return defaults - - -def _solver_info( - residuals, - power_norms, - lorentz_norms, - lorentz_orthogonality_error, - backend, - operator_size, - operator_nnz, -) -> dict[str, object]: - # Keep raw backend diagnostics close to the extension boundary. Higher-level - # context such as grid shape, PML, and normalization labels is added in - # `raster.py`. - return { - "backend": str(backend), - "operator_size": int(operator_size), - "operator_nnz": int(operator_nnz), - "residuals": np.asarray(residuals, dtype=float), - "power_norms": np.asarray(power_norms, dtype=float), - "lorentz_norms": _pairs_to_complex(lorentz_norms), - "lorentz_orthogonality_error": float(lorentz_orthogonality_error), - } - - -def _tensor_payload(tensor: np.ndarray) -> list[list[tuple[float, float]]]: - # PyO3 can accept nested Python lists reliably across build targets. The - # tensor is flattened as nine component vectors: xx, xy, xz, yx, ... zz. - tensor = np.asarray(tensor) - return [ - [(complex(value).real, complex(value).imag) for value in tensor[row, col, :]] - for row in range(3) - for col in range(3) - ] - - -def _complex_vector_payload(vector: np.ndarray) -> list[tuple[float, float]]: - # Initial vectors are complex NumPy arrays in Python but real/imag tuples at - # the Rust boundary. - return [(complex(value).real, complex(value).imag) for value in np.asarray(vector).reshape(-1)] - - -def _pairs_to_complex(values) -> np.ndarray: - array = np.asarray(values, dtype=float) - return array[..., 0] + 1j * array[..., 1] diff --git a/python/micromode/constants.py b/python/micromode/constants.py new file mode 100644 index 0000000..1ff7e54 --- /dev/null +++ b/python/micromode/constants.py @@ -0,0 +1,8 @@ +"""Physical constants used by MicroMode.""" + +# Speed of light in microns per second, matching the coordinate unit used by the +# raster API. +C_0 = 2.997_924_58e14 + +# Vacuum permittivity in solver units. +EPSILON_0 = 8.854_187_812_800_384e-18 diff --git a/python/micromode/models.py b/python/micromode/models.py index 135f8d0..818d871 100644 --- a/python/micromode/models.py +++ b/python/micromode/models.py @@ -4,7 +4,8 @@ from collections.abc import Sequence from dataclasses import dataclass -from typing import Literal +from operator import index +from typing import Literal, SupportsIndex, cast import numpy as np @@ -13,6 +14,8 @@ BoundaryCondition = Literal["pec", "pmc"] +# These dataclasses are intentionally lightweight: they validate user-facing +# arrays and options before the numerical code receives them. @dataclass(frozen=True) class PmlSpec: """Perfectly matched layer settings for mode solves.""" @@ -24,25 +27,36 @@ class PmlSpec: order: int = 3 def __post_init__(self) -> None: - if len(self.num_cells) != 2 or any(int(value) < 0 for value in self.num_cells): + """Normalize constructor inputs and enforce model invariants.""" + # First normalize the two edge counts because they control PML indexing. + if len(self.num_cells) != 2: raise ValueError("num_cells must contain two non-negative integers") - object.__setattr__(self, "num_cells", (int(self.num_cells[0]), int(self.num_cells[1]))) + num_cells = ( + _coerce_integral("num_cells", self.num_cells[0], minimum=0), + _coerce_integral("num_cells", self.num_cells[1], minimum=0), + ) + object.__setattr__(self, "num_cells", num_cells) + + # Stretch parameters must be positive scalars; zero would remove the + # damping profile or create invalid divisions in the PML factors. for name in ("sigma_max", "kappa_min", "kappa_max"): value = float(getattr(self, name)) if not np.isfinite(value) or value <= 0.0: raise ValueError(f"{name} must be finite and positive") object.__setattr__(self, name, value) + + # The outer PML stretch should never be weaker than the inner stretch. if self.kappa_max < self.kappa_min: raise ValueError("kappa_max must be greater than or equal to kappa_min") - if int(self.order) <= 0: - raise ValueError("order must be positive") - object.__setattr__(self, "order", int(self.order)) + object.__setattr__(self, "order", _coerce_integral("order", self.order, minimum=1)) @classmethod def from_num_cells(cls, num_cells: tuple[int, int]) -> PmlSpec: + """Build a PML specification from x/y cell counts.""" return cls(num_cells=num_cells) def as_dict(self) -> dict[str, float | int | tuple[int, int]]: + """Return a JSON-friendly representation of the specification.""" return { "num_cells": self.num_cells, "sigma_max": self.sigma_max, @@ -52,6 +66,7 @@ def as_dict(self) -> dict[str, float | int | tuple[int, int]]: } def profile_dict(self) -> dict[str, float | int]: + """Return only the scalar stretch-profile settings used by the solver.""" return { "sigma_max": self.sigma_max, "kappa_min": self.kappa_min, @@ -67,9 +82,14 @@ class BoundarySpec: low: tuple[BoundaryCondition, BoundaryCondition] = ("pec", "pec") def __post_init__(self) -> None: + """Normalize constructor inputs and enforce model invariants.""" + # Keep only the low-edge symmetry flags; high edges are handled by the + # open-domain derivative/PML construction. if len(self.low) != 2: raise ValueError("low must contain two boundary conditions") normalized = tuple(str(value).lower() for value in self.low) + + # Reject misspellings early so they do not silently become PEC walls. unknown = set(normalized).difference({"pec", "pmc"}) if unknown: raise ValueError("boundary conditions must be 'pec' or 'pmc'") @@ -77,13 +97,16 @@ def __post_init__(self) -> None: @property def dmin_pmc(self) -> tuple[bool, bool]: + """Return whether each low-edge boundary uses magnetic symmetry.""" return self.low[0] == "pmc", self.low[1] == "pmc" @property def dmin_pml(self) -> tuple[bool, bool]: + """Return whether each low-edge boundary is open to PML stretching.""" return self.low[0] == "pec", self.low[1] == "pec" def as_dict(self) -> dict[str, tuple[str, str]]: + """Return a JSON-friendly representation of the specification.""" return {"low": self.low} @@ -102,12 +125,21 @@ class Grid: normal_coordinate: float = 0.0 def __post_init__(self) -> None: + """Normalize constructor inputs and enforce model invariants.""" + # Store immutable float tuples so downstream validation and metadata + # serialization see one consistent representation. x_edges = tuple(float(value) for value in self.x_edges) y_edges = tuple(float(value) for value in self.y_edges) object.__setattr__(self, "x_edges", x_edges) object.__setattr__(self, "y_edges", y_edges) + + # The normal axis determines how local solver fields map back to global + # x/y/z components, so it must be one of the Cartesian axes. if self.normal_axis not in {0, 1, 2}: raise ValueError("normal_axis must be 0, 1, or 2") + + # Edge arrays define cell widths; non-monotonic values would produce + # negative or zero finite-difference spacings. for name, values in {"x_edges": x_edges, "y_edges": y_edges}.items(): if len(values) < 2: raise ValueError(f"{name} must contain at least two values") @@ -117,6 +149,7 @@ def __post_init__(self) -> None: @property def shape(self) -> tuple[int, int]: + """Return the number of cells along the two mode-plane axes.""" return len(self.x_edges) - 1, len(self.y_edges) - 1 @@ -134,9 +167,14 @@ class Materials: mu_tensor: np.ndarray | None = None def __post_init__(self) -> None: + """Normalize constructor inputs and enforce model invariants.""" + # Epsilon is required and must already be sampled on the solver cells. eps_tensor = np.asarray(self.eps_tensor, dtype=np.complex128) if eps_tensor.shape != (3, 3, *self.grid.shape): raise ValueError("eps_tensor must have shape (3, 3, nx, ny) matching the grid") + + # Permeability defaults to identity so non-magnetic material grids stay + # concise at call sites while the solver always receives full tensors. if self.mu_tensor is None: mu_tensor = np.zeros_like(eps_tensor) for axis in range(3): @@ -145,6 +183,9 @@ def __post_init__(self) -> None: mu_tensor = np.asarray(self.mu_tensor, dtype=np.complex128) if mu_tensor.shape != eps_tensor.shape: raise ValueError("mu_tensor must have the same shape as eps_tensor") + + # NaNs and infinities poison sparse assembly, so reject them at the data + # model boundary instead of deep inside ARPACK. if not np.all(np.isfinite(eps_tensor)) or not np.all(np.isfinite(mu_tensor)): raise ValueError("material tensors must contain finite values") object.__setattr__(self, "eps_tensor", eps_tensor) @@ -165,12 +206,17 @@ def from_diagonal( normal_axis: Literal[0, 1, 2] = 2, normal_coordinate: float = 0.0, ) -> Materials: + """Build a material grid from scalar or diagonal tensor components.""" + # Build and validate grid metadata before interpreting any component + # arrays, because the grid shape drives every component check. grid = Grid( tuple(float(value) for value in x_edges), tuple(float(value) for value in y_edges), normal_axis=normal_axis, normal_coordinate=normal_coordinate, ) + + # Missing yy/zz components inherit xx, matching isotropic scalar input. eps_diag = _stack_diagonal_components("eps", grid.shape, eps_xx, eps_yy, eps_zz) mu_diag = _stack_diagonal_components( "mu", @@ -179,6 +225,9 @@ def from_diagonal( np.ones(grid.shape, dtype=np.complex128) if mu_yy is None else mu_yy, np.ones(grid.shape, dtype=np.complex128) if mu_zz is None else mu_zz, ) + + # Store the result in the same full-tensor layout used by tensorial + # inputs; diagonal detection later chooses the faster solver path. return cls( grid=grid, eps_tensor=_diagonal_to_full_tensor(eps_diag), mu_tensor=_diagonal_to_full_tensor(mu_diag) ) @@ -210,12 +259,18 @@ def from_components( normal_axis: Literal[0, 1, 2] = 2, normal_coordinate: float = 0.0, ) -> Materials: + """Build a material grid from diagonal and off-diagonal tensor components.""" + # Full component construction shares the same grid validation as the + # diagonal constructor, then overlays optional off-diagonal entries. grid = Grid( tuple(float(value) for value in x_edges), tuple(float(value) for value in y_edges), normal_axis=normal_axis, normal_coordinate=normal_coordinate, ) + + # Start with diagonal tensors so scalar and anisotropic grids follow one + # predictable path before optional coupling components are assigned. eps_diag = _stack_diagonal_components("eps", grid.shape, eps_xx, eps_yy, eps_zz) eps_tensor = _diagonal_to_full_tensor(eps_diag) _assign_tensor_offdiagonal( @@ -229,6 +284,9 @@ def from_components( zx=eps_zx, zy=eps_zy, ) + + # Magnetic coupling components are optional; absent mu entries represent + # identity permeability on the diagonal and zero off-diagonal coupling. mu_diag = _stack_diagonal_components( "mu", grid.shape, @@ -248,6 +306,9 @@ def from_components( zx=mu_zx, zy=mu_zy, ) + + # The dataclass constructor performs the final finite-value and shape + # validation on the assembled full tensors. return cls(grid=grid, eps_tensor=eps_tensor, mu_tensor=mu_tensor) @classmethod @@ -290,6 +351,9 @@ def from_slice( axis_index = _normalize_slice_axis(axis) edge_values = tuple(float(value) for value in coord_edges) + + # A slice is still represented as a 2D mode plane, so one axis gets the + # user coordinates and the other gets a single finite-width cell. if len(edge_values) < 2: raise ValueError("coord_edges must contain at least two values") if invariant_width <= 0.0 or not np.isfinite(invariant_width): @@ -303,16 +367,25 @@ def from_slice( cell_count = len(edge_values) - 1 def expand(label: str, values: np.ndarray | None) -> np.ndarray | None: + """Expand a one-dimensional component onto the padded mode-plane grid.""" + # Optional components stay absent so from_components can apply its + # diagonal/permeability defaults in one place. if values is None: return None array = np.asarray(values, dtype=np.complex128) if array.shape != (cell_count,): raise ValueError(f"{label} must have shape {(cell_count,)} for a one-dimensional slice") + + # Insert the singleton invariant axis on the side implied by the + # requested varying axis. return array[:, None] if axis_index == 0 else array[None, :] expanded_eps_xx = expand("eps_xx", eps_xx) if expanded_eps_xx is None: raise ValueError("eps_xx is required") + + # Delegate to the full component constructor so slice and grid inputs + # share tensor assembly and validation behavior. return cls.from_components( x_edges=x_edges, y_edges=y_edges, @@ -366,6 +439,9 @@ def from_subpixel_diagonal( x_edge_values = tuple(float(value) for value in x_edges) y_edge_values = tuple(float(value) for value in y_edges) shape = (len(x_edge_values) - 1, len(y_edge_values) - 1) + + # Average each supplied high-resolution component independently before + # reusing the ordinary diagonal tensor constructor. averaged = { "eps_xx": cls.average_subpixels(eps_xx, shape=shape, subpixel_shape=subpixel_shape, method=average), "eps_yy": None @@ -410,11 +486,17 @@ def average_subpixels( nx, ny = (int(shape[0]), int(shape[1])) sx, sy = (int(subpixel_shape[0]), int(subpixel_shape[1])) + + # Validate the target grouping before reshaping so shape errors are + # reported in terms of solver cells and subpixel samples. if nx <= 0 or ny <= 0: raise ValueError("shape must contain positive cell counts") if sx <= 0 or sy <= 0: raise ValueError("subpixel_shape must contain positive sample counts") array = np.asarray(values, dtype=np.complex128) + + # Accept either a dense raster or an already grouped raster; both become + # (nx, ny, sx, sy) before the selected averaging rule is applied. if array.shape == (nx * sx, ny * sy): grouped = array.reshape(nx, sx, ny, sy).transpose(0, 2, 1, 3) elif array.shape == (nx, ny, sx, sy): @@ -422,6 +504,8 @@ def average_subpixels( else: raise ValueError(f"subpixel values must have shape {(nx * sx, ny * sy)} or {(nx, ny, sx, sy)}") + # Choose the averaging rule explicitly so interface-sensitive callers can + # request harmonic/geometric behavior without changing solver code. if method == "arithmetic": return grouped.mean(axis=(2, 3)) if method == "harmonic": @@ -440,10 +524,14 @@ def average_subpixels( @property def shape(self) -> tuple[int, int]: + """Return the number of cells along the two mode-plane axes.""" return self.grid.shape @property def is_diagonal(self) -> bool: + """Return whether epsilon and mu contain only diagonal components.""" + # A boolean mask lets the check stay independent of grid shape and + # catches off-diagonal entries across every solver cell. off_diagonal = np.ones((3, 3), dtype=bool) np.fill_diagonal(off_diagonal, False) mu_tensor = self._resolved_mu_tensor() @@ -452,17 +540,21 @@ def is_diagonal(self) -> bool: ) def flat_eps_tensor(self) -> np.ndarray: + """Return epsilon as flattened per-cell 3x3 tensors.""" return self.eps_tensor.reshape(3, 3, -1) def flat_mu_tensor(self) -> np.ndarray: + """Return mu as flattened per-cell 3x3 tensors.""" return self._resolved_mu_tensor().reshape(3, 3, -1) def _resolved_mu_tensor(self) -> np.ndarray: + """Return the initialized permeability tensor.""" if self.mu_tensor is None: raise RuntimeError("mu_tensor was not initialized") return self.mu_tensor def diagonal_eps(self) -> np.ndarray: + """Return the three diagonal epsilon components as a stacked array.""" return np.stack([self.eps_tensor[axis, axis] for axis in range(3)], axis=0) @@ -473,17 +565,43 @@ def _stack_diagonal_components( yy: np.ndarray | None, zz: np.ndarray | None, ) -> np.ndarray: + """Validate and stack x/y/z diagonal tensor components.""" + # xx is the required scalar/diagonal component; yy and zz inherit it unless + # anisotropic values are provided. xx_array = np.asarray(xx, dtype=np.complex128) if xx_array.shape != shape: raise ValueError(f"{label}_xx must have shape {shape}") yy_array = xx_array if yy is None else np.asarray(yy, dtype=np.complex128) zz_array = xx_array if zz is None else np.asarray(zz, dtype=np.complex128) + + # All diagonal components must be cell-centered arrays matching the grid. if yy_array.shape != shape or zz_array.shape != shape: raise ValueError(f"{label}_xx, {label}_yy, and {label}_zz must have shape {shape}") return np.stack([xx_array, yy_array, zz_array], axis=0) +def _coerce_integral(name: str, value: object, *, minimum: int) -> int: + """Coerce index-like values while rejecting floats and booleans.""" + # bool implements the integer protocol, but accepting it would make option + # typos like num_cells=(True, False) look valid. + if isinstance(value, (bool, np.bool_)): + raise ValueError(f"{name} must contain integers") + try: + integer = index(cast(SupportsIndex, value)) + except TypeError as exc: + raise ValueError(f"{name} must contain integers") from exc + + # Keep the minimum check here so every PML/order caller reports consistent + # validation messages. + if integer < minimum: + if minimum == 0: + raise ValueError(f"{name} must contain non-negative integers") + raise ValueError(f"{name} must be positive") + return int(integer) + + def _normalize_slice_axis(axis: SliceAxis) -> int: + """Map slice-axis aliases onto a zero-based axis index.""" if axis in {"x", 0}: return 0 if axis in {"y", 1}: @@ -492,6 +610,9 @@ def _normalize_slice_axis(axis: SliceAxis) -> int: def _diagonal_to_full_tensor(diagonal: np.ndarray) -> np.ndarray: + """Expand diagonal components into a full 3x3 tensor grid.""" + # The solver consumes a full tensor layout even for diagonal materials; all + # off-diagonal entries remain zero. tensor = np.zeros((3, 3, *diagonal.shape[1:]), dtype=np.complex128) for axis in range(3): tensor[axis, axis, :, :] = diagonal[axis] @@ -510,6 +631,9 @@ def _assign_tensor_offdiagonal( zx: np.ndarray | None, zy: np.ndarray | None, ) -> None: + """Validate and assign optional off-diagonal tensor components.""" + # Iterate through physical tensor suffixes instead of open-coding each + # assignment, which keeps validation messages and row/column mapping aligned. for (row, col), suffix, values in [ ((0, 1), "xy", xy), ((0, 2), "xz", xz), @@ -521,6 +645,9 @@ def _assign_tensor_offdiagonal( if values is None: continue array = np.asarray(values, dtype=np.complex128) + + # Off-diagonal components are cell-centered just like the diagonal + # components; no broadcasting is allowed because that can hide mistakes. if array.shape != shape: raise ValueError(f"{label}_{suffix} must have shape {shape}") tensor[row, col, :, :] = array @@ -528,7 +655,7 @@ def _assign_tensor_offdiagonal( @dataclass(frozen=True) class Spec: - """Mode solver options for Rust-backed grid solves.""" + """Mode solver options for grid solves.""" num_modes: int = 1 target_neff: float | None = None @@ -540,10 +667,15 @@ class Spec: bend_axis: Literal[0, 1] | None = None def __post_init__(self) -> None: + """Normalize constructor inputs and enforce model invariants.""" + # Validate simple scalar controls before expanding nested model objects. if self.num_modes <= 0: raise ValueError("num_modes must be positive") if self.target_neff is not None and self.target_neff <= 0: raise ValueError("target_neff must be positive") + + # Accept compact tuple inputs at the API boundary, but store canonical + # model objects so solve_modes can unpack a Spec without extra branches. pml = self.pml pml = PmlSpec() if pml is None else pml if not isinstance(pml, PmlSpec): @@ -553,6 +685,9 @@ def __post_init__(self) -> None: if not isinstance(boundary, BoundarySpec): boundary = BoundarySpec(low=boundary) object.__setattr__(self, "boundary", boundary) + + # Bend transforms need both a radius and an axis because the Jacobian + # depends on which transverse coordinate measures curvature. if self.bend_radius is not None and np.isclose(self.bend_radius, 0.0): raise ValueError("bend_radius magnitude must be larger than 0") if self.bend_radius is not None and self.bend_axis is None: @@ -562,12 +697,15 @@ def __post_init__(self) -> None: @property def has_angle(self) -> bool: + """Return whether an angular coordinate transform is requested.""" return abs(float(self.angle_theta)) > 0.0 @property def has_bend(self) -> bool: + """Return whether a bend transform is requested.""" return self.bend_radius is not None @property def has_transform(self) -> bool: + """Return whether any coordinate transform is requested.""" return self.has_angle or self.has_bend diff --git a/python/micromode/raster.py b/python/micromode/raster.py index deb818d..446fa49 100644 --- a/python/micromode/raster.py +++ b/python/micromode/raster.py @@ -8,13 +8,16 @@ import numpy as np import xarray as xr -from ._rust import C_0, solve_diagonal_sparse, solve_tensorial_sparse +from .constants import C_0 from .models import BoundaryCondition, BoundarySpec, Materials, PmlSpec, SliceAxis, Spec from .result import Result +from .scipy_reference import solve_diagonal_scipy_reference, solve_tensorial_scipy_reference _COMPONENTS = ("Ex", "Ey", "Ez", "Hx", "Hy", "Hz") +# Public entry points keep geometry handling out of the solver core: callers +# supply already-rasterized material arrays and grid coordinates. def solve_grid( *, eps_xx: np.ndarray, @@ -84,6 +87,9 @@ def solve_grid( normal_axis=normal_axis, normal_coordinate=normal_coordinate, ) + + # Once the component arrays have been normalized into Materials, the full + # solve path is identical to solve_modes. return solve_modes( material_grid=material_grid, freqs=freqs, @@ -148,10 +154,12 @@ def solve_slice( This is the convenience API for 2D FDTD simulations. The supplied material arrays vary along one mode-plane axis and MicroMode inserts a single - invariant cell along the other axis before using the same Rust sparse solve - path as ``solve_modes``. + invariant cell along the other axis before using the same sparse solve path + as ``solve_modes``. """ + # A one-dimensional slice is converted into a thin 2D Materials object so it + # can reuse the same validation, solver dispatch, and result wrapping. material_grid = Materials.from_slice( eps_xx=eps_xx, eps_yy=eps_yy, @@ -178,6 +186,8 @@ def solve_slice( normal_axis=normal_axis, normal_coordinate=normal_coordinate, ) + + # Delegate to solve_modes after the slice-specific padding is complete. return solve_modes( material_grid=material_grid, freqs=freqs, @@ -218,18 +228,22 @@ def solve_modes( """Solve modes for an already-rasterized material tensor grid. This is the preferred Beamz integration point. Beamz owns geometry and - material rasterization; MicroMode owns the sparse mode solve and field + material rasterization; MicroMode owns the sparse SciPy mode solve and field reconstruction on the supplied grid. """ - # Main Python-to-Rust orchestration layer. It validates user-facing grid - # objects, solves one frequency at a time, then wraps flattened Rust outputs - # back into coordinate-aware xarray arrays. + # Main solver orchestration layer. It validates user-facing grid objects, + # solves one frequency at a time, then wraps flattened solver outputs into + # coordinate-aware xarray arrays. if not isinstance(material_grid, Materials): raise TypeError("material_grid must be a Materials") + + # Validate edge arrays against the tensor shape and resolve the frequency + # input before any expensive sparse work starts. shape = material_grid.shape x_edges_arr = _validate_edges("x_edges", material_grid.grid.x_edges, shape[0]) y_edges_arr = _validate_edges("y_edges", material_grid.grid.y_edges, shape[1]) solve_freqs = _resolve_freqs(freqs=freqs, wavelength=wavelength) + if spec is not None: # Spec is a convenience bundle. Once unpacked, the rest of the function # follows exactly the same path as explicit keyword arguments. @@ -241,6 +255,8 @@ def solve_modes( angle_phi = spec.angle_phi bend_radius = spec.bend_radius bend_axis = 0 if spec.bend_axis is None else spec.bend_axis + + # Normalize scalar options and compact model objects at the API boundary. if num_modes <= 0: raise ValueError("num_modes must be positive") if direction not in {"+", "-"}: @@ -251,6 +267,9 @@ def solve_modes( raise ValueError("bend_radius magnitude must be larger than 0") if bend_axis not in {0, 1}: raise ValueError("bend_axis must be 0 or 1") + + # Component filtering happens after solving so field reconstruction still has + # every component needed for normalization and coordinate conversion. requested_components = tuple(components or _COMPONENTS) unknown = set(requested_components).difference(_COMPONENTS) if unknown: @@ -262,6 +281,8 @@ def solve_modes( solver_runs = [] fields_by_component: dict[str, list[np.ndarray]] = {component: [] for component in requested_components} for freq in solve_freqs: + # The sparse solver is frequency-local; multi-frequency solves are a + # deterministic loop plus a final stack into xarray dimensions. n_complex, fields, solver_info = _solve_one_frequency( x_edges=x_edges_arr, y_edges=y_edges_arr, @@ -278,9 +299,9 @@ def solve_modes( bend_axis=int(bend_axis), material_grid=material_grid, ) - # Rust solves in local coordinates where local z is the propagation - # normal. Convert field labels back to the global x/y/z axes requested by - # the material grid before exposing them. + # The solver uses local coordinates where local z is the propagation + # normal. Convert field labels back to the global x/y/z axes requested + # by the material grid before exposing them. fields = _local_fields_to_global(fields, normal_axis=material_grid.grid.normal_axis) n_rows.append(n_complex) solver_runs.append(solver_info) @@ -288,6 +309,7 @@ def solve_modes( if component in fields_by_component: fields_by_component[component].append(values) + # Convert accumulated lists into the public Result layout only once. n_values = np.asarray(n_rows, dtype=np.complex128) field_components = _field_data_arrays( fields_by_component, @@ -328,10 +350,14 @@ def _solve_one_frequency( bend_radius: float | None, bend_axis: int, ) -> tuple[np.ndarray, dict[str, np.ndarray], dict[str, object]]: + """Select the sparse formulation and solve a single frequency.""" # Forward and backward spacings represent the local Yee grid. The derivative # builders need both because E and H components are staggered. dlf = (np.diff(x_edges), np.diff(y_edges)) dlb = (_dual_steps(dlf[0]), _dual_steps(dlf[1])) + + # Flatten tensors to the layout expected by scipy_reference while preserving + # the original grid metadata for result coordinates. eps_tensor = material_grid.flat_eps_tensor() mu_tensor = material_grid.flat_mu_tensor() if target_neff is None: @@ -339,6 +365,9 @@ def _solve_one_frequency( # explicitly when hunting for modes near another branch. target_neff = float(np.sqrt(np.max(np.abs(eps_tensor)))) target_neff = _shift_target_neff(float(target_neff)) + + # Coordinate transforms can introduce off-diagonal coupling, so diagonal + # dispatch must be decided after any transform is applied. has_transform = abs(angle_theta) > 0.0 or bend_radius is not None is_diagonal = material_grid.is_diagonal if has_transform: @@ -356,7 +385,7 @@ def _solve_one_frequency( ) if not (_is_diagonal_tensor(eps_tensor) and _is_diagonal_tensor(mu_tensor)): # Off-diagonal components require the tensorial first-order operator. - return _solve_one_frequency_rust_tensorial_sparse( + return _solve_one_frequency_scipy_tensorial( eps_tensor=eps_tensor, mu_tensor=mu_tensor, dlf=dlf, @@ -371,7 +400,7 @@ def _solve_one_frequency( ) # If the transformed tensors remain diagonal, keep the faster diagonal # sparse formulation. - return _solve_one_frequency_rust_sparse( + return _solve_one_frequency_scipy( eps_tensor=eps_tensor, mu_tensor=mu_tensor, dlf=dlf, @@ -386,7 +415,7 @@ def _solve_one_frequency( ) if not is_diagonal: # User supplied a full tensor grid with no coordinate transform. - return _solve_one_frequency_rust_tensorial_sparse( + return _solve_one_frequency_scipy_tensorial( eps_tensor=eps_tensor, mu_tensor=mu_tensor, dlf=dlf, @@ -399,8 +428,8 @@ def _solve_one_frequency( krylov_dim=krylov_dim, boundary_spec=boundary_spec, ) - # Ordinary scalar/diagonal grids use the production diagonal sparse backend. - return _solve_one_frequency_rust_sparse( + # Ordinary scalar/diagonal grids use the diagonal sparse formulation. + return _solve_one_frequency_scipy( eps_tensor=eps_tensor, mu_tensor=mu_tensor, dlf=dlf, @@ -415,7 +444,7 @@ def _solve_one_frequency( ) -def _solve_one_frequency_rust_sparse( +def _solve_one_frequency_scipy( *, eps_tensor: np.ndarray, mu_tensor: np.ndarray, @@ -429,10 +458,15 @@ def _solve_one_frequency_rust_sparse( krylov_dim: int | None, boundary_spec: BoundarySpec, ) -> tuple[np.ndarray, dict[str, np.ndarray], dict[str, object]]: + """Run the diagonal SciPy solver and reshape its fields.""" + # The diagonal solver has two transverse unknowns per cell: Ex and Ey. nx = len(dlf[0]) ny = len(dlf[1]) actual_krylov_dim = 32 if krylov_dim is None else int(krylov_dim) - n_complex, fields, solver_info = solve_diagonal_sparse( + + # Convert frequency into the nondimensional derivative scale and angular + # frequency expected by the sparse reference implementation. + n_complex, fields, solver_info = solve_diagonal_scipy_reference( eps_tensor=eps_tensor, mu_tensor=mu_tensor, dlf=dlf, @@ -449,19 +483,22 @@ def _solve_one_frequency_rust_sparse( krylov_dim=actual_krylov_dim, initial_vector=_default_initial_vector(2 * nx * ny, shape=(nx, ny)), ) + + # Solver fields are flattened by component; reshape them back to grid + # tensors and attach dispatch metadata for diagnostics. return ( n_complex, _fields_to_grid(fields, (nx, ny)), _solver_info_with_context( solver_info, - backend_kind="diagonal_sparse", + backend_kind="diagonal_scipy_reference", shape=(nx, ny), krylov_dim=actual_krylov_dim, ), ) -def _solve_one_frequency_rust_tensorial_sparse( +def _solve_one_frequency_scipy_tensorial( *, eps_tensor: np.ndarray, mu_tensor: np.ndarray, @@ -475,10 +512,16 @@ def _solve_one_frequency_rust_tensorial_sparse( krylov_dim: int | None, boundary_spec: BoundarySpec, ) -> tuple[np.ndarray, dict[str, np.ndarray], dict[str, object]]: + """Run the tensorial SciPy solver and reshape its fields.""" + # The tensorial solver keeps four transverse unknowns per cell: + # Ex, Ey, Hx, and Hy. nx = len(dlf[0]) ny = len(dlf[1]) actual_krylov_dim = 32 if krylov_dim is None else int(krylov_dim) - n_complex, fields, solver_info = solve_tensorial_sparse( + + # Full tensor coupling uses the first-order operator and solves directly for + # effective index rather than n_eff squared. + n_complex, fields, solver_info = solve_tensorial_scipy_reference( eps_tensor=eps_tensor, mu_tensor=mu_tensor, dlf=dlf, @@ -495,12 +538,15 @@ def _solve_one_frequency_rust_tensorial_sparse( krylov_dim=actual_krylov_dim, initial_vector=_default_initial_vector(4 * nx * ny, shape=(nx, ny)), ) + + # Keep return structure identical to the diagonal path so solve_modes does + # not need backend-specific field handling. return ( n_complex, _fields_to_grid(fields, (nx, ny)), _solver_info_with_context( solver_info, - backend_kind="tensorial_sparse", + backend_kind="tensorial_scipy_reference", shape=(nx, ny), krylov_dim=actual_krylov_dim, ), @@ -518,17 +564,24 @@ def _transformed_material_tensors( bend_radius: float | None, bend_axis: int, ) -> tuple[np.ndarray, np.ndarray]: + """Apply angle and bend coordinate transforms to material tensors.""" # Transformation-optics material update: # eps' = J eps J.T / det(J) # mu' = J mu J.T / det(J) # Angle and bend solves are handled by changing material tensors, then - # passing the resulting grid to the same Rust tensorial operator. + # passing the resulting grid to the same tensorial operator family. if eps.shape != mu.shape or eps.shape[:2] != (3, 3): raise ValueError("eps and mu tensors must both have shape (3, 3, nx, ny)") + + # Work in flattened cell order because the sparse solvers consume per-cell + # 3x3 material matrices. nx, ny = eps.shape[2:] n = nx * ny eps_tensor = np.zeros((3, 3, n), dtype=np.complex128) mu_tensor = np.zeros((3, 3, n), dtype=np.complex128) + + # Cell centers define material sample locations for affine angle transforms + # and H-field bend samples. x_centers = (x_edges[:-1] + x_edges[1:]) / 2 y_centers = (y_edges[:-1] + y_edges[1:]) / 2 # Slopes of the tilted propagation coordinate in the local x/y directions. @@ -537,6 +590,8 @@ def _transformed_material_tensors( for ix, x_value in enumerate(x_centers): for iy, y_value in enumerate(y_centers): + # Convert the 2D cell index into the flattened tensor column used by + # the sparse assembly layer. flat = ix * ny + iy local_eps = eps[:, :, ix, iy] local_mu = mu[:, :, ix, iy] @@ -569,6 +624,9 @@ def _transformed_material_tensors( bend_jac_h = np.diag([1.0, 1.0, dwdz_h]).astype(np.complex128) jac_e = bend_jac_e @ jac_e jac_h = bend_jac_h @ jac_h + + # Apply the transformation-optics tensor update separately for E and + # H because their Yee-grid sample locations can differ under bend. eps_tensor[:, :, flat] = jac_e @ local_eps @ jac_e.T / np.linalg.det(jac_e) mu_tensor[:, :, flat] = jac_h @ local_mu @ jac_h.T / np.linalg.det(jac_h) return eps_tensor, mu_tensor @@ -577,6 +635,9 @@ def _transformed_material_tensors( def _resolve_pml_spec( pml: PmlSpec | tuple[int, int] | None, ) -> PmlSpec: + """Normalize user PML input into a PmlSpec.""" + # The public API accepts compact tuples, but the solver only sees validated + # PmlSpec objects. if pml is None: return PmlSpec() if isinstance(pml, PmlSpec): @@ -587,6 +648,8 @@ def _resolve_pml_spec( def _resolve_boundary_spec( boundary: BoundarySpec | tuple[str, str] | None, ) -> BoundarySpec: + """Normalize user boundary input into a BoundarySpec.""" + # BoundarySpec handles case normalization and allowed-value validation. if boundary is None: return BoundarySpec() if isinstance(boundary, BoundarySpec): @@ -601,8 +664,9 @@ def _solver_info_with_context( shape: tuple[int, int], krylov_dim: int, ) -> dict[str, object]: - # Rust reports backend-local data. Add enough Python-side context for saved - # Result files and benchmark reports to be self-describing. + """Attach Python-side dispatch metadata to solver diagnostics.""" + # Add enough Python-side context for saved Result files and benchmark + # reports to be self-describing. out = dict(solver_info) out["backend_kind"] = backend_kind out["shape"] = shape @@ -613,12 +677,18 @@ def _solver_info_with_context( def _is_diagonal_tensor(tensor: np.ndarray, *, atol: float = 1e-12) -> bool: + """Return whether a flattened tensor has negligible off-diagonal terms.""" + # Only the first two tensor axes are structural; the remaining axis is the + # flattened cell index. off_diagonal = np.ones((3, 3), dtype=bool) np.fill_diagonal(off_diagonal, False) return bool(np.all(np.abs(tensor[off_diagonal]) <= atol)) def _shift_target_neff(target_neff: float) -> float: + """Nudge the shift target away from exact eigenvalues for ARPACK stability.""" + # Exact shifts can make shift-invert factorization singular; the tiny nudge + # preserves the requested target while avoiding exact equality. target_shift = float(10 * np.finfo(np.float32).eps) if abs(target_shift) > abs(target_neff * target_shift): return target_neff + target_shift @@ -626,6 +696,9 @@ def _shift_target_neff(target_neff: float) -> float: def _validate_edges(name: str, values: Sequence[float], cell_count: int) -> np.ndarray: + """Validate mode-plane edge coordinates against a cell count.""" + # Edge validation is repeated here because callers may provide an existing + # Materials object from outside the dataclass constructors. edges = np.asarray(values, dtype=float) if edges.shape != (cell_count + 1,): raise ValueError(f"{name} must have length {cell_count + 1}") @@ -639,11 +712,15 @@ def _resolve_freqs( freqs: Sequence[float] | None, wavelength: float | Sequence[float] | None, ) -> tuple[float, ...]: + """Resolve exactly one frequency or wavelength input into frequencies.""" + # Accept either frequencies or wavelengths, never both, so the solve axis is + # unambiguous. if (freqs is None) == (wavelength is None): raise ValueError("provide exactly one of freqs or wavelength") if freqs is not None: values = tuple(float(freq) for freq in freqs) else: + # Wavelengths are in microns because C_0 is stored in um/s. wavelengths = np.asarray(wavelength, dtype=float).reshape(-1) values = tuple(float(C_0 / value) for value in wavelengths) if not values or any(not np.isfinite(freq) or freq <= 0 for freq in values): @@ -652,12 +729,17 @@ def _resolve_freqs( def _dual_steps(primal_steps: np.ndarray) -> np.ndarray: + """Return backward-grid step sizes from primal cell widths.""" + # Interior backward steps live halfway between adjacent primal cells. if len(primal_steps) == 1: return primal_steps.copy() return np.hstack((primal_steps[0], (primal_steps[:-1] + primal_steps[1:]) / 2)) def _fields_to_grid(fields: list[np.ndarray], shape: tuple[int, int]) -> dict[str, np.ndarray]: + """Reshape flattened solver fields into x/y/mode arrays.""" + # scipy_reference returns [mode, flattened_cell]; public results use + # [x, y, mode] per component. nx, ny = shape mode_count = fields[0].shape[0] out = {} @@ -670,7 +752,7 @@ def _fields_to_grid(fields: list[np.ndarray], shape: tuple[int, int]) -> dict[st def _local_fields_to_global(fields: dict[str, np.ndarray], *, normal_axis: int) -> dict[str, np.ndarray]: """Map solver-local field components onto global x/y/z component names. - The Rust kernels solve in local coordinates where local z is the + The SciPy solver uses local coordinates where local z is the propagation-normal axis. For x- or y-normal planes, component labels must be permuted back to global coordinates before returning a Result. """ @@ -678,6 +760,8 @@ def _local_fields_to_global(fields: dict[str, np.ndarray], *, normal_axis: int) axis_names = ("x", "y", "z") if normal_axis not in {0, 1, 2}: raise ValueError("normal_axis must be 0, 1, or 2") + + # Local solver axes are always tangential-x, tangential-y, normal-z. local_to_global = (*(axis for axis in range(3) if axis != normal_axis), normal_axis) out: dict[str, np.ndarray] = {} for prefix in ("E", "H"): @@ -701,12 +785,19 @@ def _field_data_arrays( normal_axis: int, normal_coordinate: float, ) -> dict[str, xr.DataArray]: + """Wrap solved field arrays in coordinate-aware xarray objects.""" axis_names = ("x", "y", "z") if normal_axis not in {0, 1, 2}: raise ValueError("normal_axis must be 0, 1, or 2") + + # Determine which two global coordinates correspond to the solver's local + # x/y plane. tangential = tuple(axis for axis in range(3) if axis != normal_axis) coord0 = (x_edges[:-1] + x_edges[1:]) / 2 coord1 = (y_edges[:-1] + y_edges[1:]) / 2 + + # Store width coordinates beside center coordinates so integrations can use + # physical cell areas without reconstructing the original edges. coords = { axis_names[tangential[0]]: coord0, f"{axis_names[tangential[0]]}_width": (axis_names[tangential[0]], np.diff(x_edges)), @@ -726,6 +817,8 @@ def _field_data_arrays( ) out = {} for component, rows in fields_by_component.items(): + # Stack frequencies on axis 2, then insert the singleton normal axis so + # every component has x/y/z/f/mode_index dimensions. values = np.stack(rows, axis=2)[:, :, None, :, :] component_coords = dict(coords) component_coords["mode_index"] = np.arange(values.shape[-1]) @@ -739,16 +832,24 @@ def _field_data_arrays( def _default_initial_vector(size: int, shape: tuple[int, int] | None = None) -> np.ndarray: + """Build a deterministic ARPACK seed vector for reproducible solves.""" + # When grid shape is known, build a seed with the same flattened cell order + # as the solver state vector. if shape is not None and size % (shape[0] * shape[1]) == 0: nx, ny = shape multiplier = size // (nx * ny) rng = np.random.default_rng(0) vector = rng.random((nx, ny, multiplier)) + 1j * rng.random((nx, ny, multiplier)) + + # Zero low-edge rows when possible so the initial guess is compatible + # with symmetry-wall derivative stencils. if nx > 1: vector[0, :, :] = 0 if ny > 1: vector[:, 0, :] = 0 stacked = np.concatenate(tuple(vector[ix, :, :] for ix in range(nx)), axis=0) return stacked.flatten("F") + + # Fallback for callers that only know the operator size. index = np.arange(1, size + 1, dtype=float) return np.sin(0.37 * index) + 1j * np.cos(0.53 * index) diff --git a/python/micromode/result.py b/python/micromode/result.py index 7b3c04c..47d74b3 100644 --- a/python/micromode/result.py +++ b/python/micromode/result.py @@ -12,7 +12,7 @@ import numpy as np import xarray as xr -from ._rust import C_0 +from .constants import C_0 _SPATIAL_DIMS = ("x", "y", "z") _E_COMPONENTS = ("Ex", "Ey", "Ez") @@ -20,6 +20,8 @@ _OVERLAP_KINDS = {"electric", "power", "lorentz"} +# Result is the post-processing boundary: solver outputs are immutable xarray +# arrays, and convenience methods derive metrics without mutating fields. @dataclass(frozen=True) class Result: """Mode solver result data. @@ -39,12 +41,15 @@ class Result: def n_eff(self) -> xr.DataArray: """Real effective index.""" + # Keep n_complex as the source of truth; derived views preserve xarray + # coordinates for frequency and mode index. return self.n_complex.real @property def k_eff(self) -> xr.DataArray: """Imaginary part of the complex effective index.""" + # The imaginary part is used by loss calculations and release summaries. return self.n_complex.imag @cached_property @@ -54,9 +59,14 @@ def pol_fraction(self) -> xr.Dataset: tangential_dims = self._tangential_dims() if len(tangential_dims) != 2: raise ValueError("exactly two tangential field dimensions are required") + + # The two transverse electric components define the TE/TM split in the + # local mode plane. first, second = (f"E{dim}" for dim in tangential_dims) self._require_components((first, second)) + # Integrate component intensities over the spatial grid, then normalize + # to avoid amplitude-dependent fractions. first_power = self._integrated_power(first) second_power = self._integrated_power(second) total = np.maximum(first_power + second_power, np.finfo(float).eps) @@ -77,6 +87,8 @@ def pol_fraction_waveguide(self) -> xr.Dataset: normal_h = f"H{normal_dim}" self._require_components((*_E_COMPONENTS, *_H_COMPONENTS)) + # Waveguide fractions measure how much total E/H energy is transverse to + # the propagation normal. total_e = sum(self._integrated_power(component) for component in _E_COMPONENTS) total_h = sum(self._integrated_power(component) for component in _H_COMPONENTS) te = 1.0 - self._integrated_power(normal_e) / np.maximum(total_e, np.finfo(float).eps) @@ -88,9 +100,14 @@ def mode_area(self) -> xr.DataArray: """Effective mode area from electric-field intensity.""" self._require_components(_E_COMPONENTS) + + # Compute |E|^2 on the full grid before applying area weights. intensity = sum(np.abs(self.field_components[name].values) ** 2 for name in _E_COMPONENTS) axes = self._spatial_axes(self.field_components["Ex"]) weights = self._spatial_weights(self.field_components["Ex"]) + + # Standard effective-area definition: (integral I dA)^2 / + # integral(I^2 dA), guarded against empty or zero fields. numerator = np.sum(intensity * weights, axis=axes) ** 2 denominator = np.maximum(np.sum(intensity**2 * weights, axis=axes), np.finfo(float).eps) return self._mode_data_array(numerator / denominator) @@ -102,16 +119,24 @@ def modes_info(self) -> xr.Dataset: freq = self.n_complex.coords["f"] wavelength = xr.DataArray(C_0 / freq.values, dims=("f",), coords={"f": freq}) wavelength_cm = wavelength / 1e4 + + # Start with metrics that require no optional field components. metrics: dict[str, xr.DataArray] = { "wavelength": wavelength, "n eff": self.n_eff, "k eff": self.k_eff, "loss (dB/cm)": 20 * 2 * np.pi * np.log10(np.e) * self.k_eff / wavelength_cm, } + + # Optional arrays are preserved when callers provide group index or + # dispersion calculations externally. if self.n_group is not None: metrics["group index"] = self.n_group if self.dispersion is not None: metrics["dispersion"] = self.dispersion + + # Field-derived metrics are best-effort because users may request only a + # subset of components from solve_modes. self._add_optional_metric(metrics, "TE fraction", lambda: self.pol_fraction["te"]) self._add_optional_metric(metrics, "TM fraction", lambda: self.pol_fraction["tm"]) self._add_optional_metric(metrics, "wg TE fraction", lambda: self.pol_fraction_waveguide["te"]) @@ -142,13 +167,19 @@ def plot_field( raise ValueError(f"field component {component!r} is not available") import matplotlib.pyplot as plt + # Select a single frequency/mode slice before deciding how many spatial + # dimensions need to be shown. data_array = self._select_field(component, f=f, mode_index=mode_index) plane_dims = [dim for dim in _SPATIAL_DIMS if dim in data_array.dims and data_array.sizes[dim] > 1] if len(plane_dims) not in {1, 2}: raise ValueError("field plotting requires one or two non-singleton spatial dimensions") + # Put spatial axes in canonical x/y/z order so 1D and 2D plotting code + # does not depend on solver normal direction. data_array = data_array.transpose(*[dim for dim in _SPATIAL_DIMS if dim in data_array.dims]) values = np.asarray(data_array.values).squeeze() + + # Convert complex fields to the requested real-valued plotting channel. if val == "real": values = np.real(values) default_cmap = "RdBu_r" @@ -163,6 +194,8 @@ def plot_field( if ax is None: _, ax = plt.subplots() + + # Thin slice results become a line plot along the one non-singleton axis. if len(plane_dims) == 1: coord = np.asarray(data_array.coords[plane_dims[0]].values) ax.plot(coord, values) @@ -171,6 +204,7 @@ def plot_field( ax.set_title(f"{component}, f={self._selected_frequency(f):.6g}, mode={mode_index}") return ax + # Full 2D mode planes render with physical coordinate extents. x_coord = np.asarray(data_array.coords[plane_dims[0]].values) y_coord = np.asarray(data_array.coords[plane_dims[1]].values) extent = [x_coord.min(), x_coord.max(), y_coord.min(), y_coord.max()] @@ -202,9 +236,12 @@ def plot_field_components( import matplotlib.pyplot as plt + # Plot only requested components that are present in this Result. available = [component for component in components if component in self.field_components] if not available: raise ValueError("none of the requested field components are available") + + # Use a compact grid with up to three columns and hide unused axes. ncols = min(3, len(available)) nrows = int(np.ceil(len(available) / ncols)) fig, axes = plt.subplots(nrows, ncols, squeeze=False, figsize=(4.0 * ncols, 3.2 * nrows)) @@ -236,7 +273,7 @@ def overlap( ``kind="power"`` computes the transverse power-product ``integral((E_a x H_b*) . n) dA``. ``kind="lorentz"`` computes the - unconjugated reciprocal product used by the Rust orthogonalization + unconjugated reciprocal product used by the solver orthogonalization pass. ``kind="electric"`` computes a simpler electric-field inner product for mode tracking. All overlap kinds use the mode-plane cell widths as integration weights and require matching result grids. @@ -245,6 +282,9 @@ def overlap( other = self if other is None else other other_mode_index = mode_index if other_mode_index is None else other_mode_index other_f = f if other_f is None else other_f + + # Extract exactly one mode from each result before computing the selected + # overlap product. selected_self = self._selected_mode_fields(f=f, mode_index=mode_index) selected_other = other._selected_mode_fields(f=other_f, mode_index=other_mode_index) value = _overlap_value(self, selected_self, other, selected_other, kind=kind) @@ -272,6 +312,9 @@ def overlap_matrix( other = self if other is None else other other_f = f if other_f is None else other_f + + # Fill the dense pairwise matrix by delegating to the single-overlap + # method so normalization and validation behavior stays identical. values = np.empty( (self.n_complex.sizes["mode_index"], other.n_complex.sizes["mode_index"]), dtype=np.complex128 ) @@ -305,15 +348,22 @@ def to_hdf5(self, path: str | Path) -> Path: destination = Path(path) with h5py.File(destination, "w") as handle: + # Store a tiny file-level marker before writing xarray-shaped groups. handle.attrs["format"] = "micromode.Result" handle.attrs["version"] = 1 self._write_data_array(handle, "n_complex", self.n_complex) + + # Optional scalar arrays are omitted when absent rather than storing + # empty sentinel datasets. if self.n_group is not None: self._write_data_array(handle, "n_group", self.n_group) if self.dispersion is not None: self._write_data_array(handle, "dispersion", self.dispersion) if self.solver_info is not None: handle.attrs["solver_info"] = json.dumps(_json_safe(self.solver_info)) + + # Field components live under one group so readers can discover all + # available components without knowing what the solve requested. fields_group = handle.create_group("field_components") for component, data_array in self.field_components.items(): self._write_data_array(fields_group, component, data_array) @@ -329,11 +379,15 @@ def from_hdf5(cls, path: str | Path) -> Result: raise ImportError("h5py is required for Result.from_hdf5()") from exc with h5py.File(path, "r") as handle: + # Rehydrate required and optional DataArrays from the compact group + # layout written by to_hdf5. n_complex = cls._read_data_array(handle["n_complex"]) n_group = cls._read_data_array(handle["n_group"]) if "n_group" in handle else None dispersion = cls._read_data_array(handle["dispersion"]) if "dispersion" in handle else None solver_info = _loads_hdf5_json_attr(handle.attrs["solver_info"]) if "solver_info" in handle.attrs else None field_group: Any = handle["field_components"] + + # Preserve whatever subset of fields was saved. field_components = {component: cls._read_data_array(group) for component, group in field_group.items()} return cls( n_complex=n_complex, @@ -344,15 +398,24 @@ def from_hdf5(cls, path: str | Path) -> Result: ) def _require_components(self, components: Iterable[str]) -> None: + """Raise if required field components are absent.""" + # Centralize missing-component errors so derived metrics and overlaps use + # consistent messages. missing = [component for component in components if component not in self.field_components] if missing: raise ValueError(f"field component(s) required but missing: {', '.join(missing)}") def _normal_dim(self) -> str: + """Infer the singleton propagation-normal dimension.""" reference = self._reference_field() + + # Solver-created results annotate the normal dimension explicitly. attr_normal = reference.attrs.get("normal_dim") if attr_normal in _SPATIAL_DIMS and attr_normal in reference.dims: return str(attr_normal) + + # External Results may not carry attrs, so fall back to singleton spatial + # dimensions and then the smallest spatial dimension. spatial_dims = [dim for dim in _SPATIAL_DIMS if dim in reference.dims] singleton_dims = [dim for dim in spatial_dims if reference.sizes[dim] == 1] if len(singleton_dims) == 1: @@ -362,19 +425,23 @@ def _normal_dim(self) -> str: raise ValueError("fields must contain at least one spatial dimension") def _tangential_dims(self) -> tuple[str, ...]: + """Return the spatial dimensions transverse to propagation.""" normal = self._normal_dim() reference = self._reference_field() return tuple(dim for dim in _SPATIAL_DIMS if dim in reference.dims and dim != normal) def _reference_field(self) -> xr.DataArray: + """Return one field array used for dimension inference.""" if not self.field_components: raise ValueError("at least one field component is required") return next(iter(self.field_components.values())) def _spatial_axes(self, data_array: xr.DataArray) -> tuple[int, ...]: + """Return axis indices corresponding to spatial dimensions.""" return tuple(axis for axis, dim in enumerate(data_array.dims) if dim in _SPATIAL_DIMS) def _integrated_power(self, component: str) -> np.ndarray: + """Integrate squared field magnitude over the spatial grid.""" # Used by mode metrics such as polarization fraction and mode area. The # integration weights come from the mode-plane cell widths stored on each # field DataArray. @@ -385,6 +452,7 @@ def _integrated_power(self, component: str) -> np.ndarray: ) def _spatial_weights(self, data_array: xr.DataArray) -> np.ndarray: + """Build broadcastable cell-area weights from width coordinates.""" # Prefer explicit width coordinates written by the solver. Fall back to # midpoint-derived widths so externally constructed Results still work. weights: np.ndarray | float = 1.0 @@ -393,9 +461,14 @@ def _spatial_weights(self, data_array: xr.DataArray) -> np.ndarray: continue width_coord = f"{dim}_width" if width_coord in data_array.coords: + # Width coordinates are written by solve_modes and preserve the + # original nonuniform grid exactly. widths = np.asarray(data_array.coords[width_coord].values, dtype=float) else: widths = self._cell_widths(np.asarray(data_array.coords[dim].values, dtype=float)) + + # Reshape each 1D width vector so NumPy broadcasting multiplies it + # along the matching spatial axis only. shape = [1] * data_array.ndim shape[axis] = len(widths) weights = weights * widths.reshape(shape) @@ -403,8 +476,14 @@ def _spatial_weights(self, data_array: xr.DataArray) -> np.ndarray: @staticmethod def _cell_widths(values: np.ndarray) -> np.ndarray: + """Infer cell widths from coordinate midpoints.""" + # A singleton dimension represents the propagation normal and contributes + # unit width to area/volume weights. if values.size <= 1: return np.ones(values.shape, dtype=float) + + # Reconstruct edges halfway between adjacent centers, with endpoint + # extrapolation matching the nearest cell spacing. midpoints = 0.5 * (values[1:] + values[:-1]) edges = np.concatenate( ( @@ -416,9 +495,11 @@ def _cell_widths(values: np.ndarray) -> np.ndarray: return np.abs(np.diff(edges)) def _fraction_dataset(self, te: np.ndarray, tm: np.ndarray) -> xr.Dataset: + """Package TE/TM fractions as a dataset aligned to modes.""" return xr.Dataset({"te": self._mode_data_array(te), "tm": self._mode_data_array(tm)}) def _mode_data_array(self, values: np.ndarray) -> xr.DataArray: + """Wrap per-mode values with the n_complex coordinates.""" return xr.DataArray(values, dims=self.n_complex.dims, coords=self.n_complex.coords) def _add_optional_metric( @@ -427,22 +508,30 @@ def _add_optional_metric( name: str, getter: Any, ) -> None: + """Add a metric if its required field components are available.""" + # Missing field components raise ValueError in the metric getter; skip + # those metrics while letting unexpected exceptions surface. try: metrics[name] = getter() except ValueError: return def _select_field(self, component: str, *, f: int | float, mode_index: int) -> xr.DataArray: + """Select one component at a frequency and mode index.""" + # Integer f means positional selection; float f means nearest frequency + # coordinate, which is friendlier for wavelength-derived frequencies. data_array = self.field_components[component] data_array = data_array.isel(f=f) if isinstance(f, int) else data_array.sel(f=f, method="nearest") return data_array.isel(mode_index=mode_index) def _selected_mode_fields(self, *, f: int | float, mode_index: int) -> dict[str, xr.DataArray]: + """Select all available fields for one mode.""" return { component: self._select_field(component, f=f, mode_index=mode_index) for component in self.field_components } def _selected_frequency(self, f: int | float) -> float: + """Return the numeric frequency selected by index or nearest value.""" freq = self.n_complex.coords["f"] if isinstance(f, int): return float(freq.values[f]) @@ -450,12 +539,18 @@ def _selected_frequency(self, f: int | float) -> float: @staticmethod def _write_data_array(parent: Any, name: str, data_array: xr.DataArray) -> None: + """Write an xarray DataArray to the compact HDF5 layout.""" group = parent.create_group(name) + + # Store dimensions as metadata and raw values as a single dataset. group.attrs["dims"] = json.dumps(list(data_array.dims)) for attr_name, attr_value in data_array.attrs.items(): if isinstance(attr_value, (str, int, float, bool, np.integer, np.floating, np.bool_)): group.attrs[f"attr:{attr_name}"] = attr_value group.create_dataset("values", data=data_array.values) + + # Coordinates keep their own dimensions because xarray coordinates can be + # scalar, 1D, or attached to nonmatching axes. coords = group.create_group("coords") for coord_name, coord in data_array.coords.items(): coord_values = np.asarray(coord.values) @@ -464,6 +559,9 @@ def _write_data_array(parent: Any, name: str, data_array: xr.DataArray) -> None: @staticmethod def _read_data_array(group: Any) -> xr.DataArray: + """Read an xarray DataArray from the compact HDF5 layout.""" + # Rebuild attrs and coordinates first, then hand them to xarray with the + # stored value dataset. dims = tuple(json.loads(group.attrs["dims"])) attrs = { key.removeprefix("attr:"): value @@ -489,6 +587,9 @@ def overlap( def _json_safe(value: Any) -> Any: + """Convert NumPy and complex values into JSON-safe objects.""" + # solver_info may contain NumPy arrays/scalars, complex values, and nested + # containers; JSON attributes require plain Python data. if isinstance(value, np.ndarray): return _json_safe(value.tolist()) if isinstance(value, (complex, np.complexfloating)): @@ -496,6 +597,8 @@ def _json_safe(value: Any) -> Any: return {"real": complex_value.real, "imag": complex_value.imag} if isinstance(value, dict): return {str(key): _json_safe(item) for key, item in value.items()} + + # Lists and tuples are both serialized as JSON arrays. if isinstance(value, (list, tuple)): return [_json_safe(item) for item in value] if isinstance(value, (np.integer, np.floating, np.bool_)): @@ -504,6 +607,7 @@ def _json_safe(value: Any) -> Any: def _loads_hdf5_json_attr(value: Any) -> Any: + """Decode a JSON HDF5 attribute stored as bytes or text.""" if isinstance(value, bytes): value = value.decode("utf-8") return json.loads(value) @@ -517,6 +621,8 @@ def _overlap_value( *, kind: str, ) -> complex: + """Compute one unnormalized field overlap integral.""" + # Validate the requested overlap family before checking required components. if kind not in _OVERLAP_KINDS: raise ValueError("kind must be 'electric', 'power', or 'lorentz'") if kind == "electric": @@ -526,6 +632,9 @@ def _overlap_value( if missing: raise ValueError(f"electric overlap requires field component(s): {', '.join(missing)}") _validate_overlap_grid(left_result, left, right_result, right) + + # Electric overlap is a weighted dot product across all electric + # components. weights = left_result._spatial_weights(left["Ex"]) integrand = sum(left[component].values * np.conj(right[component].values) for component in _E_COMPONENTS) return complex(np.sum(integrand * weights)) @@ -533,7 +642,7 @@ def _overlap_value( # Power and Lorentz overlaps require the complete six-component mode. Power # uses H* and measures physical flux. Lorentz deliberately does not # conjugate either mode; it is the reciprocal product used to orthogonalize - # the mode set in Rust. + # the mode set. missing = [ component for component in (*_E_COMPONENTS, *_H_COMPONENTS) if component not in left or component not in right ] @@ -542,6 +651,8 @@ def _overlap_value( _validate_overlap_grid(left_result, left, right_result, right) weights = left_result._spatial_weights(left["Ex"]) normal = left_result._normal_dim() + + # Dispatch to the cross-product component aligned with the mode normal. if kind == "lorentz": integrand = _normal_lorentz_integrand(left, right, normal) else: @@ -554,6 +665,9 @@ def _normal_power_integrand( right: dict[str, xr.DataArray], normal: str, ) -> np.ndarray: + """Return the normal Poynting-flux integrand.""" + # Compute (E_left x H_right*) . n for whichever global axis is normal to the + # mode plane. if normal == "x": return left["Ey"].values * np.conj(right["Hz"].values) - left["Ez"].values * np.conj(right["Hy"].values) if normal == "y": @@ -568,6 +682,8 @@ def _normal_lorentz_integrand( right: dict[str, xr.DataArray], normal: str, ) -> np.ndarray: + """Return the symmetrized unconjugated Lorentz integrand.""" + # Lorentz reciprocity uses both E_left x H_right and E_right x H_left. left_cross_right = _normal_unconjugated_cross_integrand(left, right, normal) right_cross_left = _normal_unconjugated_cross_integrand(right, left, normal) return 0.5 * (left_cross_right + right_cross_left) @@ -578,6 +694,7 @@ def _normal_unconjugated_cross_integrand( right: dict[str, xr.DataArray], normal: str, ) -> np.ndarray: + """Return one unconjugated cross-product component.""" if normal == "x": return left["Ey"].values * right["Hz"].values - left["Ez"].values * right["Hy"].values if normal == "y": @@ -593,15 +710,23 @@ def _validate_overlap_grid( right_result: Result, right: dict[str, xr.DataArray], ) -> None: + """Ensure two modes live on compatible spatial grids.""" + # Overlap values are meaningful only when both fields use the same normal and + # spatial coordinate system. if left_result._normal_dim() != right_result._normal_dim(): raise ValueError("mode overlap requires matching mode-plane normal dimensions") reference_left = left["Ex"] reference_right = right["Ex"] + + # Dimension order must match before comparing coordinate values. if reference_left.dims != reference_right.dims: raise ValueError("mode overlap requires matching field dimensions") for dim in reference_left.dims: if dim not in _SPATIAL_DIMS: continue + + # Compare each spatial coordinate axis with tolerance to allow harmless + # floating-point roundoff in externally constructed Results. left_coord = np.asarray(reference_left.coords[dim].values, dtype=float) right_coord = np.asarray(reference_right.coords[dim].values, dtype=float) if left_coord.shape != right_coord.shape or not np.allclose(left_coord, right_coord): diff --git a/python/micromode/scipy_reference.py b/python/micromode/scipy_reference.py new file mode 100644 index 0000000..f623a78 --- /dev/null +++ b/python/micromode/scipy_reference.py @@ -0,0 +1,975 @@ +"""Readable SciPy implementation for the mode solver. + +This module assembles the sparse mode-solver paths in plain Python/SciPy. It +keeps the numerical contract inspectable by users who want to audit the +finite-difference operators against SciPy/ARPACK. +""" + +from __future__ import annotations + +import warnings +from dataclasses import dataclass +from typing import Any, cast + +import numpy as np + +ETA0 = 376.730_313_666_853_5 +MU0 = 1.256_637_062_12e-12 +C0 = 2.997_924_58e14 +EPSILON0 = 1.0 / (MU0 * C0 * C0) + + +SparseSolveResult = tuple[np.ndarray, list[np.ndarray], dict[str, object]] + + +# The SciPy reference layer stays deliberately explicit so finite-difference +# assembly can be audited against the physics model. +@dataclass +class _ModeFields: + """Mutable six-component field container used during normalization.""" + + ex: np.ndarray + ey: np.ndarray + ez: np.ndarray + hx: np.ndarray + hy: np.ndarray + hz: np.ndarray + + def components(self) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Return field components in electric-then-magnetic order.""" + return self.ex, self.ey, self.ez, self.hx, self.hy, self.hz + + def add_scaled(self, other: _ModeFields, scale: complex) -> None: + """Add another mode into this mode with a complex scale factor.""" + # Orthogonalization operates in-place to avoid repeatedly allocating six + # large component arrays per mode. + for left, right in zip(self.components(), other.components(), strict=True): + left += scale * right + + +def solve_diagonal_scipy_reference( + *, + eps_tensor: np.ndarray, + mu_tensor: np.ndarray, + dlf: tuple[np.ndarray, np.ndarray], + dlb: tuple[np.ndarray, np.ndarray], + num_modes: int, + neff_guess: float, + direction: str, + derivative_scale: float, + omega: float | None = None, + num_pml: tuple[int, int] = (0, 0), + pml_profile: dict[str, float | int] | None = None, + dmin_pml: tuple[bool, bool] = (True, True), + dmin_pmc: tuple[bool, bool] = (False, False), + krylov_dim: int = 32, + initial_vector: np.ndarray | None = None, +) -> SparseSolveResult: + """Solve the diagonal sparse eigenproblem with SciPy/ARPACK. + + This is the reduced ``[Ex, Ey]`` transverse eigenproblem for diagonal + material tensors. + """ + + sparse, spla, scipy_linalg = _import_scipy() + + # The diagonal formulation has two transverse electric unknowns per cell. + nx = len(dlf[0]) + ny = len(dlf[1]) + n = nx * ny + + # Validate flattened tensor layout before assembling sparse operators. + if eps_tensor.shape != mu_tensor.shape or eps_tensor.shape[:2] != (3, 3) or eps_tensor.shape[-1] != n: + raise ValueError("eps_tensor and mu_tensor must both have shape (3, 3, len(dlf[0]) * len(dlf[1]))") + if num_modes <= 0: + raise ValueError("num_modes must be positive") + + # Build finite-difference derivative matrices, including optional PML + # complex-coordinate stretching. + derivatives = _create_derivative_matrices( + sparse, + eps_tensor=eps_tensor, + mu_tensor=mu_tensor, + shape=(nx, ny), + dlf=dlf, + dlb=dlb, + omega=omega, + num_pml=num_pml, + pml_profile=pml_profile, + dmin_pml=dmin_pml, + dmin_pmc=dmin_pmc, + scale=float(derivative_scale), + ) + + # Assemble the reduced Ex/Ey eigen-operator for diagonal material tensors. + operators = _assemble_diagonal_operators(sparse, eps_tensor, mu_tensor, derivatives) + operator = cast(Any, operators["mat"]) + eig_guess = complex(-(neff_guess * neff_guess), 0.0) + + # ARPACK can solve a real problem faster when the operator and shift are + # numerically real; otherwise it keeps the complex path. + operator, arpack_initial_vector, arpack_guess = _real_arpack_problem_if_close(operator, initial_vector, eig_guess) + values, vectors = _selected_eigenpairs( + operator, + num_modes=num_modes, + sigma=arpack_guess, + krylov_dim=krylov_dim, + initial_vector=arpack_initial_vector, + spla=spla, + scipy_linalg=scipy_linalg, + ) + + # Normalize residuals by eigenvector norm so diagnostics are comparable + # across modes. + residuals = np.asarray( + [ + np.linalg.norm(operator @ vectors[:, index] - values[index] * vectors[:, index]) + for index in range(len(values)) + ], + dtype=float, + ) + vector_norms = np.maximum(np.linalg.norm(vectors, axis=0), np.finfo(float).eps) + residuals = residuals / vector_norms + + # Convert eigenvalues of -n_eff^2 back to n_eff and sort by real index. + modes: list[tuple[complex, np.ndarray, float]] = [] + for value, vector, residual in zip(values, vectors.T, residuals, strict=True): + modes.append((complex(np.sqrt(-value + 0j)), np.asarray(vector, dtype=np.complex128), float(residual))) + modes.sort(key=lambda item: item[0].real, reverse=True) + + # Precompute z-component inverse material matrices used during field + # reconstruction. + inv_eps_zz = sparse.diags(1.0 / eps_tensor[2, 2, :], format="csc") + inv_mu_zz = sparse.diags(1.0 / mu_tensor[2, 2, :], format="csc") + dxf, dxb, dyf, dyb = derivatives + cell_areas = np.repeat(np.asarray(dlf[0], dtype=float), ny) * np.tile(np.asarray(dlf[1], dtype=float), nx) + + # Reconstruct all six field components from the transverse eigenvectors. + n_complex: list[complex] = [] + mode_fields: list[_ModeFields] = [] + sorted_residuals: list[float] = [] + for mode_n, vector, residual in modes: + # The eigenvector stores Ex then Ey in flattened cell order. + ex = vector[:n].copy() + ey = vector[n:].copy() + denom = complex(-mode_n.imag, mode_n.real) + + # Magnetic transverse components come from the Q operator divided by the + # effective-index factor. + h_field = operators["qmat"] @ vector + hx = np.asarray(h_field[:n] / denom, dtype=np.complex128) + hy = np.asarray(h_field[n:] / denom, dtype=np.complex128) + + # Longitudinal H follows from the curl of transverse E. + hz_source = dxf @ ey - dyf @ ex + hz = np.asarray(inv_mu_zz @ hz_source, dtype=np.complex128) + + # Longitudinal E follows from the curl of the material electric block. + h_partial = np.asarray((operators["q_ep"] @ vector) / denom, dtype=np.complex128) + ez_source = dxb @ h_partial[n:] - dyb @ h_partial[:n] + ez = np.asarray(inv_eps_zz @ ez_source, dtype=np.complex128) + + # Convert magnetic fields to the same physical normalization convention + # used by Result overlap calculations. + h_scale = -1j / ETA0 + hx *= h_scale + hy *= h_scale + hz *= h_scale + + # Backward propagation flips transverse H and longitudinal E. + if direction == "-": + hx *= -1.0 + hy *= -1.0 + ez *= -1.0 + + # Accumulate raw modes first; normalization is applied to the full set + # so modes can be Lorentz-orthogonalized together. + n_complex.append(mode_n) + sorted_residuals.append(residual) + mode_fields.append(_ModeFields(ex=ex, ey=ey, ez=ez, hx=hx, hy=hy, hz=hz)) + + # Apply final normalization and package field components by component name. + orthogonalization = _lorentz_orthogonalize_and_normalize(mode_fields, cell_areas) + fields = [ + np.asarray([getattr(mode, component) for mode in mode_fields], dtype=np.complex128) + for component in ("ex", "ey", "ez", "hx", "hy", "hz") + ] + return ( + np.asarray(n_complex, dtype=np.complex128), + fields, + { + "backend": "scipy_arpack_reference", + "operator_size": int(operator.shape[0]), + "operator_nnz": int(operator.nnz), + "residuals": np.asarray(sorted_residuals, dtype=float), + "power_norms": orthogonalization["power_norms"], + "lorentz_norms": orthogonalization["lorentz_norms"], + "lorentz_orthogonality_error": orthogonalization["lorentz_orthogonality_error"], + }, + ) + + +def solve_tensorial_scipy_reference( + *, + eps_tensor: np.ndarray, + mu_tensor: np.ndarray, + dlf: tuple[np.ndarray, np.ndarray], + dlb: tuple[np.ndarray, np.ndarray], + num_modes: int, + neff_guess: float, + direction: str, + derivative_scale: float, + omega: float | None = None, + num_pml: tuple[int, int] = (0, 0), + pml_profile: dict[str, float | int] | None = None, + dmin_pml: tuple[bool, bool] = (True, True), + dmin_pmc: tuple[bool, bool] = (False, False), + krylov_dim: int = 32, + initial_vector: np.ndarray | None = None, +) -> SparseSolveResult: + """Solve the first-order tensorial sparse eigenproblem with SciPy/ARPACK.""" + + sparse, spla, scipy_linalg = _import_scipy() + + # The tensorial formulation keeps Ex, Ey, Hx, and Hy as unknowns per cell. + nx = len(dlf[0]) + ny = len(dlf[1]) + n = nx * ny + + # Full tensors are already flattened by the raster layer. + if eps_tensor.shape != mu_tensor.shape or eps_tensor.shape[:2] != (3, 3) or eps_tensor.shape[-1] != n: + raise ValueError("eps_tensor and mu_tensor must both have shape (3, 3, len(dlf[0]) * len(dlf[1]))") + if num_modes <= 0: + raise ValueError("num_modes must be positive") + + # Reuse the same Yee derivative matrices as the diagonal solver. + derivatives = _create_derivative_matrices( + sparse, + eps_tensor=eps_tensor, + mu_tensor=mu_tensor, + shape=(nx, ny), + dlf=dlf, + dlb=dlb, + omega=omega, + num_pml=num_pml, + pml_profile=pml_profile, + dmin_pml=dmin_pml, + dmin_pmc=dmin_pmc, + scale=float(derivative_scale), + ) + + # Assemble the first-order operator that directly returns n_eff eigenvalues. + operator = _assemble_tensorial_operator(sparse, eps_tensor, mu_tensor, derivatives) + values, vectors = _selected_eigenpairs( + operator, + num_modes=num_modes, + sigma=complex(neff_guess, 0.0), + krylov_dim=krylov_dim, + initial_vector=initial_vector, + spla=spla, + scipy_linalg=scipy_linalg, + ) + + # Record normalized ARPACK residuals for solver diagnostics. + residuals = np.asarray( + [ + np.linalg.norm(operator @ vectors[:, index] - values[index] * vectors[:, index]) + for index in range(len(values)) + ], + dtype=float, + ) + residuals = residuals / np.maximum(np.linalg.norm(vectors, axis=0), np.finfo(float).eps) + + # Sort modes in descending real effective index, matching diagonal behavior. + modes = [ + (complex(value), np.asarray(vector, dtype=np.complex128), float(residual)) + for value, vector, residual in zip(values, vectors.T, residuals, strict=True) + ] + modes.sort(key=lambda item: item[0].real, reverse=True) + + # z-component inverses reconstruct Ez and Hz after the transverse solve. + inv_eps_zz = sparse.diags(1.0 / eps_tensor[2, 2, :], format="csc") + inv_mu_zz = sparse.diags(1.0 / mu_tensor[2, 2, :], format="csc") + dxf, dxb, dyf, dyb = derivatives + cell_areas = np.repeat(np.asarray(dlf[0], dtype=float), ny) * np.tile(np.asarray(dlf[1], dtype=float), nx) + + n_complex: list[complex] = [] + mode_fields: list[_ModeFields] = [] + sorted_residuals: list[float] = [] + for mode_n, vector, residual in modes: + # The first-order eigenvector stores transverse E followed by transverse H. + ex = vector[:n].copy() + ey = vector[n : 2 * n].copy() + hx = vector[2 * n : 3 * n].copy() + hy = vector[3 * n : 4 * n].copy() + + # Longitudinal fields include off-diagonal material coupling through the + # z-row of the tensor. + hz_source = dxf @ ey - dyf @ ex - mu_tensor[2, 0, :] * hx - mu_tensor[2, 1, :] * hy + hz = np.asarray(inv_mu_zz @ hz_source, dtype=np.complex128) + + ez_source = dxb @ hy - dyb @ hx - eps_tensor[2, 0, :] * ex - eps_tensor[2, 1, :] * ey + ez = np.asarray(inv_eps_zz @ ez_source, dtype=np.complex128) + + # Match the magnetic-field convention used by the diagonal solver. + h_scale = -1j / ETA0 + hx *= h_scale + hy *= h_scale + hz *= h_scale + + # Backward propagation uses the same sign convention in both solvers. + if direction == "-": + hx *= -1.0 + hy *= -1.0 + ez *= -1.0 + + # Store modes for the shared Lorentz orthogonalization pass. + n_complex.append(mode_n) + sorted_residuals.append(residual) + mode_fields.append(_ModeFields(ex=ex, ey=ey, ez=ez, hx=hx, hy=hy, hz=hz)) + + # Normalize the full set, then return component-major field arrays. + orthogonalization = _lorentz_orthogonalize_and_normalize(mode_fields, cell_areas) + fields = [ + np.asarray([getattr(mode, component) for mode in mode_fields], dtype=np.complex128) + for component in ("ex", "ey", "ez", "hx", "hy", "hz") + ] + return ( + np.asarray(n_complex, dtype=np.complex128), + fields, + { + "backend": "scipy_arpack_reference", + "operator_size": int(operator.shape[0]), + "operator_nnz": int(operator.nnz), + "residuals": np.asarray(sorted_residuals, dtype=float), + "power_norms": orthogonalization["power_norms"], + "lorentz_norms": orthogonalization["lorentz_norms"], + "lorentz_orthogonality_error": orthogonalization["lorentz_orthogonality_error"], + }, + ) + + +def _import_scipy(): + """Import SciPy modules lazily so package import stays lightweight.""" + try: + import scipy.linalg as scipy_linalg + import scipy.sparse as sparse + import scipy.sparse.linalg as spla + except ImportError as exc: # pragma: no cover - depends on optional extra. + raise ImportError("the SciPy solver requires `pip install micromode` with SciPy installed") from exc + return sparse, spla, scipy_linalg + + +def _create_derivative_matrices( + sparse, + *, + eps_tensor: np.ndarray, + mu_tensor: np.ndarray, + shape: tuple[int, int], + dlf: tuple[np.ndarray, np.ndarray], + dlb: tuple[np.ndarray, np.ndarray], + omega: float | None, + num_pml: tuple[int, int], + pml_profile: dict[str, float | int] | None, + dmin_pml: tuple[bool, bool], + dmin_pmc: tuple[bool, bool], + scale: float, +): + """Assemble scaled Yee derivative matrices with optional PML stretching.""" + # Build raw finite-difference stencils first; optional PML scaling is a + # diagonal pre-multiplication applied afterward. + matrices = ( + _make_dxf(sparse, np.asarray(dlf[0], dtype=float), shape, dmin_pmc[0]), + _make_dxb(sparse, np.asarray(dlb[0], dtype=float), shape, dmin_pmc[0]), + _make_dyf(sparse, np.asarray(dlf[1], dtype=float), shape, dmin_pmc[1]), + _make_dyb(sparse, np.asarray(dlb[1], dtype=float), shape, dmin_pmc[1]), + ) + if num_pml[0] > 0 or num_pml[1] > 0: + # Complex stretch factors require angular frequency to scale sigma. + if omega is None: + raise ValueError("omega is required when num_pml is nonzero") + pml_values = _create_s_diagonal_values( + shape=shape, + num_pml=num_pml, + dlf=dlf, + dlb=dlb, + eps_tensor=eps_tensor, + mu_tensor=mu_tensor, + dmin_pml=dmin_pml, + omega=float(omega), + profile=pml_profile, + ) + matrices = tuple( + sparse.diags(values, format="csc") @ matrix for values, matrix in zip(pml_values, matrices, strict=True) + ) + + # derivative_scale nondimensionalizes spatial derivatives by wavelength. + return tuple(matrix * complex(scale, 0.0) for matrix in matrices) + + +def _make_dxf(sparse, dls: np.ndarray, shape: tuple[int, int], pmc: bool): + """Build the forward x derivative on the local Yee grid.""" + nx, ny = shape + if nx == 1: + return sparse.csc_matrix((ny, ny), dtype=np.complex128) + + # Assemble in coordinate-list form, then convert to CSC for sparse algebra. + rows: list[int] = [] + cols: list[int] = [] + data: list[complex] = [] + for ix in range(nx): + for iy in range(ny): + row = ix * ny + iy + value = 1.0 / dls[ix] + + # At the low edge, PEC-style symmetry drops the diagonal term while + # PMC keeps the one-sided stencil. + diagonal = 0.0 if ix == 0 and not pmc else -value + if diagonal != 0.0: + rows.append(row) + cols.append(row) + data.append(diagonal) + if ix + 1 < nx: + # Interior forward difference points to the next x cell. + rows.append(row) + cols.append((ix + 1) * ny + iy) + data.append(value) + return sparse.csc_matrix((data, (rows, cols)), shape=(nx * ny, nx * ny), dtype=np.complex128) + + +def _make_dxb(sparse, dls: np.ndarray, shape: tuple[int, int], pmc: bool): + """Build the backward x derivative on the local Yee grid.""" + nx, ny = shape + if nx == 1: + return sparse.csc_matrix((ny, ny), dtype=np.complex128) + + # Backward stencils share the same flattened row order as forward stencils. + rows: list[int] = [] + cols: list[int] = [] + data: list[complex] = [] + for ix in range(nx): + for iy in range(ny): + row = ix * ny + iy + value = 1.0 / dls[ix] + + # PMC at the low boundary mirrors the field and doubles the diagonal + # contribution; otherwise the first row has no backward neighbor. + diagonal = 2.0 * value if ix == 0 and pmc else (0.0 if ix == 0 else value) + if diagonal != 0.0: + rows.append(row) + cols.append(row) + data.append(diagonal) + if ix > 0: + # Interior backward difference points to the previous x cell. + rows.append(row) + cols.append((ix - 1) * ny + iy) + data.append(-value) + return sparse.csc_matrix((data, (rows, cols)), shape=(nx * ny, nx * ny), dtype=np.complex128) + + +def _make_dyf(sparse, dls: np.ndarray, shape: tuple[int, int], pmc: bool): + """Build the forward y derivative on the local Yee grid.""" + nx, ny = shape + if ny == 1: + return sparse.csc_matrix((nx, nx), dtype=np.complex128) + + # y derivatives use the same flat index but move along the inner y axis. + rows: list[int] = [] + cols: list[int] = [] + data: list[complex] = [] + for ix in range(nx): + for iy in range(ny): + row = ix * ny + iy + value = 1.0 / dls[iy] + + # Low-edge y boundary uses the same PEC/PMC convention as x. + diagonal = 0.0 if iy == 0 and not pmc else -value + if diagonal != 0.0: + rows.append(row) + cols.append(row) + data.append(diagonal) + if iy + 1 < ny: + # Interior forward y difference points to the next y cell. + rows.append(row) + cols.append(ix * ny + iy + 1) + data.append(value) + return sparse.csc_matrix((data, (rows, cols)), shape=(nx * ny, nx * ny), dtype=np.complex128) + + +def _make_dyb(sparse, dls: np.ndarray, shape: tuple[int, int], pmc: bool): + """Build the backward y derivative on the local Yee grid.""" + nx, ny = shape + if ny == 1: + return sparse.csc_matrix((nx, nx), dtype=np.complex128) + + # Backward y differences mirror _make_dxb on the inner axis. + rows: list[int] = [] + cols: list[int] = [] + data: list[complex] = [] + for ix in range(nx): + for iy in range(ny): + row = ix * ny + iy + value = 1.0 / dls[iy] + + # A PMC low edge doubles the reflected diagonal contribution. + diagonal = 2.0 * value if iy == 0 and pmc else (0.0 if iy == 0 else value) + if diagonal != 0.0: + rows.append(row) + cols.append(row) + data.append(diagonal) + if iy > 0: + # Interior backward y difference points to the previous y cell. + rows.append(row) + cols.append(ix * ny + iy - 1) + data.append(-value) + return sparse.csc_matrix((data, (rows, cols)), shape=(nx * ny, nx * ny), dtype=np.complex128) + + +def _assemble_diagonal_operators(sparse, eps: np.ndarray, mu: np.ndarray, der_mats) -> dict[str, object]: + """Assemble the reduced diagonal-material eigen-operator blocks.""" + # All blocks operate on flattened cell vectors. + n = eps.shape[-1] + zero = sparse.csc_matrix((n, n), dtype=np.complex128) + dxf, dxb, dyf, dyb = der_mats + + # Longitudinal component elimination requires inverse zz material terms. + inv_eps_zz = sparse.diags(1.0 / eps[2, 2, :], format="csc") + inv_mu_zz = sparse.diags(1.0 / mu[2, 2, :], format="csc") + + # The block names mirror docs/physics-model.md: + # A_diag = P_mu Q + P_partial Q_epsilon, with Q = Q_epsilon + Q_partial. + p_mu = sparse.bmat( + [[zero, sparse.diags(mu[1, 1, :], format="csc")], [-sparse.diags(mu[0, 0, :], format="csc"), zero]], + format="csc", + ) + + # P_partial captures derivative coupling introduced by eliminating Ez. + p_partial = sparse.bmat( + [ + [-(dxf @ inv_eps_zz @ dyb), dxf @ inv_eps_zz @ dxb], + [-(dyf @ inv_eps_zz @ dyb), dyf @ inv_eps_zz @ dxb], + ], + format="csc", + ) + q_ep = sparse.bmat( + [[zero, sparse.diags(eps[1, 1, :], format="csc")], [-sparse.diags(eps[0, 0, :], format="csc"), zero]], + format="csc", + ) + + # Q_partial captures derivative coupling introduced by eliminating Hz. + q_partial = sparse.bmat( + [ + [-(dxb @ inv_mu_zz @ dyf), dxb @ inv_mu_zz @ dxf], + [-(dyb @ inv_mu_zz @ dyf), dyb @ inv_mu_zz @ dxf], + ], + format="csc", + ) + qmat = q_ep + q_partial + + # The final reduced operator acts on [Ex, Ey]. + mat = p_mu @ qmat + p_partial @ q_ep + return {"q_ep": _canonical_sparse(q_ep), "qmat": _canonical_sparse(qmat), "mat": _canonical_sparse(mat)} + + +def _assemble_tensorial_operator(sparse, eps: np.ndarray, mu: np.ndarray, der_mats): + """Assemble the full tensorial first-order eigen-operator.""" + # The tensorial operator acts on [Ex, Ey, Hx, Hy] and leaves Ez/Hz implicit. + dxf, dxb, dyf, dyb = der_mats + inv_eps_22 = sparse.diags(1.0 / eps[2, 2, :], format="csc") + inv_mu_22 = sparse.diags(1.0 / mu[2, 2, :], format="csc") + + def diag(values): + """Return a sparse diagonal matrix for one flattened tensor component.""" + return sparse.diags(values, format="csc") + + # Precompute tensor ratios that appear repeatedly after eliminating z fields. + eps_20_over_22 = eps[2, 0, :] / eps[2, 2, :] + eps_21_over_22 = eps[2, 1, :] / eps[2, 2, :] + eps_02_over_22 = eps[0, 2, :] / eps[2, 2, :] + eps_12_over_22 = eps[1, 2, :] / eps[2, 2, :] + mu_20_over_22 = mu[2, 0, :] / mu[2, 2, :] + mu_21_over_22 = mu[2, 1, :] / mu[2, 2, :] + mu_02_over_22 = mu[0, 2, :] / mu[2, 2, :] + mu_12_over_22 = mu[1, 2, :] / mu[2, 2, :] + + # Schur-complement terms eliminate Ez and Hz before solving the first-order + # transverse system [Ex, Ey, Hx, Hy]. Suffix "_s" means the component has + # been corrected by its coupling through the local z component. + mu_10_s = mu[1, 0, :] - mu[1, 2, :] * mu_20_over_22 + mu_11_s = mu[1, 1, :] - mu[1, 2, :] * mu_21_over_22 + mu_00_s = mu[0, 0, :] - mu[0, 2, :] * mu_20_over_22 + mu_01_s = mu[0, 1, :] - mu[0, 2, :] * mu_21_over_22 + eps_10_s = eps[1, 0, :] - eps[1, 2, :] * eps_20_over_22 + eps_11_s = eps[1, 1, :] - eps[1, 2, :] * eps_21_over_22 + eps_00_s = eps[0, 0, :] - eps[0, 2, :] * eps_20_over_22 + eps_01_s = eps[0, 1, :] - eps[0, 2, :] * eps_21_over_22 + + # Block names encode output row and input column: + # a = electric transverse rows, b = magnetic transverse rows, x/y = local + # transverse components. For example, axby maps Hy into the Ex equation. + # Electric rows include derivative terms and magnetic tensor corrections. + axax = -(dxf @ diag(eps_20_over_22)) - diag(mu_12_over_22) @ dyf + axay = -(dxf @ diag(eps_21_over_22)) + diag(mu_12_over_22) @ dxf + axbx = -(dxf @ inv_eps_22 @ dyb) + diag(mu_10_s) + axby = dxf @ inv_eps_22 @ dxb + diag(mu_11_s) + + ayax = -(dyf @ diag(eps_20_over_22)) + diag(mu_02_over_22) @ dyf + ayay = -(dyf @ diag(eps_21_over_22)) - diag(mu_02_over_22) @ dxf + aybx = -(dyf @ inv_eps_22 @ dyb) - diag(mu_00_s) + ayby = dyf @ inv_eps_22 @ dxb - diag(mu_01_s) + + # Magnetic rows mirror the electric rows with eps/mu roles swapped. + bxax = -(dxb @ inv_mu_22 @ dyf) + diag(eps_10_s) + bxay = dxb @ inv_mu_22 @ dxf + diag(eps_11_s) + bxbx = -(dxb @ diag(mu_20_over_22)) - diag(eps_12_over_22) @ dyb + bxby = -(dxb @ diag(mu_21_over_22)) + diag(eps_12_over_22) @ dxb + + byax = -(dyb @ inv_mu_22 @ dyf) - diag(eps_00_s) + byay = dyb @ inv_mu_22 @ dxf - diag(eps_01_s) + bybx = -(dyb @ diag(mu_20_over_22)) + diag(eps_02_over_22) @ dyb + byby = -(dyb @ diag(mu_21_over_22)) - diag(eps_02_over_22) @ dxb + + # The leading -i matches the chosen time-harmonic convention. + return _canonical_sparse( + -1j + * sparse.bmat( + [ + [axax, axay, axbx, axby], + [ayax, ayay, aybx, ayby], + [bxax, bxay, bxbx, bxby], + [byax, byay, bybx, byby], + ], + format="csc", + ) + ) + + +def _canonical_sparse(matrix): + """Return a cleaned CSC sparse matrix.""" + # CSC is the format used by ARPACK and sparse block algebra here. + matrix = matrix.tocsc(copy=True) + matrix.eliminate_zeros() + return matrix + + +def _real_arpack_problem_if_close(matrix, initial_vector: np.ndarray | None, guess: complex): + """Use a real ARPACK problem when the operator and shift are effectively real.""" + # Empty operators cannot be inspected for imaginary scale. + if matrix.nnz == 0: + return matrix, initial_vector, guess + matrix_imag = matrix.data.imag + matrix_scale = max(float(np.max(np.abs(matrix.data))), 1.0) + guess_is_real = abs(guess.imag) <= 1e-14 * max(abs(guess), 1.0) + + # If the complex part is numerical noise, cast to real to avoid SciPy's + # ComplexWarning path and improve reproducibility. + if np.max(np.abs(matrix_imag)) <= 1e-14 * matrix_scale and guess_is_real: + real_vector = None if initial_vector is None else np.asarray(initial_vector.real, dtype=float) + return matrix.real.astype(float), real_vector, float(guess.real) + return matrix, initial_vector, guess + + +def _selected_eigenpairs( + mat, + *, + num_modes: int, + sigma: complex, + krylov_dim: int, + initial_vector: np.ndarray | None, + spla, + scipy_linalg, +) -> tuple[np.ndarray, np.ndarray]: + """Select eigenpairs nearest the requested shift.""" + size = mat.shape[0] + + # Very small operators cannot use sparse eigs with k >= N - 1, so fall back + # to dense eig and select the nearest modes manually. + if num_modes >= size - 1: + values, vectors = scipy_linalg.eig(mat.toarray()) + order = np.argsort(np.abs(values - sigma))[:num_modes] + return values[order], vectors[:, order] + + # Keep ARPACK's Krylov subspace large enough for the requested number of + # modes but bounded by the matrix size. + ncv = min(size, max(int(krylov_dim), num_modes + 2)) + if ncv <= num_modes + 1: + ncv = min(size, num_modes + 2) + + # Shift-invert mode returns eigenvalues closest to sigma. + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=np.exceptions.ComplexWarning, module="scipy") + values, vectors = spla.eigs( + mat, + k=num_modes, + sigma=sigma, + which="LM", + v0=None if initial_vector is None else np.asarray(initial_vector), + ncv=ncv, + tol=1e-10, + ) + return np.asarray(values, dtype=np.complex128), np.asarray(vectors, dtype=np.complex128) + + +def _create_s_diagonal_values( + *, + shape: tuple[int, int], + num_pml: tuple[int, int], + dlf: tuple[np.ndarray, np.ndarray], + dlb: tuple[np.ndarray, np.ndarray], + eps_tensor: np.ndarray, + mu_tensor: np.ndarray, + dmin_pml: tuple[bool, bool], + omega: float, + profile: dict[str, float | int] | None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Build inverse PML stretch vectors for every derivative stencil.""" + nx, ny = shape + + # Estimate boundary-relative speed so PML sigma scales with local material. + avg_speed = _average_relative_speed(shape, num_pml, eps_tensor, mu_tensor) + + # Forward and backward derivative stencils sample stretch factors at + # different Yee locations. + sx_f = _create_sfactor( + "f", omega, np.asarray(dlf[0], dtype=float), nx, num_pml[0], dmin_pml[0], avg_speed[:2], profile + ) + sx_b = _create_sfactor( + "b", omega, np.asarray(dlb[0], dtype=float), nx, num_pml[0], dmin_pml[0], avg_speed[:2], profile + ) + sy_f = _create_sfactor( + "f", omega, np.asarray(dlf[1], dtype=float), ny, num_pml[1], dmin_pml[1], avg_speed[2:], profile + ) + sy_b = _create_sfactor( + "b", omega, np.asarray(dlb[1], dtype=float), ny, num_pml[1], dmin_pml[1], avg_speed[2:], profile + ) + + # Expand one-dimensional stretch factors into flattened cell vectors. + sx_f_vec = np.empty(nx * ny, dtype=np.complex128) + sx_b_vec = np.empty(nx * ny, dtype=np.complex128) + sy_f_vec = np.empty(nx * ny, dtype=np.complex128) + sy_b_vec = np.empty(nx * ny, dtype=np.complex128) + for ix in range(nx): + for iy in range(ny): + index = ix * ny + iy + sx_f_vec[index] = 1.0 / sx_f[ix] + sx_b_vec[index] = 1.0 / sx_b[ix] + sy_f_vec[index] = 1.0 / sy_f[iy] + sy_b_vec[index] = 1.0 / sy_b[iy] + return sx_f_vec, sx_b_vec, sy_f_vec, sy_b_vec + + +def _average_relative_speed( + shape: tuple[int, int], + num_pml: tuple[int, int], + eps_tensor: np.ndarray, + mu_tensor: np.ndarray, +) -> np.ndarray: + """Estimate relative wave speed near PML boundaries.""" + # Average diagonal epsilon and mu on each side, then convert to relative + # speed for damping-strength scaling. + eps_avg = _pml_average_all_sides(shape, num_pml, eps_tensor) + mu_avg = _pml_average_all_sides(shape, num_pml, mu_tensor) + return 1.0 / np.sqrt(eps_avg * mu_avg) + + +def _pml_average_all_sides(shape: tuple[int, int], num_pml: tuple[int, int], tensor: np.ndarray) -> np.ndarray: + """Average diagonal material components on each boundary side.""" + nx, ny = shape + + # Region order is xmin, xmax, ymin, ymax. + regions: list[list[complex]] = [[], [], [], []] + for comp in range(3): + for ix in range(nx): + for iy in range(ny): + value = complex(tensor[comp, comp, ix * ny + iy]) + + # Collect only cells that lie inside the requested PML thickness. + if ix < num_pml[0]: + regions[0].append(value) + if ix >= max(nx - num_pml[0], 0) + 1: + regions[1].append(value) + if iy < num_pml[1]: + regions[2].append(value) + if iy >= max(ny - num_pml[1], 0) + 1: + regions[3].append(value) + out = np.ones(4, dtype=np.complex128) + + # If a side has no PML cells, leave its average at one so it does not affect + # unused stretch factors. + for index, values in enumerate(regions): + if values: + out[index] = sum(values) / len(values) + return out + + +def _create_sfactor( + direction: str, + omega: float, + dls: np.ndarray, + n: int, + n_pml: int, + dmin_pml: bool, + avg_speed: np.ndarray, + profile: dict[str, float | int] | None, +) -> np.ndarray: + """Dispatch PML stretch-factor construction for a derivative direction.""" + # No PML means identity stretching along this axis. + if n_pml == 0: + return np.ones(n, dtype=np.complex128) + + # Forward and backward stencils use half-cell-shifted PML depths. + if direction == "f": + return _create_sfactor_f(omega, dls, n, n_pml, dmin_pml, avg_speed, profile) + if direction == "b": + return _create_sfactor_b(omega, dls, n, n_pml, dmin_pml, avg_speed, profile) + raise ValueError(f"direction value {direction} not recognized") + + +def _create_sfactor_f( + omega: float, + dls: np.ndarray, + n: int, + n_pml: int, + dmin_pml: bool, + avg_speed: np.ndarray, + profile: dict[str, float | int] | None, +) -> np.ndarray: + """Build forward-grid PML stretch factors.""" + # Start with identity stretch and overwrite only PML cells. + sfactor = np.ones(n, dtype=np.complex128) + for i in range(n): + # Low-edge PML can be disabled by symmetry walls. + if i < n_pml and dmin_pml: + sfactor[i] = _s_value(dls[0], (n_pml - i - 0.5) / n_pml, omega, avg_speed[0], profile) + elif i >= n - n_pml: + sfactor[i] = _s_value(dls[-1], (i - (n - n_pml) + 0.5) / n_pml, omega, avg_speed[1], profile) + return sfactor + + +def _create_sfactor_b( + omega: float, + dls: np.ndarray, + n: int, + n_pml: int, + dmin_pml: bool, + avg_speed: np.ndarray, + profile: dict[str, float | int] | None, +) -> np.ndarray: + """Build backward-grid PML stretch factors.""" + # Backward stencils sample exactly on the low edge, not half a cell inward. + sfactor = np.ones(n, dtype=np.complex128) + for i in range(n): + if i < n_pml and dmin_pml: + sfactor[i] = _s_value(dls[0], (n_pml - i) / n_pml, omega, avg_speed[0], profile) + elif i > n - n_pml: + sfactor[i] = _s_value(dls[-1], (i - (n - n_pml)) / n_pml, omega, avg_speed[1], profile) + return sfactor + + +def _s_value( + dl: float, + step: float, + omega: float, + avg_speed: complex, + profile: dict[str, float | int] | None, +) -> complex: + """Evaluate one polynomial PML stretch factor.""" + # Defaults match PmlSpec and are overridden by a validated profile dict. + values = { + "sigma_max": 2.0, + "kappa_min": 1.0, + "kappa_max": 3.0, + "order": 3, + } + if profile is not None: + values.update(profile) + + # Polynomial depth controls both attenuation sigma and coordinate kappa. + step_power = step ** int(values["order"]) + kappa = float(values["kappa_min"]) + (float(values["kappa_max"]) - float(values["kappa_min"])) * step_power + sigma = avg_speed * (float(values["sigma_max"]) / (ETA0 * dl) * step_power) + return complex(kappa, 0.0) + 1j * sigma / (omega * EPSILON0) + + +def _lorentz_orthogonalize_and_normalize(modes: list[_ModeFields], cell_areas: np.ndarray) -> dict[str, object]: + """Normalize modes and enforce Lorentz orthogonality.""" + # Begin with unit transverse power so projection coefficients are stable. + for mode in modes: + _normalize_to_unit_power(mode, cell_areas) + + # Modified Gram-Schmidt using the unconjugated Lorentz overlap. + for mode_index, mode in enumerate(modes): + for previous in modes[:mode_index]: + denom = _lorentz_overlap(previous, previous, cell_areas) + if abs(denom) <= np.finfo(float).eps: + continue + coeff = _lorentz_overlap(previous, mode, cell_areas) / denom + mode.add_scaled(previous, -coeff) + + # Renormalize and pin phase after each projection step. + _normalize_to_unit_power(mode, cell_areas) + _apply_dominant_e_phase_convention(mode) + + # Diagnostics expose both power normalization and residual Lorentz coupling. + power_norms = np.asarray([abs(_transverse_power(mode, cell_areas)) for mode in modes], dtype=float) + lorentz_norms = np.asarray([_lorentz_overlap(mode, mode, cell_areas) for mode in modes], dtype=np.complex128) + error = 0.0 + for left_index, left in enumerate(modes): + for right_index, right in enumerate(modes): + if left_index == right_index: + continue + denom = float(np.sqrt(abs(lorentz_norms[left_index]) * abs(lorentz_norms[right_index]))) + if denom <= np.finfo(float).eps: + continue + # Track the largest normalized off-diagonal overlap. + error = max(error, abs(_lorentz_overlap(left, right, cell_areas)) / denom) + return { + "power_norms": power_norms, + "lorentz_norms": lorentz_norms, + "lorentz_orthogonality_error": float(error), + } + + +def _normalize_to_unit_power(mode: _ModeFields, cell_areas: np.ndarray) -> float: + """Scale a mode to unit transverse power.""" + # Use magnitude so forward/backward sign conventions do not prevent scaling. + norm = abs(_transverse_power(mode, cell_areas)) + if norm <= np.finfo(float).eps: + return 0.0 + scale = 1.0 / np.sqrt(norm) + + # Apply one scalar to all six components to preserve field ratios. + for component in mode.components(): + component *= scale + return abs(_transverse_power(mode, cell_areas)) + + +def _transverse_power(mode: _ModeFields, cell_areas: np.ndarray) -> complex: + """Compute conjugated transverse power flux.""" + # For local z propagation, transverse flux is (E x H*) . z. + return complex(np.sum((mode.ex * np.conj(mode.hy) - mode.ey * np.conj(mode.hx)) * cell_areas)) + + +def _lorentz_overlap(left: _ModeFields, right: _ModeFields, cell_areas: np.ndarray) -> complex: + """Compute the unconjugated reciprocal-product overlap.""" + # Symmetrize the unconjugated cross product to match reciprocal-mode theory. + left_cross_right = np.sum((left.ex * right.hy - left.ey * right.hx) * cell_areas) + right_cross_left = np.sum((right.ex * left.hy - right.ey * left.hx) * cell_areas) + return complex(0.5 * (left_cross_right + right_cross_left)) + + +def _apply_dominant_e_phase_convention(mode: _ModeFields) -> None: + """Rotate a mode so its largest electric component is real-positive.""" + # Choose a robust phase anchor from the largest electric-field entry. + electric = np.concatenate((mode.ex, mode.ey, mode.ez)) + if electric.size == 0: + return + anchor = electric[int(np.argmax(np.abs(electric) ** 2))] + if abs(anchor) <= np.finfo(float).eps: + return + + # Multiplying by conj(anchor)/|anchor| rotates the anchor onto the positive + # real axis without changing norms. + phase = np.conj(anchor) / abs(anchor) + for component in mode.components(): + component *= phase diff --git a/python/micromode/sweep.py b/python/micromode/sweep.py index 9609816..3bcb1da 100644 --- a/python/micromode/sweep.py +++ b/python/micromode/sweep.py @@ -20,6 +20,9 @@ class Sweep: parameter_name: str = "parameter" def __post_init__(self) -> None: + """Validate sweep lengths and mode-count consistency.""" + # Sweep values become a fixed one-dimensional float axis for all summary + # arrays and data-frame exports. values = np.asarray(self.values, dtype=float) if values.ndim != 1: raise ValueError("values must be one-dimensional") @@ -27,6 +30,9 @@ def __post_init__(self) -> None: raise ValueError("values and results must have the same length") if not self.results: raise ValueError("at least one result is required") + + # Mode tracking and stacked metrics assume every sweep step exposes the + # same number of modes. mode_counts = {int(result.n_complex.sizes["mode_index"]) for result in self.results} if len(mode_counts) != 1: raise ValueError("all sweep results must have the same number of modes") @@ -35,18 +41,28 @@ def __post_init__(self) -> None: @property def num_modes(self) -> int: + """Return the shared number of modes in the sweep.""" return int(self.results[0].n_complex.sizes["mode_index"]) @property def n_eff(self) -> np.ndarray: + """Return real effective indices arranged by sweep step and mode.""" + # Each Result stores one or more frequencies; sweep helpers currently + # summarize the first frequency for every step. return np.vstack([np.asarray(result.n_eff.values)[0] for result in self.results]) @property def n_complex(self) -> np.ndarray: + """Return complex effective indices arranged by sweep step and mode.""" + # Preserve the imaginary part here so loss/k_eff can be derived without + # re-reading individual Result objects. return np.vstack([np.asarray(result.n_complex.values)[0] for result in self.results]) @property def pol_fraction(self) -> dict[str, np.ndarray]: + """Return TE/TM fractions arranged by sweep step and mode.""" + # Keep TE and TM arrays parallel so callers can index by [step, mode] + # without touching xarray internals. return { "te": np.vstack([np.asarray(result.pol_fraction["te"].values)[0] for result in self.results]), "tm": np.vstack([np.asarray(result.pol_fraction["tm"].values)[0] for result in self.results]), @@ -54,6 +70,7 @@ def pol_fraction(self) -> dict[str, np.ndarray]: @property def pol_fraction_waveguide(self) -> dict[str, np.ndarray]: + """Return waveguide TE/TM fractions arranged by sweep step and mode.""" return { "te": np.vstack([np.asarray(result.pol_fraction_waveguide["te"].values)[0] for result in self.results]), "tm": np.vstack([np.asarray(result.pol_fraction_waveguide["tm"].values)[0] for result in self.results]), @@ -64,6 +81,8 @@ def to_dataframe(self): import pandas as pd + # Precompute stacked arrays once; the nested loop below only packages + # scalar values into tabular records. rows = [] pol = self.pol_fraction wg_pol = self.pol_fraction_waveguide @@ -100,6 +119,9 @@ def track_modes_by_overlap( tracked = tuple(results) if not tracked: return () + + # The exhaustive assignment search is factorial, so keep it explicitly + # limited to small mode sets where it is predictable and readable. mode_count = int(tracked[0].n_complex.sizes["mode_index"]) if mode_count > 8: raise ValueError("exhaustive overlap tracking is limited to at most 8 modes") @@ -107,6 +129,9 @@ def track_modes_by_overlap( for result in tracked[1:]: if int(result.n_complex.sizes["mode_index"]) != mode_count: raise ValueError("all results must have the same number of modes") + + # Compare the next raw result against the previously tracked result, then + # choose the permutation with the largest total normalized overlap. overlaps = np.abs(reordered[-1].overlap_matrix(result, kind=kind).values) best_order = max(permutations(range(mode_count)), key=lambda order: _assignment_score(overlaps, order)) reordered.append(_reorder_result_modes(result, best_order)) @@ -114,16 +139,26 @@ def track_modes_by_overlap( def _assignment_score(overlaps: np.ndarray, order: tuple[int, ...]) -> float: + """Score a proposed mode assignment by total overlap magnitude.""" + # order maps tracked mode_index -> source mode_index in the candidate result. return float(sum(overlaps[mode_index, source_index] for mode_index, source_index in enumerate(order))) def _reorder_result_modes(result: Result, order: tuple[int, ...]) -> Result: + """Return a result with all mode-indexed arrays reordered together.""" + # Reset mode coordinates after reordering so the output uses dense + # branch-tracking indices instead of the source result's raw order. mode_coord = np.arange(len(order)) n_complex = result.n_complex.isel(mode_index=list(order)).assign_coords(mode_index=mode_coord) + + # Every field component carries the same mode_index dimension and must move + # in lockstep with n_complex. field_components = { name: data_array.isel(mode_index=list(order)).assign_coords(mode_index=mode_coord) for name, data_array in result.field_components.items() } + + # Optional dispersion arrays are mode-indexed too; preserve them when present. n_group = None if result.n_group is not None: n_group = result.n_group.isel(mode_index=list(order)).assign_coords(mode_index=mode_coord) @@ -135,4 +170,5 @@ def _reorder_result_modes(result: Result, order: tuple[int, ...]) -> Result: field_components=field_components, n_group=n_group, dispersion=dispersion, + solver_info=result.solver_info, ) diff --git a/scripts/check_dist_artifacts.py b/scripts/check_dist_artifacts.py index 6b879e1..9955e0d 100644 --- a/scripts/check_dist_artifacts.py +++ b/scripts/check_dist_artifacts.py @@ -12,6 +12,7 @@ def main() -> None: + """Validate wheel and source-distribution artifact coverage.""" parser = argparse.ArgumentParser() parser.add_argument("dist", nargs="?", default="dist", help="Directory containing release artifacts") parser.add_argument( @@ -28,6 +29,11 @@ def main() -> None: metavar="PREFIX", help="Require at least one wheel platform tag with each prefix, for example macosx or manylinux", ) + parser.add_argument( + "--allow-pure-python", + action="store_true", + help="Allow a py3-none-any wheel instead of platform-specific wheels.", + ) args = parser.parse_args() dist = Path(args.dist) @@ -45,6 +51,10 @@ def main() -> None: platform = match["platform"] python_tag = match["python"] platform_tags.update(platform.split(".")) + if args.allow_pure_python and python_tag == "py3" and match["abi"] == "none" and platform == "any": + python_tags.update(args.require_cpython) + platform_tags.update(args.require_platform) + continue if python_tag.startswith("cp") and python_tag[2:].isdigit(): version_digits = python_tag[2:] python_tags.add(f"3.{version_digits[1:]}") @@ -63,6 +73,7 @@ def main() -> None: def require(condition: object, message: str) -> None: + """Raise SystemExit with a message when a condition is false.""" if not condition: raise SystemExit(message) diff --git a/scripts/check_release_metadata.py b/scripts/check_release_metadata.py index 4ccd248..2a46a02 100644 --- a/scripts/check_release_metadata.py +++ b/scripts/check_release_metadata.py @@ -16,28 +16,20 @@ def main() -> None: + """Validate release metadata and workflow expectations.""" pyproject = load_toml("pyproject.toml") - cargo = load_toml("Cargo.toml") project = pyproject["project"] - package = cargo["package"] publish_workflow = (ROOT / ".github/workflows/publish.yml").read_text(encoding="utf-8") require(project["name"] == "micromode", "Python package name must be micromode") require(project["version"] != "0.0.0", "Python package version must not be 0.0.0") - require( - package["version"] == python_to_cargo_version(project["version"]), - "Rust crate version must match the Python package version", - ) require(project["requires-python"] == ">=3.10,<3.14", "Python support must match the release wheel matrix") - require(package["version"] != "0.0.0", "Rust crate version must not be 0.0.0") require(project["license"] == "Apache-2.0", "Python package license must be Apache-2.0") - require(package["license"] == "Apache-2.0", "Rust crate license must be Apache-2.0") require((ROOT / "LICENSE").exists(), "LICENSE file is missing") require((ROOT / "CHANGELOG.md").exists(), "CHANGELOG.md is missing") require(project.get("authors"), "project.authors is missing") require(project.get("classifiers"), "project.classifiers is missing") require(project.get("urls"), "project.urls is missing") - require(package.get("repository"), "Cargo repository is missing") require((ROOT / ".github/workflows/publish.yml").exists(), "publish workflow is missing") require((ROOT / ".github/workflows/tests.yml").exists(), "tests workflow is missing") require((ROOT / "scripts/smoke_wheel.py").exists(), "wheel smoke test is missing") @@ -46,13 +38,7 @@ def main() -> None: f'"{version}"' in publish_workflow, f"publish workflow is missing a Python {version} wheel build", ) - require("--compatibility pypi" in publish_workflow, "publish workflow must request PyPI-compatible wheels") - require("--auditwheel repair" in publish_workflow, "Linux release wheels must be auditwheel-repaired") - require("windows-latest" in publish_workflow, "publish workflow is missing Windows wheel builds") - require( - "--require-platform macosx manylinux win" in publish_workflow, - "release artifact check must require macOS, manylinux, and Windows wheels", - ) + require("--allow-pure-python" in publish_workflow, "release artifact check must allow pure-Python wheels") changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") require(project["version"] in changelog, "Python version is not mentioned in CHANGELOG.md") @@ -63,22 +49,13 @@ def main() -> None: def load_toml(path: str) -> dict: + """Load a TOML file relative to the repository root.""" with (ROOT / path).open("rb") as handle: return tomllib.load(handle) -def python_to_cargo_version(version: str) -> str: - match = re.fullmatch(r"(\d+\.\d+\.\d+)(?:(a|b|rc)(\d+))?", version) - if match is None: - raise ValueError(f"Python version {version!r} is not a supported release version") - base, phase, number = match.groups() - if phase is None: - return base - phase_names = {"a": "alpha", "b": "beta", "rc": "rc"} - return f"{base}-{phase_names[phase]}.{number}" - - def require_tag_matches_version(version: str) -> None: + """Ensure a tag-triggered workflow uses the package version.""" if os.environ.get("GITHUB_REF_TYPE") != "tag": return @@ -87,6 +64,7 @@ def require_tag_matches_version(version: str) -> None: def require(condition: bool, message: str) -> None: + """Raise SystemExit with a message when a condition is false.""" if not condition: raise SystemExit(message) diff --git a/scripts/generate_coverage_badge.py b/scripts/generate_coverage_badge.py index 400d0cf..6938d4e 100644 --- a/scripts/generate_coverage_badge.py +++ b/scripts/generate_coverage_badge.py @@ -10,6 +10,7 @@ def main() -> None: + """Generate an SVG coverage badge from coverage XML.""" parser = argparse.ArgumentParser() parser.add_argument("coverage_xml", type=Path, nargs="?", default=Path("coverage.xml")) parser.add_argument("output_svg", type=Path, nargs="?", default=Path("docs/assets/coverage.svg")) @@ -23,6 +24,7 @@ def main() -> None: def coverage_percent(path: Path) -> float: + """Extract line coverage percentage from coverage.py XML.""" root = ET.parse(path).getroot() line_rate = root.attrib.get("line-rate") if line_rate is not None: @@ -36,6 +38,7 @@ def coverage_percent(path: Path) -> float: def color_for_percent(percent: float) -> str: + """Choose a badge color for a coverage percentage.""" if percent >= 90.0: return "#4c1" if percent >= 80.0: @@ -48,6 +51,7 @@ def color_for_percent(percent: float) -> str: def render_badge(label: str, message: str, color: str) -> str: + """Render a compact shields-style SVG badge.""" label = html.escape(label) message = html.escape(message) label_width = max(50, len(label) * 7 + 10) diff --git a/scripts/smoke_dist.py b/scripts/smoke_dist.py index 3a249b5..24d9bd8 100644 --- a/scripts/smoke_dist.py +++ b/scripts/smoke_dist.py @@ -13,6 +13,7 @@ def main() -> None: + """Install a built wheel into a temporary environment and smoke-test it.""" parser = argparse.ArgumentParser() parser.add_argument( "wheel", @@ -37,17 +38,22 @@ def main() -> None: def latest_wheel(required_tag: str) -> Path: - wheels = sorted( - (ROOT / "dist").glob(f"micromode-*-{required_tag}-*.whl"), - key=lambda path: path.stat().st_mtime, - reverse=True, - ) + """Find the newest compatible wheel in dist.""" + wheels = sorted((ROOT / "dist").glob(f"micromode-*-{required_tag}-*.whl"), key=_mtime, reverse=True) + if not wheels: + wheels = sorted((ROOT / "dist").glob("micromode-*-py3-none-any.whl"), key=_mtime, reverse=True) if not wheels: - raise SystemExit(f"no {required_tag} wheel found in dist/") + raise SystemExit(f"no {required_tag} or py3-none-any wheel found in dist/") return wheels[0].resolve() +def _mtime(path: Path) -> float: + """Return a path modification timestamp for sorting.""" + return path.stat().st_mtime + + def python_tag(python: str) -> str: + """Return the CPython wheel tag for an interpreter.""" command = [ python, "-c", @@ -58,6 +64,7 @@ def python_tag(python: str) -> str: def run(command: list[str]) -> None: + """Run a subprocess command and fail on nonzero exit.""" subprocess.run(command, check=True) diff --git a/scripts/smoke_wheel.py b/scripts/smoke_wheel.py index 24beac1..90a4d84 100644 --- a/scripts/smoke_wheel.py +++ b/scripts/smoke_wheel.py @@ -11,6 +11,7 @@ def main() -> None: + """Solve a tiny problem and round-trip HDF5 in an installed wheel.""" x_edges = np.linspace(-0.8, 0.8, 7) y_edges = np.linspace(-0.6, 0.6, 6) x = 0.5 * (x_edges[:-1] + x_edges[1:]) diff --git a/src/derivatives.rs b/src/derivatives.rs deleted file mode 100644 index 2d321fa..0000000 --- a/src/derivatives.rs +++ /dev/null @@ -1,522 +0,0 @@ -use num_complex::Complex64; - -use crate::sparse_matrix::SparseMatrix; - -pub const C0: f64 = 2.997_924_58e14; -pub const MU0: f64 = 1.256_637_062_12e-12; -pub const EPSILON0: f64 = 1.0 / (MU0 * C0 * C0); -pub const ETA0: f64 = 376.730_313_666_853_5; - -pub type Tensor3 = [[Vec; 3]; 3]; - -#[derive(Clone, Debug)] -pub struct PmlProfile { - pub sigma_max: f64, - pub kappa_min: f64, - pub kappa_max: f64, - pub order: i32, -} - -impl Default for PmlProfile { - fn default() -> Self { - Self { - sigma_max: 2.0, - kappa_min: 1.0, - kappa_max: 3.0, - order: 3, - } - } -} - -pub fn make_dxf_sparse(dls: &[f64], shape: (usize, usize), pmc: bool) -> SparseMatrix { - let (nx, ny) = shape; - if nx == 1 { - return SparseMatrix::zeros(ny, ny); - } - let mut triplets = Vec::new(); - for ix in 0..nx { - for iy in 0..ny { - let row = ix * ny + iy; - let scale = 1.0 / dls[ix]; - let diagonal = if ix == 0 && !pmc { 0.0 } else { -scale }; - if diagonal != 0.0 { - triplets.push((row, row, Complex64::new(diagonal, 0.0))); - } - if ix + 1 < nx { - let col = (ix + 1) * ny + iy; - triplets.push((row, col, Complex64::new(scale, 0.0))); - } - } - } - SparseMatrix::from_triplets(nx * ny, nx * ny, triplets) -} - -pub fn make_dxb_sparse(dls: &[f64], shape: (usize, usize), pmc: bool) -> SparseMatrix { - let (nx, ny) = shape; - if nx == 1 { - return SparseMatrix::zeros(ny, ny); - } - let mut triplets = Vec::new(); - for ix in 0..nx { - for iy in 0..ny { - let row = ix * ny + iy; - let scale = 1.0 / dls[ix]; - let diagonal = if ix == 0 { - if pmc { - 2.0 * scale - } else { - 0.0 - } - } else { - scale - }; - if diagonal != 0.0 { - triplets.push((row, row, Complex64::new(diagonal, 0.0))); - } - if ix > 0 { - let col = (ix - 1) * ny + iy; - triplets.push((row, col, Complex64::new(-scale, 0.0))); - } - } - } - SparseMatrix::from_triplets(nx * ny, nx * ny, triplets) -} - -pub fn make_dyf_sparse(dls: &[f64], shape: (usize, usize), pmc: bool) -> SparseMatrix { - let (nx, ny) = shape; - if ny == 1 { - return SparseMatrix::zeros(nx, nx); - } - let mut triplets = Vec::new(); - for ix in 0..nx { - for iy in 0..ny { - let row = ix * ny + iy; - let scale = 1.0 / dls[iy]; - let diagonal = if iy == 0 && !pmc { 0.0 } else { -scale }; - if diagonal != 0.0 { - triplets.push((row, row, Complex64::new(diagonal, 0.0))); - } - if iy + 1 < ny { - let col = ix * ny + iy + 1; - triplets.push((row, col, Complex64::new(scale, 0.0))); - } - } - } - SparseMatrix::from_triplets(nx * ny, nx * ny, triplets) -} - -pub fn make_dyb_sparse(dls: &[f64], shape: (usize, usize), pmc: bool) -> SparseMatrix { - let (nx, ny) = shape; - if ny == 1 { - return SparseMatrix::zeros(nx, nx); - } - let mut triplets = Vec::new(); - for ix in 0..nx { - for iy in 0..ny { - let row = ix * ny + iy; - let scale = 1.0 / dls[iy]; - let diagonal = if iy == 0 { - if pmc { - 2.0 * scale - } else { - 0.0 - } - } else { - scale - }; - if diagonal != 0.0 { - triplets.push((row, row, Complex64::new(diagonal, 0.0))); - } - if iy > 0 { - let col = ix * ny + iy - 1; - triplets.push((row, col, Complex64::new(-scale, 0.0))); - } - } - } - SparseMatrix::from_triplets(nx * ny, nx * ny, triplets) -} - -pub fn create_d_matrices_sparse( - shape: (usize, usize), - dlf: (&[f64], &[f64]), - dlb: (&[f64], &[f64]), - dmin_pmc: (bool, bool), -) -> [SparseMatrix; 4] { - [ - make_dxf_sparse(dlf.0, shape, dmin_pmc.0), - make_dxb_sparse(dlb.0, shape, dmin_pmc.0), - make_dyf_sparse(dlf.1, shape, dmin_pmc.1), - make_dyb_sparse(dlb.1, shape, dmin_pmc.1), - ] -} - -pub fn create_s_matrices_sparse( - omega: f64, - shape: (usize, usize), - npml: (usize, usize), - dlf: (&[f64], &[f64]), - dlb: (&[f64], &[f64]), - eps_tensor: &Tensor3, - mu_tensor: &Tensor3, - dmin_pml: (bool, bool), -) -> [SparseMatrix; 4] { - create_s_matrices_sparse_with_profile( - omega, - shape, - npml, - dlf, - dlb, - eps_tensor, - mu_tensor, - dmin_pml, - &PmlProfile::default(), - ) -} - -#[allow(clippy::too_many_arguments)] -pub fn create_s_matrices_sparse_with_profile( - omega: f64, - shape: (usize, usize), - npml: (usize, usize), - dlf: (&[f64], &[f64]), - dlb: (&[f64], &[f64]), - eps_tensor: &Tensor3, - mu_tensor: &Tensor3, - dmin_pml: (bool, bool), - profile: &PmlProfile, -) -> [SparseMatrix; 4] { - create_s_diagonal_values( - omega, shape, npml, dlf, dlb, eps_tensor, mu_tensor, dmin_pml, profile, - ) - .map(|values| SparseMatrix::diagonal(&values)) -} - -#[allow(clippy::too_many_arguments)] -pub fn create_s_diagonal_values( - omega: f64, - shape: (usize, usize), - npml: (usize, usize), - dlf: (&[f64], &[f64]), - dlb: (&[f64], &[f64]), - eps_tensor: &Tensor3, - mu_tensor: &Tensor3, - dmin_pml: (bool, bool), - profile: &PmlProfile, -) -> [Vec; 4] { - let (nx, ny) = shape; - let n = nx * ny; - let avg_speed = average_relative_speed(shape, npml, eps_tensor, mu_tensor); - - let sx_f = create_sfactor( - "f", - omega, - dlf.0, - nx, - npml.0, - dmin_pml.0, - (avg_speed[0], avg_speed[1]), - profile, - ); - let sx_b = create_sfactor( - "b", - omega, - dlb.0, - nx, - npml.0, - dmin_pml.0, - (avg_speed[0], avg_speed[1]), - profile, - ); - let sy_f = create_sfactor( - "f", - omega, - dlf.1, - ny, - npml.1, - dmin_pml.1, - (avg_speed[2], avg_speed[3]), - profile, - ); - let sy_b = create_sfactor( - "b", - omega, - dlb.1, - ny, - npml.1, - dmin_pml.1, - (avg_speed[2], avg_speed[3]), - profile, - ); - - let mut sx_f_vec = vec![Complex64::new(0.0, 0.0); n]; - let mut sx_b_vec = vec![Complex64::new(0.0, 0.0); n]; - let mut sy_f_vec = vec![Complex64::new(0.0, 0.0); n]; - let mut sy_b_vec = vec![Complex64::new(0.0, 0.0); n]; - - for ix in 0..nx { - for iy in 0..ny { - let index = ix * ny + iy; - sx_f_vec[index] = Complex64::new(1.0, 0.0) / sx_f[ix]; - sx_b_vec[index] = Complex64::new(1.0, 0.0) / sx_b[ix]; - sy_f_vec[index] = Complex64::new(1.0, 0.0) / sy_f[iy]; - sy_b_vec[index] = Complex64::new(1.0, 0.0) / sy_b[iy]; - } - } - - [sx_f_vec, sx_b_vec, sy_f_vec, sy_b_vec] -} - -pub fn average_relative_speed( - shape: (usize, usize), - npml: (usize, usize), - eps_tensor: &Tensor3, - mu_tensor: &Tensor3, -) -> [Complex64; 4] { - let eps_avg = pml_average_all_sides(shape, npml, eps_tensor); - let mu_avg = pml_average_all_sides(shape, npml, mu_tensor); - let mut out = [Complex64::new(1.0, 0.0); 4]; - for i in 0..4 { - out[i] = Complex64::new(1.0, 0.0) / (eps_avg[i] * mu_avg[i]).sqrt(); - } - out -} - -fn pml_average_all_sides( - shape: (usize, usize), - npml: (usize, usize), - tensor: &Tensor3, -) -> [Complex64; 4] { - let (nx, ny) = shape; - let mut regions = [Vec::new(), Vec::new(), Vec::new(), Vec::new()]; - for comp in 0..3 { - for ix in 0..nx { - for iy in 0..ny { - let value = tensor[comp][comp][ix * ny + iy]; - if ix < npml.0 { - regions[0].push(value); - } - if ix >= nx.saturating_sub(npml.0).saturating_add(1) { - regions[1].push(value); - } - if iy < npml.1 { - regions[2].push(value); - } - if iy >= ny.saturating_sub(npml.1).saturating_add(1) { - regions[3].push(value); - } - } - } - } - - let mut out = [Complex64::new(1.0, 0.0); 4]; - for (index, values) in regions.iter().enumerate() { - if !values.is_empty() { - out[index] = values.iter().copied().sum::() / values.len() as f64; - } - } - out -} - -pub fn create_sfactor( - direction: &str, - omega: f64, - dls: &[f64], - n: usize, - n_pml: usize, - dmin_pml: bool, - avg_speed: (Complex64, Complex64), - profile: &PmlProfile, -) -> Vec { - if n_pml == 0 { - return vec![Complex64::new(1.0, 0.0); n]; - } - match direction { - "f" => create_sfactor_f(omega, dls, n, n_pml, dmin_pml, avg_speed, profile), - "b" => create_sfactor_b(omega, dls, n, n_pml, dmin_pml, avg_speed, profile), - _ => panic!("direction value {direction} not recognized"), - } -} - -#[allow(clippy::too_many_arguments)] -pub fn create_sfactor_f( - omega: f64, - dls: &[f64], - n: usize, - n_pml: usize, - dmin_pml: bool, - avg_speed: (Complex64, Complex64), - profile: &PmlProfile, -) -> Vec { - let mut sfactor = vec![Complex64::new(1.0, 0.0); n]; - for (i, value) in sfactor.iter_mut().enumerate() { - if i < n_pml && dmin_pml { - *value = s_value( - dls[0], - (n_pml as f64 - i as f64 - 0.5) / n_pml as f64, - omega, - avg_speed.0, - profile, - ); - } else if i >= n - n_pml { - *value = s_value( - dls[dls.len() - 1], - (i as f64 - (n - n_pml) as f64 + 0.5) / n_pml as f64, - omega, - avg_speed.1, - profile, - ); - } - } - sfactor -} - -#[allow(clippy::too_many_arguments)] -pub fn create_sfactor_b( - omega: f64, - dls: &[f64], - n: usize, - n_pml: usize, - dmin_pml: bool, - avg_speed: (Complex64, Complex64), - profile: &PmlProfile, -) -> Vec { - let mut sfactor = vec![Complex64::new(1.0, 0.0); n]; - for (i, value) in sfactor.iter_mut().enumerate() { - if i < n_pml && dmin_pml { - *value = s_value( - dls[0], - (n_pml as f64 - i as f64) / n_pml as f64, - omega, - avg_speed.0, - profile, - ); - } else if i > n - n_pml { - *value = s_value( - dls[dls.len() - 1], - (i as f64 - (n - n_pml) as f64) / n_pml as f64, - omega, - avg_speed.1, - profile, - ); - } - } - sfactor -} - -pub fn s_value( - dl: f64, - step: f64, - omega: f64, - avg_speed: Complex64, - profile: &PmlProfile, -) -> Complex64 { - let step_power = step.powi(profile.order); - let kappa = profile.kappa_min + (profile.kappa_max - profile.kappa_min) * step_power; - let sigma = avg_speed * (profile.sigma_max / (ETA0 * dl) * step_power); - Complex64::new(kappa, 0.0) + Complex64::new(0.0, 1.0) * sigma / (omega * EPSILON0) -} - -pub fn tensor_from_flat(flat: &[Vec<(f64, f64)>], n: usize) -> Result { - if flat.len() != 9 { - return Err("tensor must contain 9 flattened components".to_string()); - } - let mut tensor: Tensor3 = std::array::from_fn(|_| std::array::from_fn(|_| Vec::new())); - for row in 0..3 { - for col in 0..3 { - let values = &flat[row * 3 + col]; - if values.len() != n { - return Err("tensor component length does not match grid shape".to_string()); - } - tensor[row][col] = values - .iter() - .map(|(real, imag)| Complex64::new(*real, *imag)) - .collect(); - } - } - Ok(tensor) -} - -#[cfg(test)] -mod sparse_tests { - use super::*; - - fn sample_tensor(shape: (usize, usize), base: f64) -> Tensor3 { - let n = shape.0 * shape.1; - let mut tensor: Tensor3 = std::array::from_fn(|_| std::array::from_fn(|_| Vec::new())); - for row in 0..3 { - for col in 0..3 { - tensor[row][col] = vec![Complex64::new(0.0, 0.0); n]; - } - } - tensor[0][0] = (0..n) - .map(|index| Complex64::new(base + 0.03 * index as f64, 0.0)) - .collect(); - tensor[1][1] = (0..n) - .map(|index| Complex64::new(base + 0.2 + 0.02 * index as f64, 0.0)) - .collect(); - tensor[2][2] = (0..n) - .map(|index| Complex64::new(base + 0.6 + 0.01 * index as f64, 0.0)) - .collect(); - tensor - } - - #[test] - fn sparse_derivative_matrices_have_expected_shape() { - let shape = (4, 5); - let dlf = ( - vec![0.09, 0.10, 0.13, 0.18], - vec![0.08, 0.11, 0.12, 0.14, 0.19], - ); - let dlb = ( - vec![0.09, 0.095, 0.115, 0.15], - vec![0.08, 0.10, 0.115, 0.13, 0.17], - ); - let dmin_pmc = (true, false); - let sparse = create_d_matrices_sparse(shape, (&dlf.0, &dlf.1), (&dlb.0, &dlb.1), dmin_pmc); - - for matrix in &sparse { - assert_eq!( - (matrix.rows, matrix.cols), - (shape.0 * shape.1, shape.0 * shape.1) - ); - assert!(matrix.nnz() > 0); - } - } - - #[test] - fn sparse_pml_matrices_have_expected_shape() { - let shape = (4, 5); - let dlf = ( - vec![0.09, 0.10, 0.13, 0.18], - vec![0.08, 0.11, 0.12, 0.14, 0.19], - ); - let dlb = ( - vec![0.09, 0.095, 0.115, 0.15], - vec![0.08, 0.10, 0.115, 0.13, 0.17], - ); - let eps = sample_tensor(shape, 2.0); - let mu = sample_tensor(shape, 1.0); - let omega = 2.0 * std::f64::consts::PI * 193.414_489e12; - let npml = (2, 1); - let dmin_pml = (true, false); - - let sparse = create_s_matrices_sparse( - omega, - shape, - npml, - (&dlf.0, &dlf.1), - (&dlb.0, &dlb.1), - &eps, - &mu, - dmin_pml, - ); - - for matrix in &sparse { - assert_eq!( - (matrix.rows, matrix.cols), - (shape.0 * shape.1, shape.0 * shape.1) - ); - assert!(matrix.nnz() > 0); - } - } -} diff --git a/src/diagonal_solver.rs b/src/diagonal_solver.rs deleted file mode 100644 index 2a30b58..0000000 --- a/src/diagonal_solver.rs +++ /dev/null @@ -1,160 +0,0 @@ -pub use crate::eigensolve::{ - diagonal_eigs_to_effective_index, selected_sparse_shift_invert_eigenpairs, - selected_sparse_shift_invert_native_eigenpairs, Eigenpair, ShiftInvertOptions, -}; -pub use crate::mode_solver::{ - solve_diagonal_sparse, solve_tensorial_sparse, DiagonalSolveResult, SolveDiagnostics, -}; -pub use crate::operators::{ - assemble_sparse_diagonal_operators, assemble_sparse_tensorial_operator, SparseDiagonalOperators, -}; - -#[cfg(test)] -mod sparse_tests { - use super::*; - use crate::derivatives; - use crate::derivatives::Tensor3; - use crate::eigensolve::sparse_residual_norm; - use num_complex::Complex64; - - fn sample_tensor(shape: (usize, usize), base: f64) -> Tensor3 { - let n = shape.0 * shape.1; - let grid = (0..n).map(|i| i as f64).collect::>(); - let mut tensor: Tensor3 = std::array::from_fn(|_| std::array::from_fn(|_| Vec::new())); - for row in 0..3 { - for col in 0..3 { - tensor[row][col] = vec![Complex64::new(0.0, 0.0); n]; - } - } - tensor[0][0] = grid - .iter() - .map(|value| Complex64::new(base + 0.03 * value, 0.0)) - .collect(); - tensor[1][1] = grid - .iter() - .map(|value| Complex64::new(base + 0.2 + 0.02 * value, 0.0)) - .collect(); - tensor[2][2] = grid - .iter() - .map(|value| Complex64::new(base + 0.6 + 0.01 * value, 0.0)) - .collect(); - tensor - } - - #[test] - fn sparse_diagonal_operator_assembly_has_expected_shape() { - let shape = (3, 4); - let dlf = (vec![0.17, 0.19, 0.23], vec![0.11, 0.13, 0.17, 0.21]); - let dlb = (vec![0.16, 0.18, 0.21], vec![0.10, 0.12, 0.15, 0.19]); - let dmin_pmc = (false, true); - let sparse_derivatives = derivatives::create_d_matrices_sparse( - shape, - (&dlf.0, &dlf.1), - (&dlb.0, &dlb.1), - dmin_pmc, - ); - let eps = sample_tensor(shape, 2.0); - let mu = sample_tensor(shape, 1.0); - - let sparse = assemble_sparse_diagonal_operators(&eps, &mu, &sparse_derivatives); - - assert_eq!((sparse.mat.rows, sparse.mat.cols), (24, 24)); - assert_eq!((sparse.qmat.rows, sparse.qmat.cols), (24, 24)); - assert_eq!((sparse.q_ep.rows, sparse.q_ep.cols), (24, 24)); - assert!(sparse.mat.nnz() > 0); - assert!(sparse.qmat.nnz() > 0); - } - - #[test] - fn sparse_shift_invert_eigenpairs_have_small_residuals() { - let shape = (3, 4); - let dlf = (vec![0.17, 0.19, 0.23], vec![0.11, 0.13, 0.17, 0.21]); - let dlb = (vec![0.16, 0.18, 0.21], vec![0.10, 0.12, 0.15, 0.19]); - let sparse_derivatives = derivatives::create_d_matrices_sparse( - shape, - (&dlf.0, &dlf.1), - (&dlb.0, &dlb.1), - (false, false), - ); - let eps = sample_tensor(shape, 2.0); - let mu = sample_tensor(shape, 1.0); - let sparse = assemble_sparse_diagonal_operators(&eps, &mu, &sparse_derivatives); - let guess = Complex64::new(-(2.2 * 2.2), 0.0); - - let actual = selected_sparse_shift_invert_eigenpairs( - &sparse.mat, - 2, - guess, - None, - ShiftInvertOptions { - krylov_dim: 20, - tolerance: 1e-11, - }, - ) - .unwrap(); - - assert_eq!(actual.len(), 2); - for actual in &actual { - let residual = sparse_residual_norm(&sparse.mat, &actual.vector, actual.value); - assert!(residual < 1e-6, "residual was {residual}"); - } - } - - #[test] - fn sparse_diagonal_solve_recovers_normalized_fields() { - let shape = (3, 4); - let dlf = (vec![0.17, 0.19, 0.23], vec![0.11, 0.13, 0.17, 0.21]); - let dlb = (vec![0.16, 0.18, 0.21], vec![0.10, 0.12, 0.15, 0.19]); - let sparse_derivatives = derivatives::create_d_matrices_sparse( - shape, - (&dlf.0, &dlf.1), - (&dlb.0, &dlb.1), - (false, false), - ); - let eps = sample_tensor(shape, 2.0); - let mu = sample_tensor(shape, 1.0); - let cell_areas = test_cell_areas(&dlf.0, &dlf.1); - - let sparse = solve_diagonal_sparse( - &eps, - &mu, - &sparse_derivatives, - &cell_areas, - 2, - 2.2, - "+", - None, - ShiftInvertOptions { - krylov_dim: 20, - tolerance: 1e-11, - }, - ) - .unwrap(); - - assert_eq!(sparse.n_complex.len(), 2); - assert!(sparse.n_complex[0].re >= sparse.n_complex[1].re); - for value in &sparse.n_complex { - assert!(value.re.is_finite()); - assert!(value.im.is_finite()); - } - for power_norm in &sparse.diagnostics.power_norms { - assert!((*power_norm - 1.0).abs() < 1e-10); - } - - for mode_index in 0..2 { - for component in &sparse.fields { - assert_eq!(component[mode_index].len(), shape.0 * shape.1); - } - } - } - - fn test_cell_areas(dlf_x: &[f64], dlf_y: &[f64]) -> Vec { - let mut out = Vec::with_capacity(dlf_x.len() * dlf_y.len()); - for dx in dlf_x { - for dy in dlf_y { - out.push(dx * dy); - } - } - out - } -} diff --git a/src/eigensolve.rs b/src/eigensolve.rs deleted file mode 100644 index b21dbac..0000000 --- a/src/eigensolve.rs +++ /dev/null @@ -1,659 +0,0 @@ -//! Eigenvalue selection and shift-invert backends. -//! -//! The solver usually needs a handful of modes near a requested effective -//! index, not the whole spectrum. Shift-invert changes that local search into a -//! dominant-eigenvalue problem for `(A - sigma I)^-1`, which sparse Krylov -//! methods can solve efficiently on realistic grids. - -use std::time::{Duration, Instant}; - -use nalgebra::{linalg::SVD, DMatrix}; -use num_complex::Complex64; - -use crate::sparse_matrix::SparseMatrix; - -#[derive(Clone, Debug)] -pub struct Eigenpair { - pub value: Complex64, - pub vector: Vec, - pub residual: f64, - pub backend: &'static str, -} - -#[derive(Clone, Debug)] -pub struct ShiftInvertOptions { - pub krylov_dim: usize, - pub tolerance: f64, -} - -impl Default for ShiftInvertOptions { - fn default() -> Self { - Self { - krylov_dim: 32, - tolerance: 1e-10, - } - } -} - -#[derive(Clone, Debug, Default)] -pub struct ShiftInvertProfile { - pub pairs: Vec, - pub total: Duration, - pub shift_diagonal: Duration, - pub amd_ordering: Duration, - pub lu_factorization: Duration, - pub lu_packing: Duration, - pub linear_solves: Duration, - pub arnoldi_orthogonalization: Duration, - pub hessenberg_eigensolve: Duration, - pub ritz_reconstruction: Duration, - pub residuals: Duration, - pub sorting: Duration, - pub solve_calls: usize, - pub arnoldi_steps: usize, - pub candidate_count: usize, - pub returned_pairs: usize, - pub lu_l_nnz: usize, - pub lu_u_nnz: usize, - pub max_residual: f64, -} - -pub fn selected_sparse_shift_invert_eigenpairs( - mat: &SparseMatrix, - num_modes: usize, - guess_value: Complex64, - initial_vector: Option<&[Complex64]>, - options: ShiftInvertOptions, -) -> Result, String> { - selected_sparse_shift_invert_native_eigenpairs( - mat, - num_modes, - guess_value, - initial_vector, - options, - ) -} - -pub fn selected_sparse_shift_invert_native_eigenpairs( - mat: &SparseMatrix, - num_modes: usize, - guess_value: Complex64, - initial_vector: Option<&[Complex64]>, - options: ShiftInvertOptions, -) -> Result, String> { - selected_sparse_shift_invert_native_eigenpairs_impl( - mat, - num_modes, - guess_value, - initial_vector, - options, - None, - ) -} - -pub fn profile_sparse_shift_invert_native_eigenpairs( - mat: &SparseMatrix, - num_modes: usize, - guess_value: Complex64, - initial_vector: Option<&[Complex64]>, - options: ShiftInvertOptions, -) -> Result { - let total_start = Instant::now(); - let mut profile = ShiftInvertProfile::default(); - let pairs = selected_sparse_shift_invert_native_eigenpairs_impl( - mat, - num_modes, - guess_value, - initial_vector, - options, - Some(&mut profile), - )?; - profile.total = total_start.elapsed(); - profile.max_residual = pairs.iter().map(|pair| pair.residual).fold(0.0, f64::max); - profile.returned_pairs = pairs.len(); - profile.pairs = pairs; - Ok(profile) -} - -fn selected_sparse_shift_invert_native_eigenpairs_impl( - mat: &SparseMatrix, - num_modes: usize, - guess_value: Complex64, - initial_vector: Option<&[Complex64]>, - options: ShiftInvertOptions, - mut profile: Option<&mut ShiftInvertProfile>, -) -> Result, String> { - if mat.rows != mat.cols { - return Err("eigenvalue matrix must be square".to_string()); - } - if num_modes == 0 { - return Err("num_modes must be positive".to_string()); - } - let n = mat.rows; - let krylov_dim = options.krylov_dim.min(n).max(num_modes + 2); - if krylov_dim < num_modes { - return Err("krylov_dim must be at least num_modes".to_string()); - } - - // Shift-invert solves `(A - sigma I) y = x` inside Arnoldi. Ritz values - // theta of the inverse-shifted operator map back to lambda = sigma + 1/theta. - let shift_start = profile.as_ref().map(|_| Instant::now()); - let shifted = mat.shifted_diagonal(guess_value); - if let (Some(profile), Some(start)) = (profile.as_mut(), shift_start) { - profile.shift_diagonal += start.elapsed(); - } - let factorization = SparseLu::factor_with_profile(&shifted, profile.as_deref_mut())?; - let mut solve_workspace = vec![Complex64::new(0.0, 0.0); mat.rows]; - selected_sparse_shift_invert_native_with_solver( - mat, - num_modes, - guess_value, - initial_vector, - options, - "native_shift_invert", - profile, - |input, output| factorization.solve_into(input, output, &mut solve_workspace), - ) -} - -fn selected_sparse_shift_invert_native_with_solver( - mat: &SparseMatrix, - num_modes: usize, - guess_value: Complex64, - initial_vector: Option<&[Complex64]>, - options: ShiftInvertOptions, - backend: &'static str, - mut profile: Option<&mut ShiftInvertProfile>, - mut solve: F, -) -> Result, String> -where - F: FnMut(&[Complex64], &mut [Complex64]) -> Result<(), String>, -{ - let n = mat.rows; - let krylov_dim = options.krylov_dim.min(n).max(num_modes + 2); - let checkpoint_start = - krylov_dim.min((3 * krylov_dim).div_ceil(4).max((num_modes + 8).max(16))); - let checkpoint_interval = 4; - let stability_tolerance = options.tolerance.sqrt(); - let mut previous_checkpoint_values: Option> = None; - let start = initial_vector - .map(|values| { - if values.len() != n { - return Err("initial vector length does not match matrix size".to_string()); - } - Ok(values.to_vec()) - }) - .unwrap_or_else(|| Ok(default_initial_vector(n)))?; - let mut q_basis = ArnoldiBasis::with_first(normalize_complex_vector(start), krylov_dim + 1); - let mut hessenberg = DMatrix::::zeros(krylov_dim + 1, krylov_dim); - let mut actual_dim = 0usize; - - for col in 0..krylov_dim { - // Native Arnoldi fallback with one reorthogonalization pass. - let solve_start = profile.as_ref().map(|_| Instant::now()); - let mut work = vec![Complex64::new(0.0, 0.0); n]; - solve(q_basis.vector(col), &mut work)?; - if let (Some(profile), Some(start)) = (profile.as_mut(), solve_start) { - profile.linear_solves += start.elapsed(); - profile.solve_calls += 1; - } - let orthogonalization_start = profile.as_ref().map(|_| Instant::now()); - for row in 0..=col { - let basis_vector = q_basis.vector(row); - let projection = complex_dot(basis_vector, &work); - hessenberg[(row, col)] = projection; - axpy(&mut work, basis_vector, -projection); - } - for row in 0..=col { - let basis_vector = q_basis.vector(row); - let projection = complex_dot(basis_vector, &work); - hessenberg[(row, col)] += projection; - axpy(&mut work, basis_vector, -projection); - } - let norm = vector_norm(&work); - actual_dim = col + 1; - hessenberg[(col + 1, col)] = Complex64::new(norm, 0.0); - if let (Some(profile), Some(start)) = (profile.as_mut(), orthogonalization_start) { - profile.arnoldi_orthogonalization += start.elapsed(); - } - if let Some(profile) = profile.as_mut() { - profile.arnoldi_steps = actual_dim; - } - - let exhausted = norm <= options.tolerance || col + 1 == krylov_dim; - let checkpoint_due = actual_dim >= checkpoint_start - && ((actual_dim - checkpoint_start).is_multiple_of(checkpoint_interval) || exhausted); - if checkpoint_due { - let candidates = candidate_eigenpairs_from_hessenberg( - mat, - &q_basis, - &hessenberg, - actual_dim, - num_modes, - guess_value, - backend, - options.tolerance, - profile.as_deref_mut(), - )?; - let converged = candidates_converged(&candidates, num_modes, options.tolerance); - let stable = previous_checkpoint_values.as_ref().is_some_and(|previous| { - candidates_stable(previous, &candidates, stability_tolerance) - }); - previous_checkpoint_values = - Some(candidates.iter().map(|candidate| candidate.value).collect()); - if exhausted || (converged && stable) { - return Ok(candidates); - } - } - if exhausted { - break; - } - scale_vector(&mut work, Complex64::new(1.0 / norm, 0.0)); - q_basis.push(work); - } - if let Some(profile) = profile.as_mut() { - profile.arnoldi_steps = actual_dim; - } - - candidate_eigenpairs_from_hessenberg( - mat, - &q_basis, - &hessenberg, - actual_dim, - num_modes, - guess_value, - backend, - options.tolerance, - profile, - ) -} - -fn candidate_eigenpairs_from_hessenberg( - mat: &SparseMatrix, - q_basis: &ArnoldiBasis, - hessenberg: &DMatrix, - actual_dim: usize, - num_modes: usize, - guess_value: Complex64, - backend: &'static str, - tolerance: f64, - mut profile: Option<&mut ShiftInvertProfile>, -) -> Result, String> { - let h_square = hessenberg - .view((0, 0), (actual_dim, actual_dim)) - .into_owned(); - let hessenberg_start = profile.as_ref().map(|_| Instant::now()); - let theta_values = h_square - .clone() - .schur() - .eigenvalues() - .ok_or_else(|| "failed to compute Hessenberg eigenvalues".to_string())?; - if let (Some(profile), Some(start)) = (profile.as_mut(), hessenberg_start) { - profile.hessenberg_eigensolve += start.elapsed(); - } - let mut theta_candidates = Vec::new(); - for theta in theta_values.iter().copied() { - if theta.norm() <= tolerance { - continue; - } - let lambda = guess_value + Complex64::new(1.0, 0.0) / theta; - theta_candidates.push((lambda, theta)); - } - theta_candidates.sort_by(|(left, _), (right, _)| { - let left_distance = (*left - guess_value).norm(); - let right_distance = (*right - guess_value).norm(); - left_distance - .partial_cmp(&right_distance) - .unwrap_or(std::cmp::Ordering::Equal) - }); - - // Residuals are only a tie-breaker after shift distance. Avoid the - // SVD-based full-vector reconstruction for distant projected eigenvalues - // that cannot survive the final truncation. - let reconstruction_count = theta_candidates - .len() - .min((num_modes * 2).max(num_modes + 2)); - let mut candidates = Vec::with_capacity(reconstruction_count); - for (lambda, theta) in theta_candidates.into_iter().take(reconstruction_count) { - let ritz_start = profile.as_ref().map(|_| Instant::now()); - let coeffs = null_vector_for_eigenvalue(&h_square, theta)?; - let vector = combine_ritz_vector(q_basis, &coeffs); - if let (Some(profile), Some(start)) = (profile.as_mut(), ritz_start) { - profile.ritz_reconstruction += start.elapsed(); - } - let residual_start = profile.as_ref().map(|_| Instant::now()); - let residual = sparse_residual_norm(mat, &vector, lambda); - if let (Some(profile), Some(start)) = (profile.as_mut(), residual_start) { - profile.residuals += start.elapsed(); - } - candidates.push((lambda, vector, residual)); - } - if let Some(profile) = profile.as_mut() { - profile.candidate_count = candidates.len(); - } - // Sort by closeness to the requested shift. Residual is the tie-breaker - // because it is the direct measure of eigenpair quality. - let sorting_start = profile.as_ref().map(|_| Instant::now()); - candidates.sort_by(|(left, _, left_res), (right, _, right_res)| { - let left_distance = (*left - guess_value).norm(); - let right_distance = (*right - guess_value).norm(); - left_distance - .partial_cmp(&right_distance) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| { - left_res - .partial_cmp(right_res) - .unwrap_or(std::cmp::Ordering::Equal) - }) - }); - candidates.truncate(num_modes); - if let (Some(profile), Some(start)) = (profile.as_mut(), sorting_start) { - profile.sorting += start.elapsed(); - } - Ok(candidates - .into_iter() - .map(|(value, vector, residual)| Eigenpair { - value, - vector, - residual, - backend, - }) - .collect()) -} - -fn candidates_converged(candidates: &[Eigenpair], num_modes: usize, tolerance: f64) -> bool { - candidates.len() == num_modes - && candidates - .iter() - .all(|candidate| candidate.residual <= tolerance) -} - -fn candidates_stable( - previous_values: &[Complex64], - candidates: &[Eigenpair], - tolerance: f64, -) -> bool { - previous_values.len() == candidates.len() - && previous_values - .iter() - .zip(candidates) - .all(|(previous, candidate)| (*previous - candidate.value).norm() <= tolerance) -} - -#[derive(Clone, Debug)] -struct ArnoldiBasis { - n: usize, - values: Vec, -} - -impl ArnoldiBasis { - fn with_first(first: Vec, capacity: usize) -> Self { - let n = first.len(); - let mut values = Vec::with_capacity(n * capacity); - values.extend_from_slice(&first); - Self { n, values } - } - - fn vector(&self, index: usize) -> &[Complex64] { - let start = index * self.n; - &self.values[start..start + self.n] - } - - fn push(&mut self, vector: Vec) { - assert_eq!(vector.len(), self.n); - self.values.extend_from_slice(&vector); - } -} - -#[derive(Clone, Debug)] -struct SparseLu { - n: usize, - l: PackedTriangularMatrix, - u: PackedTriangularMatrix, - row_perm: Vec, - col_perm: Option>, -} - -#[derive(Clone, Debug)] -struct PackedTriangularMatrix { - col_ptrs: Vec, - row_indices: Vec, - values: Vec, -} - -impl SparseLu { - fn factor_with_profile( - matrix: &SparseMatrix, - mut profile: Option<&mut ShiftInvertProfile>, - ) -> Result { - // Native sparse LU is the fallback linear solve. Validate pivots here - // so `solve` can assume the factors are usable. - if matrix.rows != matrix.cols { - return Err("LU factorization requires a square matrix".to_string()); - } - let ordering_start = profile.as_ref().map(|_| Instant::now()); - let col_perm = amd::order::( - matrix.rows, - matrix.col_ptrs(), - matrix.row_indices(), - &amd::Control::default(), - ) - .map(|(perm, _, _)| perm) - .ok(); - if let (Some(profile), Some(start)) = (profile.as_mut(), ordering_start) { - profile.amd_ordering += start.elapsed(); - } - let factor_start = profile.as_ref().map(|_| Instant::now()); - let (l_columns, u_columns, row_perm) = rlu::lu_decomposition( - matrix.rows, - matrix.row_indices(), - matrix.col_ptrs(), - matrix.values(), - col_perm.as_deref(), - None, - None, - true, - ); - if let (Some(profile), Some(start)) = (profile.as_mut(), factor_start) { - profile.lu_factorization += start.elapsed(); - } - if row_perm.iter().any(|value| value.is_none()) { - return Err("sparse LU failed to find a complete pivot set".to_string()); - } - let row_perm = row_perm - .into_iter() - .map(|value| value.expect("validated pivot set")) - .collect(); - let packing_start = profile.as_ref().map(|_| Instant::now()); - let l = PackedTriangularMatrix::from_columns(l_columns); - let u = PackedTriangularMatrix::from_columns(u_columns); - if let (Some(profile), Some(start)) = (profile.as_mut(), packing_start) { - profile.lu_packing += start.elapsed(); - profile.lu_l_nnz = l.nnz(); - profile.lu_u_nnz = u.nnz(); - } - Ok(Self { - n: matrix.rows, - l, - u, - row_perm, - col_perm, - }) - } - - fn solve_into( - &self, - rhs: &[Complex64], - out: &mut [Complex64], - work: &mut [Complex64], - ) -> Result<(), String> { - if rhs.len() != self.n { - return Err("right-hand side length does not match LU size".to_string()); - } - if out.len() != self.n || work.len() != self.n { - return Err("solve workspace length does not match LU size".to_string()); - } - - if let Some(col_perm) = &self.col_perm { - for (index, value) in rhs.iter().copied().enumerate() { - work[self.row_perm[index]] = value; - } - self.l.lsolve(work); - self.u.usolve(work); - for (index, value) in work.iter().copied().enumerate() { - out[col_perm[index]] = value; - } - } else { - for (index, value) in rhs.iter().copied().enumerate() { - out[self.row_perm[index]] = value; - } - self.l.lsolve(out); - self.u.usolve(out); - } - Ok(()) - } -} - -impl PackedTriangularMatrix { - fn from_columns(columns: rlu::Matrix) -> Self { - let mut col_ptrs = Vec::with_capacity(columns.len() + 1); - let nnz = columns.iter().map(Vec::len).sum(); - let mut row_indices = Vec::with_capacity(nnz); - let mut values = Vec::with_capacity(nnz); - col_ptrs.push(0); - for column in columns { - for (row, value) in column { - row_indices.push(row); - values.push(value); - } - col_ptrs.push(row_indices.len()); - } - Self { - col_ptrs, - row_indices, - values, - } - } - - fn nnz(&self) -> usize { - self.values.len() - } - - fn lsolve(&self, rhs: &mut [Complex64]) { - for col in 0..rhs.len() { - let value = rhs[col]; - for index in self.col_ptrs[col]..self.col_ptrs[col + 1] { - rhs[self.row_indices[index]] -= self.values[index] * value; - } - } - } - - fn usolve(&self, rhs: &mut [Complex64]) { - for col in (0..rhs.len()).rev() { - for index in (self.col_ptrs[col]..self.col_ptrs[col + 1]).rev() { - let row = self.row_indices[index]; - if row == col { - rhs[col] /= self.values[index]; - } else { - rhs[row] -= self.values[index] * rhs[col]; - } - } - } - } -} - -fn null_vector_for_eigenvalue( - mat: &DMatrix, - value: Complex64, -) -> Result, String> { - // Given lambda, an eigenvector is in the null space of A - lambda I. The - // smallest-singular right vector is a stable way to recover that direction - // for the small projected Arnoldi matrix. - let mut shifted = mat.clone(); - let dim = shifted.nrows(); - for index in 0..dim { - shifted[(index, index)] -= value; - } - let svd = SVD::try_new(shifted, false, true, f64::EPSILON * 16.0, 0) - .ok_or_else(|| "failed to compute null vector SVD".to_string())?; - let v_t = svd - .v_t - .ok_or_else(|| "SVD did not return right singular vectors".to_string())?; - let row = v_t.row(v_t.nrows() - 1); - let vector = row.iter().map(|value| value.conj()).collect::>(); - Ok(normalize_complex_vector(vector)) -} - -fn normalize_complex_vector(mut vector: Vec) -> Vec { - let norm = vector_norm(&vector); - if norm > 0.0 { - scale_vector(&mut vector, Complex64::new(1.0 / norm, 0.0)); - } - vector -} - -pub(crate) fn vector_norm(vector: &[Complex64]) -> f64 { - vector - .iter() - .map(|value| value.norm_sqr()) - .sum::() - .sqrt() -} - -fn scale_vector(vector: &mut [Complex64], scale: Complex64) { - for value in vector { - *value *= scale; - } -} - -pub(crate) fn complex_dot(left: &[Complex64], right: &[Complex64]) -> Complex64 { - assert_eq!(left.len(), right.len()); - left.iter() - .zip(right) - .map(|(left, right)| left.conj() * *right) - .sum() -} - -fn axpy(target: &mut [Complex64], vector: &[Complex64], scale: Complex64) { - assert_eq!(target.len(), vector.len()); - for (target, value) in target.iter_mut().zip(vector) { - *target += scale * *value; - } -} - -fn default_initial_vector(n: usize) -> Vec { - (0..n) - .map(|index| { - let x = (index + 1) as f64; - Complex64::new((0.37 * x).sin(), (0.53 * x).cos()) - }) - .collect() -} - -fn combine_ritz_vector(q_basis: &ArnoldiBasis, coeffs: &[Complex64]) -> Vec { - let n = q_basis.n; - let mut out = vec![Complex64::new(0.0, 0.0); n]; - for (basis_index, coeff) in coeffs.iter().copied().enumerate() { - axpy(&mut out, q_basis.vector(basis_index), coeff); - } - normalize_complex_vector(out) -} - -pub(crate) fn sparse_residual_norm( - mat: &SparseMatrix, - vector: &[Complex64], - value: Complex64, -) -> f64 { - // Relative residual ||A v - lambda v|| / ||v||. Python surfaces this in - // solver diagnostics, and the tests use it to catch backend regressions. - let mut residual = mat.matvec(vector); - axpy(&mut residual, vector, -value); - vector_norm(&residual) / vector_norm(vector).max(f64::EPSILON) -} - -pub fn diagonal_eigs_to_effective_index(eigenvalues: &[Complex64]) -> Vec { - eigenvalues - .iter() - .map(|value| (-*value + Complex64::new(0.0, 0.0)).sqrt()) - .collect() -} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 52345af..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,9 +0,0 @@ -pub mod derivatives; -pub mod diagonal_solver; -pub mod eigensolve; -pub mod mode_solver; -pub mod operators; -pub mod sparse_matrix; - -#[cfg(feature = "python")] -mod python_api; diff --git a/src/mode_solver.rs b/src/mode_solver.rs deleted file mode 100644 index 1b842ae..0000000 --- a/src/mode_solver.rs +++ /dev/null @@ -1,569 +0,0 @@ -//! Mode-level solve orchestration. -//! -//! `operators` builds the matrices and `eigensolve` returns transverse -//! eigenvectors. This module turns those eigenvectors into user-facing mode -//! data: effective indices, all six field components, direction handling, -//! deterministic phase, and unit-power normalization. - -use num_complex::Complex64; - -use crate::derivatives::{Tensor3, ETA0}; -use crate::eigensolve::{ - diagonal_eigs_to_effective_index, selected_sparse_shift_invert_eigenpairs, ShiftInvertOptions, -}; -use crate::operators::{assemble_sparse_diagonal_operators, assemble_sparse_tensorial_operator}; -use crate::sparse_matrix::SparseMatrix; - -#[derive(Clone, Debug)] -pub struct SolveDiagnostics { - pub backend: String, - pub operator_size: usize, - pub operator_nnz: usize, - pub residuals: Vec, - pub power_norms: Vec, - pub lorentz_norms: Vec, - pub lorentz_orthogonality_error: f64, -} - -#[derive(Clone, Debug)] -pub struct DiagonalSolveResult { - pub n_complex: Vec, - pub fields: [Vec>; 6], - pub diagnostics: SolveDiagnostics, -} - -#[derive(Clone, Debug)] -struct ModeFields { - ex: Vec, - ey: Vec, - ez: Vec, - hx: Vec, - hy: Vec, - hz: Vec, -} - -impl ModeFields { - fn mutable_components(&mut self) -> [&mut Vec; 6] { - [ - &mut self.ex, - &mut self.ey, - &mut self.ez, - &mut self.hx, - &mut self.hy, - &mut self.hz, - ] - } - - fn add_scaled(&mut self, other: &ModeFields, scale: Complex64) { - for (left, right) in self.ex.iter_mut().zip(&other.ex) { - *left += scale * *right; - } - for (left, right) in self.ey.iter_mut().zip(&other.ey) { - *left += scale * *right; - } - for (left, right) in self.ez.iter_mut().zip(&other.ez) { - *left += scale * *right; - } - for (left, right) in self.hx.iter_mut().zip(&other.hx) { - *left += scale * *right; - } - for (left, right) in self.hy.iter_mut().zip(&other.hy) { - *left += scale * *right; - } - for (left, right) in self.hz.iter_mut().zip(&other.hz) { - *left += scale * *right; - } - } -} - -pub fn solve_diagonal_sparse( - eps: &Tensor3, - mu: &Tensor3, - der_mats: &[SparseMatrix; 4], - cell_areas: &[f64], - num_modes: usize, - neff_guess: f64, - direction: &str, - initial_vector: Option<&[Complex64]>, - options: ShiftInvertOptions, -) -> Result { - // Production diagonal-media path. The eigenvector contains only [Ex, Ey]; - // the remaining components are reconstructed from Maxwell curl equations. - validate_cell_areas(cell_areas, eps[0][0].len())?; - let operators = assemble_sparse_diagonal_operators(eps, mu, der_mats); - let eig_guess = Complex64::new(-(neff_guess * neff_guess), 0.0); - let pairs = selected_sparse_shift_invert_eigenpairs( - &operators.mat, - num_modes, - eig_guess, - initial_vector, - options, - )?; - let operator_size = operators.mat.rows; - let operator_nnz = operators.mat.nnz(); - let mut modes = pairs - .into_iter() - .map(|pair| { - let n_complex = diagonal_eigs_to_effective_index(&[pair.value])[0]; - (n_complex, pair.vector, pair.residual, pair.backend) - }) - .collect::>(); - modes.sort_by(|(left, _, _, _), (right, _, _, _)| { - left.re - .partial_cmp(&right.re) - .unwrap_or(std::cmp::Ordering::Equal) - .reverse() - }); - - let n = eps[0][0].len(); - let dxf = &der_mats[0]; - let dxb = &der_mats[1]; - let dyf = &der_mats[2]; - let dyb = &der_mats[3]; - let inv_eps_zz = SparseMatrix::diagonal( - &eps[2][2] - .iter() - .map(|value| Complex64::new(1.0, 0.0) / *value) - .collect::>(), - ); - let inv_mu_zz = SparseMatrix::diagonal( - &mu[2][2] - .iter() - .map(|value| Complex64::new(1.0, 0.0) / *value) - .collect::>(), - ); - - let mut n_complex = Vec::with_capacity(modes.len()); - let mut mode_fields = Vec::with_capacity(modes.len()); - let mut residuals = Vec::with_capacity(modes.len()); - let backend = modes - .first() - .map(|(_, _, _, backend)| *backend) - .unwrap_or("sparse_shift_invert"); - - for (mode_n, vector, residual, _) in modes { - // Eigenvector layout is [Ex, Ey] on the flattened local Yee grid. - let ex = vector[..n].to_vec(); - let ey = vector[n..].to_vec(); - let denom = Complex64::new(-mode_n.im, mode_n.re); - - let h_field = operators.qmat.matvec(&vector); - let mut hx = h_field[..n] - .iter() - .map(|value| *value / denom) - .collect::>(); - let mut hy = h_field[n..] - .iter() - .map(|value| *value / denom) - .collect::>(); - - // Reconstruct longitudinal fields from the stored transverse fields. - let dxf_ey = dxf.matvec(&ey); - let dyf_ex = dyf.matvec(&ex); - let hz_source = dxf_ey - .iter() - .zip(&dyf_ex) - .map(|(left, right)| *left - *right) - .collect::>(); - let mut hz = inv_mu_zz.matvec(&hz_source); - - let h_partial_field = operators - .q_ep - .matvec(&vector) - .into_iter() - .map(|value| value / denom) - .collect::>(); - let dxb_hy = dxb.matvec(&h_partial_field[n..]); - let dyb_hx = dyb.matvec(&h_partial_field[..n]); - let ez_source = dxb_hy - .iter() - .zip(&dyb_hx) - .map(|(left, right)| *left - *right) - .collect::>(); - let mut ez = inv_eps_zz.matvec(&ez_source); - - let h_scale = Complex64::new(0.0, -1.0) / ETA0; - for component in [&mut hx, &mut hy, &mut hz] { - for value in component { - *value *= h_scale; - } - } - - if direction == "-" { - for value in &mut hx { - *value *= -1.0; - } - for value in &mut hy { - *value *= -1.0; - } - for value in &mut ez { - *value *= -1.0; - } - } - n_complex.push(mode_n); - residuals.push(residual); - mode_fields.push(ModeFields { - ex, - ey, - ez, - hx, - hy, - hz, - }); - } - let orthogonalization = lorentz_orthogonalize_and_normalize(&mut mode_fields, cell_areas); - let fields = collect_mode_fields(mode_fields); - - Ok(DiagonalSolveResult { - n_complex, - fields, - diagnostics: SolveDiagnostics { - backend: backend.to_string(), - operator_size, - operator_nnz, - residuals, - power_norms: orthogonalization.power_norms, - lorentz_norms: orthogonalization.lorentz_norms, - lorentz_orthogonality_error: orthogonalization.lorentz_orthogonality_error, - }, - }) -} - -pub fn solve_tensorial_sparse( - eps: &Tensor3, - mu: &Tensor3, - der_mats: &[SparseMatrix; 4], - cell_areas: &[f64], - num_modes: usize, - neff_guess: f64, - direction: &str, - initial_vector: Option<&[Complex64]>, - options: ShiftInvertOptions, -) -> Result { - // Tensorial path for off-diagonal material tensors and angle/bend coordinate - // transforms. The eigenvector keeps both transverse E and H components. - validate_cell_areas(cell_areas, eps[0][0].len())?; - let operator = assemble_sparse_tensorial_operator(eps, mu, der_mats); - let eig_guess = Complex64::new(neff_guess, 0.0); - let pairs = selected_sparse_shift_invert_eigenpairs( - &operator, - num_modes, - eig_guess, - initial_vector, - options, - )?; - let operator_size = operator.rows; - let operator_nnz = operator.nnz(); - let mut modes = pairs - .into_iter() - .map(|pair| (pair.value, pair.vector, pair.residual, pair.backend)) - .collect::>(); - modes.sort_by(|(left, _, _, _), (right, _, _, _)| { - left.re - .partial_cmp(&right.re) - .unwrap_or(std::cmp::Ordering::Equal) - .reverse() - }); - - let n = eps[0][0].len(); - let dxf = &der_mats[0]; - let dxb = &der_mats[1]; - let dyf = &der_mats[2]; - let dyb = &der_mats[3]; - let inv_eps_zz = SparseMatrix::diagonal( - &eps[2][2] - .iter() - .map(|value| Complex64::new(1.0, 0.0) / *value) - .collect::>(), - ); - let inv_mu_zz = SparseMatrix::diagonal( - &mu[2][2] - .iter() - .map(|value| Complex64::new(1.0, 0.0) / *value) - .collect::>(), - ); - - let mut n_complex = Vec::with_capacity(modes.len()); - let mut mode_fields = Vec::with_capacity(modes.len()); - let mut residuals = Vec::with_capacity(modes.len()); - let backend = modes - .first() - .map(|(_, _, _, backend)| *backend) - .unwrap_or("sparse_shift_invert"); - - for (mode_n, vector, residual, _) in modes { - if vector.len() != 4 * n { - return Err("tensorial eigenvector has an unexpected length".to_string()); - } - // Tensorial eigenvector layout is [Ex, Ey, Hx, Hy]. - let ex = vector[..n].to_vec(); - let ey = vector[n..2 * n].to_vec(); - let mut hx = vector[2 * n..3 * n].to_vec(); - let mut hy = vector[3 * n..4 * n].to_vec(); - - // Reconstruct Hz/Ez while accounting for off-diagonal tensor coupling - // into the eliminated z components. - let dxf_ey = dxf.matvec(&ey); - let dyf_ex = dyf.matvec(&ex); - let hz_source = dxf_ey - .iter() - .zip(&dyf_ex) - .zip(mu[2][0].iter()) - .zip(mu[2][1].iter()) - .zip(hx.iter()) - .zip(hy.iter()) - .map(|(((((dxf_ey, dyf_ex), mu_20), mu_21), hx), hy)| { - *dxf_ey - *dyf_ex - *mu_20 * *hx - *mu_21 * *hy - }) - .collect::>(); - let mut hz = inv_mu_zz.matvec(&hz_source); - - let dxb_hy = dxb.matvec(&hy); - let dyb_hx = dyb.matvec(&hx); - let ez_source = dxb_hy - .iter() - .zip(&dyb_hx) - .zip(eps[2][0].iter()) - .zip(eps[2][1].iter()) - .zip(ex.iter()) - .zip(ey.iter()) - .map(|(((((dxb_hy, dyb_hx), eps_20), eps_21), ex), ey)| { - *dxb_hy - *dyb_hx - *eps_20 * *ex - *eps_21 * *ey - }) - .collect::>(); - let mut ez = inv_eps_zz.matvec(&ez_source); - - let h_scale = Complex64::new(0.0, -1.0) / ETA0; - for component in [&mut hx, &mut hy, &mut hz] { - for value in component { - *value *= h_scale; - } - } - - if direction == "-" { - for value in &mut hx { - *value *= -1.0; - } - for value in &mut hy { - *value *= -1.0; - } - for value in &mut ez { - *value *= -1.0; - } - } - n_complex.push(mode_n); - residuals.push(residual); - mode_fields.push(ModeFields { - ex, - ey, - ez, - hx, - hy, - hz, - }); - } - let orthogonalization = lorentz_orthogonalize_and_normalize(&mut mode_fields, cell_areas); - let fields = collect_mode_fields(mode_fields); - - Ok(DiagonalSolveResult { - n_complex, - fields, - diagnostics: SolveDiagnostics { - backend: backend.to_string(), - operator_size, - operator_nnz, - residuals, - power_norms: orthogonalization.power_norms, - lorentz_norms: orthogonalization.lorentz_norms, - lorentz_orthogonality_error: orthogonalization.lorentz_orthogonality_error, - }, - }) -} - -#[derive(Clone, Debug)] -struct OrthogonalizationDiagnostics { - power_norms: Vec, - lorentz_norms: Vec, - lorentz_orthogonality_error: f64, -} - -fn lorentz_orthogonalize_and_normalize( - modes: &mut [ModeFields], - cell_areas: &[f64], -) -> OrthogonalizationDiagnostics { - // First normalize each reconstructed eigenmode to a sane amplitude. Then - // apply modified Gram-Schmidt with the unconjugated Lorentz reciprocity - // product: - // - // L(a,b) = 1/2 integral[((Ea x Hb) + (Eb x Ha)) . z] dA - // - // The existing unit-power normalization uses H*, which fixes physical - // amplitude. This Lorentz product intentionally does not conjugate either - // mode; it removes residual mixing between modes in the reciprocal - // eigenbasis. - for mode in modes.iter_mut() { - normalize_to_unit_power(mode.mutable_components(), cell_areas); - } - - for mode_index in 0..modes.len() { - for previous_index in 0..mode_index { - let denom = lorentz_overlap(&modes[previous_index], &modes[previous_index], cell_areas); - if denom.norm() <= f64::EPSILON { - continue; - } - let numer = lorentz_overlap(&modes[previous_index], &modes[mode_index], cell_areas); - let coeff = numer / denom; - let previous = modes[previous_index].clone(); - modes[mode_index].add_scaled(&previous, -coeff); - } - normalize_to_unit_power(modes[mode_index].mutable_components(), cell_areas); - apply_dominant_e_phase_convention(modes[mode_index].mutable_components()); - } - - let power_norms = modes - .iter_mut() - .map(|mode| transverse_power(&mode.mutable_components(), cell_areas).norm()) - .collect::>(); - let lorentz_norms = modes - .iter() - .map(|mode| lorentz_overlap(mode, mode, cell_areas)) - .collect::>(); - let mut lorentz_orthogonality_error: f64 = 0.0; - for left in 0..modes.len() { - for right in 0..modes.len() { - if left == right { - continue; - } - let denom = (lorentz_norms[left].norm() * lorentz_norms[right].norm()).sqrt(); - if denom <= f64::EPSILON { - continue; - } - let normalized = - lorentz_overlap(&modes[left], &modes[right], cell_areas).norm() / denom; - lorentz_orthogonality_error = lorentz_orthogonality_error.max(normalized); - } - } - - OrthogonalizationDiagnostics { - power_norms, - lorentz_norms, - lorentz_orthogonality_error, - } -} - -fn collect_mode_fields(modes: Vec) -> [Vec>; 6] { - let mut fields: [Vec>; 6] = std::array::from_fn(|_| Vec::new()); - for mode in modes { - fields[0].push(mode.ex); - fields[1].push(mode.ey); - fields[2].push(mode.ez); - fields[3].push(mode.hx); - fields[4].push(mode.hy); - fields[5].push(mode.hz); - } - fields -} - -fn validate_cell_areas(cell_areas: &[f64], n: usize) -> Result<(), String> { - // Integration weights come from the Python grid edges. Keep this validation - // at the Rust boundary so normalization cannot silently use the wrong grid. - if cell_areas.len() != n { - return Err(format!( - "cell area vector length {} does not match grid size {n}", - cell_areas.len() - )); - } - if cell_areas - .iter() - .any(|value| !value.is_finite() || *value <= 0.0) - { - return Err("cell areas must be finite and positive".to_string()); - } - Ok(()) -} - -fn normalize_to_unit_power(mut components: [&mut Vec; 6], cell_areas: &[f64]) -> f64 { - // Scale all six components together so the transverse Poynting product has - // unit magnitude. This preserves E/H ratios while making returned modes - // deterministic enough for injection and overlap calculations. - let power = transverse_power(&components, cell_areas); - let norm = power.norm(); - if norm <= f64::EPSILON { - return 0.0; - } - let scale = 1.0 / norm.sqrt(); - for component in &mut components { - for value in component.iter_mut() { - *value *= scale; - } - } - transverse_power(&components, cell_areas).norm() -} - -fn transverse_power(components: &[&mut Vec; 6], cell_areas: &[f64]) -> Complex64 { - // Local z-normal power flux: integral((E x H*) . z) dA. - let ex = &components[0]; - let ey = &components[1]; - let hx = &components[3]; - let hy = &components[4]; - ex.iter() - .zip(ey.iter()) - .zip(hx.iter()) - .zip(hy.iter()) - .zip(cell_areas.iter()) - .map(|((((ex, ey), hx), hy), area)| { - (*ex * hy.conj() - *ey * hx.conj()) * Complex64::new(*area, 0.0) - }) - .sum() -} - -fn lorentz_overlap(left: &ModeFields, right: &ModeFields, cell_areas: &[f64]) -> Complex64 { - // Symmetric unconjugated Lorentz product for z-normal mode planes. The - // solver currently reconstructs local fields with propagation along local - // z, and Python later permutes labels back to global axes if needed. - let left_cross_right = left - .ex - .iter() - .zip(&left.ey) - .zip(&right.hx) - .zip(&right.hy) - .zip(cell_areas) - .map(|((((ex, ey), hx), hy), area)| (*ex * *hy - *ey * *hx) * Complex64::new(*area, 0.0)) - .sum::(); - let right_cross_left = right - .ex - .iter() - .zip(&right.ey) - .zip(&left.hx) - .zip(&left.hy) - .zip(cell_areas) - .map(|((((ex, ey), hx), hy), area)| (*ex * *hy - *ey * *hx) * Complex64::new(*area, 0.0)) - .sum::(); - (left_cross_right + right_cross_left) * Complex64::new(0.5, 0.0) -} - -fn apply_dominant_e_phase_convention(mut components: [&mut Vec; 6]) { - // Eigenvectors have arbitrary complex phase. Anchor the dominant electric - // sample to be real-positive so plots and saved fixtures are stable between - // runs/backends. - let mut anchor = Complex64::new(0.0, 0.0); - let mut anchor_norm = 0.0; - for component in components.iter().take(3) { - for value in component.iter() { - let norm = value.norm_sqr(); - if norm > anchor_norm { - anchor = *value; - anchor_norm = norm; - } - } - } - if anchor_norm <= f64::EPSILON { - return; - } - let phase = anchor.conj() / Complex64::new(anchor_norm.sqrt(), 0.0); - for component in &mut components { - for value in component.iter_mut() { - *value *= phase; - } - } -} diff --git a/src/operators.rs b/src/operators.rs deleted file mode 100644 index 683e4bc..0000000 --- a/src/operators.rs +++ /dev/null @@ -1,251 +0,0 @@ -//! Maxwell operator assembly. -//! -//! This module contains only matrix construction. It does not choose modes, -//! reconstruct fields, normalize amplitudes, or know about Python. Keeping the -//! algebra here isolated makes it easier to replace individual pieces without -//! touching the eigensolver. - -use num_complex::Complex64; - -use crate::derivatives::Tensor3; -use crate::sparse_matrix::SparseMatrix; - -#[derive(Clone, Debug)] -pub struct SparseDiagonalOperators { - pub p_mu: SparseMatrix, - pub p_partial: SparseMatrix, - pub q_ep: SparseMatrix, - pub q_partial: SparseMatrix, - pub qmat: SparseMatrix, - pub mat: SparseMatrix, -} - -pub fn assemble_sparse_diagonal_operators( - eps: &Tensor3, - mu: &Tensor3, - der_mats: &[SparseMatrix; 4], -) -> SparseDiagonalOperators { - // Diagonal media reduce to a 2N x 2N transverse-electric eigenproblem for - // [Ex, Ey]. Ez and H are recovered later from Maxwell curl equations. - let n = eps[0][0].len(); - let eps_xx = &eps[0][0]; - let eps_yy = &eps[1][1]; - let eps_zz = &eps[2][2]; - let mu_xx = &mu[0][0]; - let mu_yy = &mu[1][1]; - let mu_zz = &mu[2][2]; - let dxf = &der_mats[0]; - let dxb = &der_mats[1]; - let dyf = &der_mats[2]; - let dyb = &der_mats[3]; - - let zero = SparseMatrix::zeros(n, n); - let inv_eps_zz = SparseMatrix::diagonal( - &eps_zz - .iter() - .map(|value| Complex64::new(1.0, 0.0) / *value) - .collect::>(), - ); - let inv_mu_zz = SparseMatrix::diagonal( - &mu_zz - .iter() - .map(|value| Complex64::new(1.0, 0.0) / *value) - .collect::>(), - ); - - let p_mu = SparseMatrix::block_2x2( - &zero, - &SparseMatrix::diagonal(mu_yy), - &SparseMatrix::diagonal(mu_xx).scale(Complex64::new(-1.0, 0.0)), - &zero, - ); - - let p00 = dxf - .matmul(&inv_eps_zz) - .matmul(dyb) - .scale(Complex64::new(-1.0, 0.0)); - let p01 = dxf.matmul(&inv_eps_zz).matmul(dxb); - let p10 = dyf - .matmul(&inv_eps_zz) - .matmul(dyb) - .scale(Complex64::new(-1.0, 0.0)); - let p11 = dyf.matmul(&inv_eps_zz).matmul(dxb); - let p_partial = SparseMatrix::block_2x2(&p00, &p01, &p10, &p11); - - let q_ep = SparseMatrix::block_2x2( - &zero, - &SparseMatrix::diagonal(eps_yy), - &SparseMatrix::diagonal(eps_xx).scale(Complex64::new(-1.0, 0.0)), - &zero, - ); - - let q00 = dxb - .matmul(&inv_mu_zz) - .matmul(dyf) - .scale(Complex64::new(-1.0, 0.0)); - let q01 = dxb.matmul(&inv_mu_zz).matmul(dxf); - let q10 = dyb - .matmul(&inv_mu_zz) - .matmul(dyf) - .scale(Complex64::new(-1.0, 0.0)); - let q11 = dyb.matmul(&inv_mu_zz).matmul(dxf); - let q_partial = SparseMatrix::block_2x2(&q00, &q01, &q10, &q11); - - let qmat = q_ep.add(&q_partial); - let mat = p_mu.matmul(&qmat).add(&p_partial.matmul(&q_ep)); - - SparseDiagonalOperators { - p_mu, - p_partial, - q_ep, - q_partial, - qmat, - mat, - } -} - -pub fn assemble_sparse_tensorial_operator( - eps: &Tensor3, - mu: &Tensor3, - der_mats: &[SparseMatrix; 4], -) -> SparseMatrix { - // Full tensor media and coordinate transforms cannot use the simpler - // diagonal reduction. Here the eigenvector is [Ex, Ey, Hx, Hy], and Ez/Hz - // are still reconstructed after the solve. Off-diagonal material terms are - // Schur-complemented through eps_zz and mu_zz. - let one = Complex64::new(1.0, 0.0); - let i_scale = Complex64::new(0.0, -1.0); - let dxf = &der_mats[0]; - let dxb = &der_mats[1]; - let dyf = &der_mats[2]; - let dyb = &der_mats[3]; - - let inv_eps_22 = SparseMatrix::diagonal( - &eps[2][2] - .iter() - .map(|value| one / *value) - .collect::>(), - ); - let inv_mu_22 = SparseMatrix::diagonal( - &mu[2][2] - .iter() - .map(|value| one / *value) - .collect::>(), - ); - - // Precompute tensor ratios such as eps_zx / eps_zz. These appear repeatedly - // when eliminating the longitudinal field components. - let eps_20_over_22 = component_div(&eps[2][0], &eps[2][2]); - let eps_21_over_22 = component_div(&eps[2][1], &eps[2][2]); - let eps_02_over_22 = component_div(&eps[0][2], &eps[2][2]); - let eps_12_over_22 = component_div(&eps[1][2], &eps[2][2]); - let mu_20_over_22 = component_div(&mu[2][0], &mu[2][2]); - let mu_21_over_22 = component_div(&mu[2][1], &mu[2][2]); - let mu_02_over_22 = component_div(&mu[0][2], &mu[2][2]); - let mu_12_over_22 = component_div(&mu[1][2], &mu[2][2]); - - // Schur-complemented transverse material blocks. The suffix `_s` means the - // effect of the eliminated z component has already been included. - let mu_10_s = component_sub(&mu[1][0], &component_mul(&mu[1][2], &mu_20_over_22)); - let mu_11_s = component_sub(&mu[1][1], &component_mul(&mu[1][2], &mu_21_over_22)); - let mu_00_s = component_sub(&mu[0][0], &component_mul(&mu[0][2], &mu_20_over_22)); - let mu_01_s = component_sub(&mu[0][1], &component_mul(&mu[0][2], &mu_21_over_22)); - let eps_10_s = component_sub(&eps[1][0], &component_mul(&eps[1][2], &eps_20_over_22)); - let eps_11_s = component_sub(&eps[1][1], &component_mul(&eps[1][2], &eps_21_over_22)); - let eps_00_s = component_sub(&eps[0][0], &component_mul(&eps[0][2], &eps_20_over_22)); - let eps_01_s = component_sub(&eps[0][1], &component_mul(&eps[0][2], &eps_21_over_22)); - - let diag = |values: &[Complex64]| SparseMatrix::diagonal(values); - - // The 4x4 block grid below is the first-order tensorial Maxwell operator. - // The names encode destination/source blocks: a = electric transverse - // components, b = magnetic transverse components, x/y = local grid axes. - let axax = dxf - .matmul(&diag(&eps_20_over_22)) - .scale(-one) - .sub(&diag(&mu_12_over_22).matmul(dyf)); - let axay = dxf - .matmul(&diag(&eps_21_over_22)) - .scale(-one) - .add(&diag(&mu_12_over_22).matmul(dxf)); - let axbx = dxf - .matmul(&inv_eps_22) - .matmul(dyb) - .scale(-one) - .add(&diag(&mu_10_s)); - let axby = dxf.matmul(&inv_eps_22).matmul(dxb).add(&diag(&mu_11_s)); - - let ayax = dyf - .matmul(&diag(&eps_20_over_22)) - .scale(-one) - .add(&diag(&mu_02_over_22).matmul(dyf)); - let ayay = dyf - .matmul(&diag(&eps_21_over_22)) - .scale(-one) - .sub(&diag(&mu_02_over_22).matmul(dxf)); - let aybx = dyf - .matmul(&inv_eps_22) - .matmul(dyb) - .scale(-one) - .sub(&diag(&mu_00_s)); - let ayby = dyf.matmul(&inv_eps_22).matmul(dxb).sub(&diag(&mu_01_s)); - - let bxax = dxb - .matmul(&inv_mu_22) - .matmul(dyf) - .scale(-one) - .add(&diag(&eps_10_s)); - let bxay = dxb.matmul(&inv_mu_22).matmul(dxf).add(&diag(&eps_11_s)); - let bxbx = dxb - .matmul(&diag(&mu_20_over_22)) - .scale(-one) - .sub(&diag(&eps_12_over_22).matmul(dyb)); - let bxby = dxb - .matmul(&diag(&mu_21_over_22)) - .scale(-one) - .add(&diag(&eps_12_over_22).matmul(dxb)); - - let byax = dyb - .matmul(&inv_mu_22) - .matmul(dyf) - .scale(-one) - .sub(&diag(&eps_00_s)); - let byay = dyb.matmul(&inv_mu_22).matmul(dxf).sub(&diag(&eps_01_s)); - let bybx = dyb - .matmul(&diag(&mu_20_over_22)) - .scale(-one) - .add(&diag(&eps_02_over_22).matmul(dyb)); - let byby = dyb - .matmul(&diag(&mu_21_over_22)) - .scale(-one) - .sub(&diag(&eps_02_over_22).matmul(dxb)); - - SparseMatrix::block_grid(&[ - vec![&axax, &axay, &axbx, &axby], - vec![&ayax, &ayay, &aybx, &ayby], - vec![&bxax, &bxay, &bxbx, &bxby], - vec![&byax, &byay, &bybx, &byby], - ]) - .scale(i_scale) -} - -fn component_div(left: &[Complex64], right: &[Complex64]) -> Vec { - left.iter() - .zip(right) - .map(|(left, right)| *left / *right) - .collect() -} - -fn component_mul(left: &[Complex64], right: &[Complex64]) -> Vec { - left.iter() - .zip(right) - .map(|(left, right)| *left * *right) - .collect() -} - -fn component_sub(left: &[Complex64], right: &[Complex64]) -> Vec { - left.iter() - .zip(right) - .map(|(left, right)| *left - *right) - .collect() -} diff --git a/src/python_api.rs b/src/python_api.rs deleted file mode 100644 index f08a37a..0000000 --- a/src/python_api.rs +++ /dev/null @@ -1,371 +0,0 @@ -//! PyO3 extension boundary. -//! -//! The Python package keeps user-facing objects in Python, while Rust owns the -//! numerical kernels. This module translates Python lists of real/imag pairs -//! into Rust tensors, runs the appropriate solver, and converts diagnostics and -//! fields back into simple Python values. - -#[cfg(feature = "python")] -use pyo3::exceptions::PyValueError; -#[cfg(feature = "python")] -use pyo3::prelude::*; - -use crate::{derivatives, diagonal_solver, sparse_matrix}; - -#[cfg(feature = "python")] -type SolvePayload = ( - // complex effective indices - Vec<(f64, f64)>, - // six field components, grouped as component -> mode -> flattened values - Vec>>, - // eigenpair residuals - Vec, - // power-normalization checks - Vec, - // unconjugated Lorentz self-products and the largest normalized - // off-diagonal product after orthogonalization - Vec<(f64, f64)>, - f64, - // backend label and sparse operator diagnostics - String, - usize, - usize, -); - -#[cfg(feature = "python")] -#[pyfunction] -#[pyo3(signature = ( - nx, - ny, - dlf_x, - dlf_y, - dlb_x, - dlb_y, - pmc_x, - pmc_y, - eps_tensor, - mu_tensor, - num_modes, - neff_guess, - direction, - derivative_scale = None, - npml_x = 0, - npml_y = 0, - pml_sigma_max = 2.0, - pml_kappa_min = 1.0, - pml_kappa_max = 3.0, - pml_order = 3, - dmin_pml_x = true, - dmin_pml_y = true, - omega = None, - krylov_dim = 32, - initial_vector = None -))] -fn solve_diagonal_sparse_py( - nx: usize, - ny: usize, - dlf_x: Vec, - dlf_y: Vec, - dlb_x: Vec, - dlb_y: Vec, - pmc_x: bool, - pmc_y: bool, - eps_tensor: Vec>, - mu_tensor: Vec>, - num_modes: usize, - neff_guess: f64, - direction: String, - derivative_scale: Option, - npml_x: usize, - npml_y: usize, - pml_sigma_max: f64, - pml_kappa_min: f64, - pml_kappa_max: f64, - pml_order: i32, - dmin_pml_x: bool, - dmin_pml_y: bool, - omega: Option, - krylov_dim: usize, - initial_vector: Option>, -) -> PyResult { - let n = nx * ny; - let eps = derivatives::tensor_from_flat(&eps_tensor, n).map_err(PyValueError::new_err)?; - let mu = derivatives::tensor_from_flat(&mu_tensor, n).map_err(PyValueError::new_err)?; - let der_mats = sparse_derivative_matrices_for_solve( - (nx, ny), - (&dlf_x, &dlf_y), - (&dlb_x, &dlb_y), - (pmc_x, pmc_y), - &eps, - &mu, - (npml_x, npml_y), - derivatives::PmlProfile { - sigma_max: pml_sigma_max, - kappa_min: pml_kappa_min, - kappa_max: pml_kappa_max, - order: pml_order, - }, - (dmin_pml_x, dmin_pml_y), - omega, - derivative_scale, - ) - .map_err(PyValueError::new_err)?; - let initial_vector = initial_vector.map(|values| { - values - .into_iter() - .map(|(re, im)| num_complex::Complex64::new(re, im)) - .collect::>() - }); - let cell_areas = cell_areas_from_steps(&dlf_x, &dlf_y); - let result = diagonal_solver::solve_diagonal_sparse( - &eps, - &mu, - &der_mats, - &cell_areas, - num_modes, - neff_guess, - &direction, - initial_vector.as_deref(), - diagonal_solver::ShiftInvertOptions { - krylov_dim, - tolerance: 1e-10, - }, - ) - .map_err(PyValueError::new_err)?; - let n_complex = result - .n_complex - .into_iter() - .map(|value| (value.re, value.im)) - .collect(); - let fields = result - .fields - .into_iter() - .map(|component| { - component - .into_iter() - .map(|mode| { - mode.into_iter() - .map(|value| (value.re, value.im)) - .collect::>() - }) - .collect::>() - }) - .collect(); - Ok(( - n_complex, - fields, - result.diagnostics.residuals, - result.diagnostics.power_norms, - result - .diagnostics - .lorentz_norms - .into_iter() - .map(|value| (value.re, value.im)) - .collect(), - result.diagnostics.lorentz_orthogonality_error, - result.diagnostics.backend, - result.diagnostics.operator_size, - result.diagnostics.operator_nnz, - )) -} - -#[cfg(feature = "python")] -#[pyfunction] -#[pyo3(signature = ( - nx, - ny, - dlf_x, - dlf_y, - dlb_x, - dlb_y, - pmc_x, - pmc_y, - eps_tensor, - mu_tensor, - num_modes, - neff_guess, - direction, - derivative_scale = None, - npml_x = 0, - npml_y = 0, - pml_sigma_max = 2.0, - pml_kappa_min = 1.0, - pml_kappa_max = 3.0, - pml_order = 3, - dmin_pml_x = true, - dmin_pml_y = true, - omega = None, - krylov_dim = 32, - initial_vector = None -))] -fn solve_tensorial_sparse_py( - nx: usize, - ny: usize, - dlf_x: Vec, - dlf_y: Vec, - dlb_x: Vec, - dlb_y: Vec, - pmc_x: bool, - pmc_y: bool, - eps_tensor: Vec>, - mu_tensor: Vec>, - num_modes: usize, - neff_guess: f64, - direction: String, - derivative_scale: Option, - npml_x: usize, - npml_y: usize, - pml_sigma_max: f64, - pml_kappa_min: f64, - pml_kappa_max: f64, - pml_order: i32, - dmin_pml_x: bool, - dmin_pml_y: bool, - omega: Option, - krylov_dim: usize, - initial_vector: Option>, -) -> PyResult { - let n = nx * ny; - let eps = derivatives::tensor_from_flat(&eps_tensor, n).map_err(PyValueError::new_err)?; - let mu = derivatives::tensor_from_flat(&mu_tensor, n).map_err(PyValueError::new_err)?; - let der_mats = sparse_derivative_matrices_for_solve( - (nx, ny), - (&dlf_x, &dlf_y), - (&dlb_x, &dlb_y), - (pmc_x, pmc_y), - &eps, - &mu, - (npml_x, npml_y), - derivatives::PmlProfile { - sigma_max: pml_sigma_max, - kappa_min: pml_kappa_min, - kappa_max: pml_kappa_max, - order: pml_order, - }, - (dmin_pml_x, dmin_pml_y), - omega, - derivative_scale, - ) - .map_err(PyValueError::new_err)?; - let initial_vector = initial_vector.map(|values| { - values - .into_iter() - .map(|(re, im)| num_complex::Complex64::new(re, im)) - .collect::>() - }); - let cell_areas = cell_areas_from_steps(&dlf_x, &dlf_y); - let result = diagonal_solver::solve_tensorial_sparse( - &eps, - &mu, - &der_mats, - &cell_areas, - num_modes, - neff_guess, - &direction, - initial_vector.as_deref(), - diagonal_solver::ShiftInvertOptions { - krylov_dim, - tolerance: 1e-10, - }, - ) - .map_err(PyValueError::new_err)?; - let n_complex = result - .n_complex - .into_iter() - .map(|value| (value.re, value.im)) - .collect(); - let fields = result - .fields - .into_iter() - .map(|component| { - component - .into_iter() - .map(|mode| { - mode.into_iter() - .map(|value| (value.re, value.im)) - .collect::>() - }) - .collect::>() - }) - .collect(); - Ok(( - n_complex, - fields, - result.diagnostics.residuals, - result.diagnostics.power_norms, - result - .diagnostics - .lorentz_norms - .into_iter() - .map(|value| (value.re, value.im)) - .collect(), - result.diagnostics.lorentz_orthogonality_error, - result.diagnostics.backend, - result.diagnostics.operator_size, - result.diagnostics.operator_nnz, - )) -} - -#[cfg(feature = "python")] -fn cell_areas_from_steps(dlf_x: &[f64], dlf_y: &[f64]) -> Vec { - // Python supplies mode-plane edges; by this point they have become cell - // widths. Flatten in the same x-major/y-minor order used by material tensors. - let mut areas = Vec::with_capacity(dlf_x.len() * dlf_y.len()); - for dx in dlf_x { - for dy in dlf_y { - areas.push(dx.abs() * dy.abs()); - } - } - areas -} - -#[cfg(feature = "python")] -fn sparse_derivative_matrices_for_solve( - shape: (usize, usize), - dlf: (&[f64], &[f64]), - dlb: (&[f64], &[f64]), - dmin_pmc: (bool, bool), - eps: &derivatives::Tensor3, - mu: &derivatives::Tensor3, - npml: (usize, usize), - pml_profile: derivatives::PmlProfile, - dmin_pml: (bool, bool), - omega: Option, - derivative_scale: Option, -) -> Result<[sparse_matrix::SparseMatrix; 4], String> { - // Build sparse derivative matrices, optionally preconditioned by PML - // stretch matrices and scaled into the nondimensional coordinates used by - // the operator assembly. - let mut der_mats = derivatives::create_d_matrices_sparse(shape, dlf, dlb, dmin_pmc); - if npml.0 > 0 || npml.1 > 0 { - let omega = omega.ok_or_else(|| "omega is required when num_pml is nonzero".to_string())?; - let pml_mats = derivatives::create_s_matrices_sparse_with_profile( - omega, - shape, - npml, - dlf, - dlb, - eps, - mu, - dmin_pml, - &pml_profile, - ); - for (der_mat, pml_mat) in der_mats.iter_mut().zip(pml_mats.iter()) { - *der_mat = pml_mat.matmul(der_mat); - } - } - if let Some(scale) = derivative_scale { - for matrix in &mut der_mats { - *matrix = matrix.scale(num_complex::Complex64::new(scale, 0.0)); - } - } - Ok(der_mats) -} - -#[cfg(feature = "python")] -#[pymodule] -fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> { - m.add_function(wrap_pyfunction!(solve_diagonal_sparse_py, m)?)?; - m.add_function(wrap_pyfunction!(solve_tensorial_sparse_py, m)?)?; - Ok(()) -} diff --git a/src/sparse_matrix.rs b/src/sparse_matrix.rs deleted file mode 100644 index 2beb1e6..0000000 --- a/src/sparse_matrix.rs +++ /dev/null @@ -1,241 +0,0 @@ -use std::collections::BTreeMap; - -use num_complex::Complex64; - -#[derive(Clone, Debug, PartialEq)] -pub struct SparseMatrix { - pub rows: usize, - pub cols: usize, - col_ptrs: Vec, - row_indices: Vec, - values: Vec, -} - -impl SparseMatrix { - pub fn zeros(rows: usize, cols: usize) -> Self { - Self { - rows, - cols, - col_ptrs: vec![0; cols + 1], - row_indices: Vec::new(), - values: Vec::new(), - } - } - - pub fn eye(size: usize) -> Self { - let values = vec![Complex64::new(1.0, 0.0); size]; - Self::diagonal(&values) - } - - pub fn diagonal(values: &[Complex64]) -> Self { - let mut triplets = Vec::with_capacity(values.len()); - for (index, value) in values.iter().copied().enumerate() { - if value != Complex64::new(0.0, 0.0) { - triplets.push((index, index, value)); - } - } - Self::from_triplets(values.len(), values.len(), triplets) - } - - pub fn from_triplets( - rows: usize, - cols: usize, - triplets: Vec<(usize, usize, Complex64)>, - ) -> Self { - let mut by_col = (0..cols) - .map(|_| BTreeMap::::new()) - .collect::>(); - for (row, col, value) in triplets { - assert!(row < rows); - assert!(col < cols); - if value == Complex64::new(0.0, 0.0) { - continue; - } - *by_col[col].entry(row).or_insert(Complex64::new(0.0, 0.0)) += value; - } - Self::from_column_maps(rows, cols, by_col) - } - - fn from_column_maps(rows: usize, cols: usize, by_col: Vec>) -> Self { - assert_eq!(by_col.len(), cols); - let mut col_ptrs = Vec::with_capacity(cols + 1); - let mut row_indices = Vec::new(); - let mut values = Vec::new(); - col_ptrs.push(0); - for col in by_col { - for (row, value) in col { - if value != Complex64::new(0.0, 0.0) { - row_indices.push(row); - values.push(value); - } - } - col_ptrs.push(row_indices.len()); - } - Self { - rows, - cols, - col_ptrs, - row_indices, - values, - } - } - - pub fn nnz(&self) -> usize { - self.values.len() - } - - pub fn col_ptrs(&self) -> &[usize] { - &self.col_ptrs - } - - pub fn row_indices(&self) -> &[usize] { - &self.row_indices - } - - pub fn values(&self) -> &[Complex64] { - &self.values - } - - pub fn shifted_diagonal(&self, shift: Complex64) -> Self { - assert_eq!(self.rows, self.cols); - let mut columns = (0..self.cols) - .map(|_| BTreeMap::::new()) - .collect::>(); - for (col, column) in columns.iter_mut().enumerate().take(self.cols) { - for (row, value) in self.column_entries(col) { - column.insert(row, value); - } - *column.entry(col).or_insert(Complex64::new(0.0, 0.0)) -= shift; - } - Self::from_column_maps(self.rows, self.cols, columns) - } - - pub fn column_entries(&self, col: usize) -> impl Iterator + '_ { - let start = self.col_ptrs[col]; - let end = self.col_ptrs[col + 1]; - self.row_indices[start..end] - .iter() - .copied() - .zip(self.values[start..end].iter().copied()) - } - - pub fn scale(&self, scale: Complex64) -> Self { - let mut out = self.clone(); - for value in &mut out.values { - *value *= scale; - } - out - } - - pub fn add(&self, other: &Self) -> Self { - assert_eq!((self.rows, self.cols), (other.rows, other.cols)); - let mut columns = (0..self.cols) - .map(|_| BTreeMap::::new()) - .collect::>(); - for (col, column) in columns.iter_mut().enumerate().take(self.cols) { - for (row, value) in self.column_entries(col) { - *column.entry(row).or_insert(Complex64::new(0.0, 0.0)) += value; - } - for (row, value) in other.column_entries(col) { - *column.entry(row).or_insert(Complex64::new(0.0, 0.0)) += value; - } - } - Self::from_column_maps(self.rows, self.cols, columns) - } - - pub fn sub(&self, other: &Self) -> Self { - self.add(&other.scale(Complex64::new(-1.0, 0.0))) - } - - pub fn matmul(&self, other: &Self) -> Self { - assert_eq!(self.cols, other.rows); - let mut columns = Vec::with_capacity(other.cols); - for col in 0..other.cols { - let mut accum = BTreeMap::::new(); - for (inner, right) in other.column_entries(col) { - for (row, left) in self.column_entries(inner) { - *accum.entry(row).or_insert(Complex64::new(0.0, 0.0)) += left * right; - } - } - columns.push(accum); - } - Self::from_column_maps(self.rows, other.cols, columns) - } - - pub fn matvec(&self, vector: &[Complex64]) -> Vec { - assert_eq!(self.cols, vector.len()); - let mut out = vec![Complex64::new(0.0, 0.0); self.rows]; - for (col, value) in vector.iter().copied().enumerate() { - if value == Complex64::new(0.0, 0.0) { - continue; - } - for (row, matrix_value) in self.column_entries(col) { - out[row] += matrix_value * value; - } - } - out - } - - pub fn block_2x2(a: &Self, b: &Self, c: &Self, d: &Self) -> Self { - assert_eq!(a.rows, b.rows); - assert_eq!(c.rows, d.rows); - assert_eq!(a.cols, c.cols); - assert_eq!(b.cols, d.cols); - let rows = a.rows + c.rows; - let cols = a.cols + b.cols; - let mut triplets = Vec::with_capacity(a.nnz() + b.nnz() + c.nnz() + d.nnz()); - append_block_triplets(&mut triplets, a, 0, 0); - append_block_triplets(&mut triplets, b, 0, a.cols); - append_block_triplets(&mut triplets, c, a.rows, 0); - append_block_triplets(&mut triplets, d, a.rows, a.cols); - Self::from_triplets(rows, cols, triplets) - } - - pub fn block_grid(blocks: &[Vec<&Self>]) -> Self { - assert!(!blocks.is_empty()); - assert!(!blocks[0].is_empty()); - let block_cols = blocks[0].len(); - assert!(blocks.iter().all(|row| row.len() == block_cols)); - - let row_heights = blocks.iter().map(|row| row[0].rows).collect::>(); - let col_widths = blocks[0].iter().map(|block| block.cols).collect::>(); - for (row_index, row) in blocks.iter().enumerate() { - for (col_index, block) in row.iter().enumerate() { - assert_eq!(block.rows, row_heights[row_index]); - assert_eq!(block.cols, col_widths[col_index]); - } - } - - let rows = row_heights.iter().sum(); - let cols = col_widths.iter().sum(); - let nnz = blocks - .iter() - .flat_map(|row| row.iter()) - .map(|block| block.nnz()) - .sum(); - let mut triplets = Vec::with_capacity(nnz); - let mut row_offset = 0; - for (row_index, row) in blocks.iter().enumerate() { - let mut col_offset = 0; - for (col_index, block) in row.iter().enumerate() { - append_block_triplets(&mut triplets, block, row_offset, col_offset); - col_offset += col_widths[col_index]; - } - row_offset += row_heights[row_index]; - } - Self::from_triplets(rows, cols, triplets) - } -} - -fn append_block_triplets( - triplets: &mut Vec<(usize, usize, Complex64)>, - matrix: &SparseMatrix, - row_offset: usize, - col_offset: usize, -) { - for col in 0..matrix.cols { - for (row, value) in matrix.column_entries(col) { - triplets.push((row + row_offset, col + col_offset, value)); - } - } -} diff --git a/tests/conftest.py b/tests/conftest.py index da5833f..f464adc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +"""Pytest configuration for importing the local source tree.""" + from __future__ import annotations import sys diff --git a/tests/test_micromode_api.py b/tests/test_micromode_api.py index 66018c3..1ad5caf 100644 --- a/tests/test_micromode_api.py +++ b/tests/test_micromode_api.py @@ -1,3 +1,5 @@ +"""Integration-style tests for the public MicroMode API.""" + from __future__ import annotations from collections.abc import Sequence @@ -11,20 +13,24 @@ def _linspace_edges(start: float, stop: float, count: int) -> tuple[float, ...]: + """Return linspace edges used by tests.""" return tuple(float(value) for value in np.linspace(start, stop, count)) def _edge_centers(edges: Sequence[float]) -> np.ndarray: + """Return edge centers used by tests.""" edge_array = np.asarray(edges, dtype=float) return (edge_array[:-1] + edge_array[1:]) / 2 def _solver_info(data: mm.Result) -> dict[str, Any]: + """Return solver info used by tests.""" assert data.solver_info is not None return data.solver_info def _strip_grid(nx: int = 5, ny: int = 4) -> tuple[np.ndarray, tuple[float, ...], tuple[float, ...]]: + """Return strip grid used by tests.""" x_edges = _linspace_edges(-1.0, 1.0, nx + 1) y_edges = _linspace_edges(-0.8, 0.8, ny + 1) x_centers = _edge_centers(x_edges) @@ -34,7 +40,8 @@ def _strip_grid(nx: int = 5, ny: int = 4) -> tuple[np.ndarray, tuple[float, ...] return eps, x_edges, y_edges -def test_grid_api_solves_with_rust_sparse_backend(): +def test_grid_api_solves_with_scipy_solver(): + """Verify grid api solves with scipy solver.""" eps, x_edges, y_edges = _strip_grid(6, 5) freq = mm.C_0 / 1.55 @@ -53,6 +60,8 @@ def test_grid_api_solves_with_rust_sparse_backend(): assert data.field_components["Ex"].shape == (6, 5, 1, 1, 2) assert set(data.field_components) == {"Ex", "Ey", "Ez", "Hx", "Hy", "Hz"} run_info = _solver_info(data)["runs"][0] + assert _solver_info(data)["backend"] == "scipy_arpack_reference" + assert run_info["backend_kind"] == "diagonal_scipy_reference" assert run_info["phase_convention"] == "dominant_e_real_positive" assert run_info["normalization"] == "lorentz_orthogonal_unit_transverse_power" np.testing.assert_allclose(run_info["power_norms"], np.ones(2), rtol=1e-10, atol=1e-10) @@ -68,7 +77,28 @@ def test_grid_api_solves_with_rust_sparse_backend(): assert abs(anchor.imag) <= 1e-10 * max(abs(anchor), 1.0) +def test_scipy_solver_reports_operator_diagnostics(): + """Verify scipy solver reports operator diagnostics.""" + eps, x_edges, y_edges = _strip_grid(5, 4) + + data = mm.solve_grid( + eps_xx=eps, + x_edges=x_edges, + y_edges=y_edges, + wavelength=1.55, + num_modes=1, + target_neff=2.5, + krylov_dim=16, + ) + + run_info = _solver_info(data)["runs"][0] + assert run_info["backend_kind"] == "diagonal_scipy_reference" + assert run_info["operator_size"] == 2 * eps.size + assert run_info["operator_nnz"] > run_info["operator_size"] + + def test_materials_api_matches_component_api_for_diagonal_grid(): + """Verify materials api matches component api for diagonal grid.""" eps, x_edges, y_edges = _strip_grid() freq = mm.C_0 / 1.55 materials = mm.Materials.from_diagonal(eps_xx=eps, x_edges=x_edges, y_edges=y_edges) @@ -94,7 +124,93 @@ def test_materials_api_matches_component_api_for_diagonal_grid(): assert from_materials.field_components["Ex"].shape == (5, 4, 1, 1, 2) +def test_scipy_solver_handles_diagonal_grid(): + """Verify scipy solver handles diagonal grid.""" + eps, x_edges, y_edges = _strip_grid(5, 4) + freq = mm.C_0 / 1.55 + + data = mm.solve_grid( + eps_xx=eps, + x_edges=x_edges, + y_edges=y_edges, + freqs=[freq], + num_modes=2, + target_neff=2.5, + krylov_dim=18, + ) + + run_info = _solver_info(data)["runs"][0] + assert _solver_info(data)["backend"] == "scipy_arpack_reference" + assert run_info["backend_kind"] == "diagonal_scipy_reference" + assert run_info["operator_size"] == 2 * eps.size + assert run_info["operator_nnz"] > run_info["operator_size"] + np.testing.assert_allclose(run_info["power_norms"], np.ones(2), rtol=1e-10, atol=1e-10) + assert run_info["lorentz_orthogonality_error"] < 1e-8 + + +def test_scipy_solver_handles_pml_and_tensorial_paths(): + """Verify scipy solver handles pml and tensorial paths.""" + eps, x_edges, y_edges = _strip_grid(4, 3) + + pml_common = { + "eps_xx": eps, + "x_edges": x_edges, + "y_edges": y_edges, + "freqs": [mm.C_0 / 1.55], + "num_modes": 1, + "target_neff": 2.5, + "pml": (1, 0), + "krylov_dim": 18, + } + pml_data = mm.solve_grid(**pml_common) + + pml_run = _solver_info(pml_data)["runs"][0] + assert pml_run["backend_kind"] == "diagonal_scipy_reference" + assert pml_run["operator_size"] == 2 * eps.size + assert pml_run["operator_nnz"] > pml_run["operator_size"] + + tensor_common = { + "eps_xx": eps, + "eps_yy": np.full_like(eps, 2.2**2), + "eps_zz": np.full_like(eps, 2.0**2), + "eps_xz": np.full_like(eps, 0.01), + "eps_zx": np.full_like(eps, 0.01), + "x_edges": x_edges, + "y_edges": y_edges, + "freqs": [mm.C_0 / 1.55], + "num_modes": 1, + "target_neff": 2.2, + "krylov_dim": 20, + } + tensor_data = mm.solve_grid(**tensor_common) + + tensor_run = _solver_info(tensor_data)["runs"][0] + assert tensor_run["backend_kind"] == "tensorial_scipy_reference" + assert tensor_run["operator_size"] == 4 * eps.size + assert tensor_run["operator_nnz"] > tensor_run["operator_size"] + + +def test_scipy_solver_handles_transformed_grid(): + """Verify scipy solver handles transformed grid.""" + eps, x_edges, y_edges = _strip_grid(4, 3) + + data = mm.solve_grid( + eps_xx=eps, + x_edges=x_edges, + y_edges=y_edges, + freqs=[mm.C_0 / 1.55], + num_modes=1, + target_neff=2.5, + angle_theta=0.08, + angle_phi=0.25, + krylov_dim=20, + ) + + assert _solver_info(data)["runs"][0]["backend_kind"] == "tensorial_scipy_reference" + + def test_materials_api_accepts_full_tensor_grid(): + """Verify materials api accepts full tensor grid.""" x_edges = _linspace_edges(-1.0, 1.0, 5) y_edges = _linspace_edges(-0.8, 0.8, 4) shape = (len(x_edges) - 1, len(y_edges) - 1) @@ -128,6 +244,7 @@ def test_materials_api_accepts_full_tensor_grid(): def test_solver_specs_report_diagnostics_and_persist_to_hdf5(tmp_path): + """Verify solver specs report diagnostics and persist to hdf5.""" eps, x_edges, y_edges = _strip_grid(6, 5) pml = mm.PmlSpec(num_cells=(1, 1), sigma_max=1.6, kappa_min=1.0, kappa_max=2.2, order=2) boundary = mm.BoundarySpec(low=("pec", "pmc")) @@ -164,6 +281,7 @@ def test_solver_specs_report_diagnostics_and_persist_to_hdf5(tmp_path): def test_slice_api_solves_1d_x_slice_and_matches_single_cell_grid(tmp_path): + """Verify slice api solves 1d x slice and matches single cell grid.""" x_edges = _linspace_edges(-1.0, 1.0, 9) x_centers = _edge_centers(x_edges) eps = np.where(np.abs(x_centers) <= 0.3, 3.4**2, 1.44**2) @@ -211,6 +329,7 @@ def test_slice_api_solves_1d_x_slice_and_matches_single_cell_grid(tmp_path): def test_materials_slice_api_supports_y_axis_and_tensor_components(): + """Verify materials slice api supports y axis and tensor components.""" y_edges = _linspace_edges(-0.8, 0.8, 7) shape = (len(y_edges) - 1,) eps_xx = np.full(shape, 2.2**2) @@ -244,6 +363,7 @@ def test_materials_slice_api_supports_y_axis_and_tensor_components(): def test_y_normal_solve_returns_global_component_names(): + """Verify y normal solve returns global component names.""" z_edges = _linspace_edges(-1.0, 1.0, 9) z_centers = _edge_centers(z_edges) eps = np.where(np.abs(z_centers) <= 0.3, 3.4**2, 1.44**2) @@ -269,6 +389,7 @@ def test_y_normal_solve_returns_global_component_names(): def test_x_normal_solve_returns_global_component_names_and_positive_power(): + """Verify x normal solve returns global component names and positive power.""" y_edges = _linspace_edges(-1.0, 1.0, 7) z_edges = _linspace_edges(-0.8, 0.8, 6) y_centers = _edge_centers(y_edges) @@ -298,6 +419,7 @@ def test_x_normal_solve_returns_global_component_names_and_positive_power(): def test_y_normal_power_overlap_is_positive(): + """Verify y normal power overlap is positive.""" z_edges = _linspace_edges(-1.0, 1.0, 9) z_centers = _edge_centers(z_edges) eps = np.where(np.abs(z_centers) <= 0.3, 3.4**2, 1.44**2) @@ -320,6 +442,7 @@ def test_y_normal_power_overlap_is_positive(): def test_materials_subpixel_averaging_helpers(): + """Verify materials subpixel averaging helpers.""" x_edges = _linspace_edges(-1.0, 1.0, 3) y_edges = _linspace_edges(-0.5, 0.5, 3) samples = np.asarray( @@ -352,7 +475,8 @@ def test_materials_subpixel_averaging_helpers(): assert materials.shape == (2, 2) -def test_angle_and_bend_use_tensorial_rust_path(): +def test_angle_and_bend_use_tensorial_path(): + """Verify angle and bend use tensorial path.""" eps, x_edges, y_edges = _strip_grid() data = mm.solve_grid( @@ -375,6 +499,7 @@ def test_angle_and_bend_use_tensorial_rust_path(): def test_full_tensor_grid_supports_angle_and_bend_transform(): + """Verify full tensor grid supports angle and bend transform.""" x_edges = _linspace_edges(-1.0, 1.0, 5) y_edges = _linspace_edges(-0.8, 0.8, 4) shape = (len(x_edges) - 1, len(y_edges) - 1) @@ -400,12 +525,13 @@ def test_full_tensor_grid_supports_angle_and_bend_transform(): krylov_dim=18, ) - assert _solver_info(data)["runs"][0]["backend_kind"] == "tensorial_sparse" + assert _solver_info(data)["runs"][0]["backend_kind"] == "tensorial_scipy_reference" assert np.isfinite(data.n_complex.values).all() assert np.isfinite(data.field_components["Ex"].values).all() def test_spec_can_drive_grid_solve_options(): + """Verify spec can drive grid solve options.""" eps, x_edges, y_edges = _strip_grid() spec = mm.Spec(num_modes=1, target_neff=2.5) @@ -423,6 +549,7 @@ def test_spec_can_drive_grid_solve_options(): def test_result_metrics_io_plotting_and_overlap(tmp_path): + """Verify result metrics io plotting and overlap.""" eps, x_edges, y_edges = _strip_grid(6, 5) data = mm.solve_grid( eps_xx=eps, @@ -465,6 +592,7 @@ def test_result_metrics_io_plotting_and_overlap(tmp_path): def test_result_from_hdf5_accepts_bytes_solver_info(tmp_path): + """Verify result from hdf5 accepts bytes solver info.""" eps, x_edges, y_edges = _strip_grid(6, 5) data = mm.solve_grid( eps_xx=eps, @@ -486,6 +614,7 @@ def test_result_from_hdf5_accepts_bytes_solver_info(tmp_path): def test_result_overlap_for_synthetic_orthogonal_modes(): + """Verify result overlap for synthetic orthogonal modes.""" dims = ("x", "y", "z", "f", "mode_index") coords = { "x": np.asarray([-0.5, 0.5]), @@ -519,6 +648,7 @@ def test_result_overlap_for_synthetic_orthogonal_modes(): def test_sweep_tracks_modes_by_overlap(): + """Verify sweep tracks modes by overlap.""" dims = ("x", "y", "z", "f", "mode_index") coords = { "x": np.asarray([-0.5, 0.5]), @@ -530,10 +660,12 @@ def test_sweep_tracks_modes_by_overlap(): shape = (2, 2, 1, 1, 2) def result(n_values: list[float], order: tuple[str, str]) -> mm.Result: + """Return result used by tests.""" fields = {component: np.zeros(shape, dtype=np.complex128) for component in ("Ex", "Ey", "Ez", "Hx", "Hy", "Hz")} for mode_index, component in enumerate(order): fields[component][..., mode_index] = 1.0 arrays = {component: xr.DataArray(values, dims=dims, coords=coords) for component, values in fields.items()} + solver_info = {"backend": "synthetic", "n_values": n_values} return mm.Result( n_complex=xr.DataArray( [np.asarray(n_values, dtype=np.complex128)], @@ -541,6 +673,7 @@ def result(n_values: list[float], order: tuple[str, str]) -> mm.Result: coords={"f": coords["f"], "mode_index": coords["mode_index"]}, ), field_components=arrays, + solver_info=solver_info, ) first = result([2.2, 1.9], ("Ex", "Ey")) @@ -550,11 +683,13 @@ def result(n_values: list[float], order: tuple[str, str]) -> mm.Result: sweep = mm.Sweep(values=np.asarray([0.4, 0.5]), results=tracked, parameter_name="width") np.testing.assert_allclose(tracked[1].n_complex.values, [[2.18, 1.91]]) + assert tracked[1].solver_info == {"backend": "synthetic", "n_values": [1.91, 2.18]} np.testing.assert_allclose(sweep.n_eff[:, 0], [2.2, 2.18]) assert {"width", "mode_index", "n_eff", "te_fraction"} <= set(sweep.to_dataframe().columns) def test_spec_validation_for_core_options(): + """Verify spec validation for core options.""" spec = mm.Spec( num_modes=2, target_neff=2.0, @@ -579,3 +714,7 @@ def test_spec_validation_for_core_options(): mm.Spec(num_modes=0) with pytest.raises(ValueError, match="bend_axis"): mm.Spec(bend_radius=5.0) + with pytest.raises(ValueError, match="num_cells"): + mm.PmlSpec(num_cells=(1.5, 0)) # type: ignore[arg-type] + with pytest.raises(ValueError, match="order"): + mm.PmlSpec(order=2.5) # type: ignore[arg-type] diff --git a/tests/test_mode_solver_fixtures.py b/tests/test_mode_solver_fixtures.py index 5baf67e..fc284f9 100644 --- a/tests/test_mode_solver_fixtures.py +++ b/tests/test_mode_solver_fixtures.py @@ -1,3 +1,5 @@ +"""Tests for committed mode-solver fixture integrity and local comparisons.""" + from __future__ import annotations from pathlib import Path @@ -21,14 +23,17 @@ def test_smoke_fixture_manifest_and_hashes_are_current(): + """Verify smoke fixture manifest and hashes are current.""" _assert_fixture_manifest(SMOKE_FIXTURE_ROOT) def test_extended_fixture_manifest_and_hashes_are_current(): + """Verify extended fixture manifest and hashes are current.""" _assert_fixture_manifest(EXTENDED_FIXTURE_ROOT) def test_reference_fixture_files_do_not_embed_external_package_name(): + """Verify reference fixture files do not embed external package name.""" needle = bytes.fromhex("746964793364") for root in (SMOKE_FIXTURE_ROOT, EXTENDED_FIXTURE_ROOT): for path in root.rglob("*"): @@ -37,6 +42,7 @@ def test_reference_fixture_files_do_not_embed_external_package_name(): def test_reference_hdf5_files_do_not_store_serialized_solver_metadata(): + """Verify reference hdf5 files do not store serialized solver metadata.""" import h5py for root in (SMOKE_FIXTURE_ROOT, EXTENDED_FIXTURE_ROOT): @@ -46,6 +52,7 @@ def test_reference_hdf5_files_do_not_store_serialized_solver_metadata(): def test_reference_n_complex_matches_summary_payload(): + """Verify reference n complex matches summary payload.""" for root in (SMOKE_FIXTURE_ROOT, EXTENDED_FIXTURE_ROOT): for entry in read_json(manifest_path(root))["cases"]: case_id = entry["case_id"] @@ -60,6 +67,7 @@ def test_reference_n_complex_matches_summary_payload(): def test_reference_field_signatures_match_summary_payload(): + """Verify reference field signatures match summary payload.""" for root in (SMOKE_FIXTURE_ROOT, EXTENDED_FIXTURE_ROOT): for entry in read_json(manifest_path(root))["cases"]: case_id = entry["case_id"] @@ -73,6 +81,7 @@ def test_reference_field_signatures_match_summary_payload(): def test_phase_aligned_relative_error_accepts_global_complex_phase(): + """Verify phase aligned relative error accepts global complex phase.""" golden = np.array([1 + 2j, -3 + 1j, 0.5 - 0.25j]) actual = golden * np.exp(1j * 0.73) @@ -84,6 +93,7 @@ def test_phase_aligned_relative_error_accepts_global_complex_phase(): @pytest.mark.slow def test_local_fixture_comparison_uses_staggered_rasterization_for_z_strips(): + """Verify local fixture comparison uses staggered rasterization for z strips.""" manifest = read_json(manifest_path(EXTENDED_FIXTURE_ROOT)) entries = {entry["case_id"]: entry for entry in manifest["cases"]} @@ -95,6 +105,7 @@ def test_local_fixture_comparison_uses_staggered_rasterization_for_z_strips(): @pytest.mark.slow def test_local_production_fixture_matrix_passes(): + """Verify local production fixture matrix passes.""" from benchmarks.compare_mode_solver_fixtures import _LOCAL_CASES manifest = read_json(manifest_path(EXTENDED_FIXTURE_ROOT)) @@ -115,6 +126,7 @@ def test_local_production_fixture_matrix_passes(): @pytest.mark.slow def test_unsupported_fixture_matrix_is_explicit(): + """Verify unsupported fixture matrix is explicit.""" from benchmarks.compare_mode_solver_fixtures import _LOCAL_CASES manifest = read_json(manifest_path(EXTENDED_FIXTURE_ROOT)) @@ -137,6 +149,7 @@ def test_unsupported_fixture_matrix_is_explicit(): def _assert_fixture_manifest(root: Path) -> None: + """Return assert fixture manifest used by tests.""" manifest = read_json(manifest_path(root)) expected_ids = [case["case_id"] for case in manifest["registered_cases"]] actual_ids = [case["case_id"] for case in manifest["cases"]] @@ -156,6 +169,7 @@ def _assert_fixture_manifest(root: Path) -> None: def _array_from_summary_values(payload: dict) -> np.ndarray: + """Return array from summary values used by tests.""" if "real" in payload: return np.asarray(payload["real"]) + 1j * np.asarray(payload["imag"]) return np.asarray(payload["values"]) diff --git a/uv.lock b/uv.lock index e48e271..5994e59 100644 --- a/uv.lock +++ b/uv.lock @@ -805,30 +805,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" }, ] -[[package]] -name = "maturin" -version = "1.13.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9c/1c/612d23d33ec21b9ae7ece7b3f0dd5f9dfd57b4009e9d2938165869ebd6ae/maturin-1.13.3.tar.gz", hash = "sha256:771e1e9e71a278e56db01552e0d1acfd1464259f9575b6e72842f893cd299079", size = 357934, upload-time = "2026-05-11T07:43:39.027Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/66/18c2aaac0b2a5dea9f1db5984ce83b905ad205cfc7c02d0091e707c0c2e7/maturin-1.13.3-py3-none-linux_armv6l.whl", hash = "sha256:3cc13929ca82aefa4adbf0f2c35419369796213c6fb0eb24e914945f50ef5d8c", size = 10190971, upload-time = "2026-05-11T07:43:10.431Z" }, - { url = "https://files.pythonhosted.org/packages/bc/71/26a988d092e4fd6a9523d46d44400a46cad7cdf3fd206ce702240c748aee/maturin-1.13.3-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:53b08bd075649ce96513ad9abf241a43cb685ed6e9e7790f8dbc2d66e95d8323", size = 19716714, upload-time = "2026-05-11T07:43:36.911Z" }, - { url = "https://files.pythonhosted.org/packages/82/5c/f3fd0e184255d9fc7e272c62af3dfa84c617b2577ef83af9ce615f5279cc/maturin-1.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4cd478e6e4c56251e48ed079b8efd55b30bc5c09cf695a1bdafaeb582ee735a0", size = 10194726, upload-time = "2026-05-11T07:43:07.05Z" }, - { url = "https://files.pythonhosted.org/packages/a9/e1/f4edb69fb647b77c4769a9bfd4d6fb62961e653d164bc277ecdffac3ab61/maturin-1.13.3-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:a2675e25f313034ae6f57388cf14818f87d8961c4a96795287f3e155f59beb11", size = 10172781, upload-time = "2026-05-11T07:43:40.796Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7d/a1be934690cdcc3c6609769ceaad322ab7501c2ee5bafcac1b14d609e403/maturin-1.13.3-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:4667ef609ab446c1b5e0bfe4f9fb99699ab6d8548433f8d1a684256e0b67217f", size = 10682670, upload-time = "2026-05-11T07:43:13.132Z" }, - { url = "https://files.pythonhosted.org/packages/18/f5/372ae19b72ce8f6e37e5864ae4dc5b252ee9fce0619ccc3aa366aa3a7f97/maturin-1.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:3db93337ed97e60ffc878aa8b493cd7ae44d3a5e1a37256db3a4491f57565018", size = 10060363, upload-time = "2026-05-11T07:43:21.107Z" }, - { url = "https://files.pythonhosted.org/packages/cb/5b/c68340cca09368af0df80965dfabed4234205a492a93da00793c7b9aae20/maturin-1.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:1cc0a110b224ca90406b668a3e3c1f5a515062e59e26292f6dbaf5fd4909c6f3", size = 10017551, upload-time = "2026-05-11T07:43:33.916Z" }, - { url = "https://files.pythonhosted.org/packages/28/1e/f90fb2b000bad9e6d850cd5afb88b2f1e2a279cfb4de02ea40078484690e/maturin-1.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:c00ea6428dea17bf616fe93770837634454b28c2de1a876e42ef8036c616079a", size = 13301712, upload-time = "2026-05-11T07:43:26.492Z" }, - { url = "https://files.pythonhosted.org/packages/be/58/1670f68a8f04ccd7b90df11047bd9a046585310e84e1967cc9849cd1c5a3/maturin-1.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:49fd6ab08da28098ccf37afca24cdba72376ba9c1eedf9dd25ff82ed771961ff", size = 10946765, upload-time = "2026-05-11T07:43:16.135Z" }, - { url = "https://files.pythonhosted.org/packages/4b/ac/00c955c2ef134817b1a7bdaa76b0309e9c5291eb17d9ff88069eecd08bc2/maturin-1.13.3-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:b6741d7bf4af97da937528fd1e523c6ab54f53d9a21870fa735d6e67fd88e273", size = 10388661, upload-time = "2026-05-11T07:43:18.727Z" }, - { url = "https://files.pythonhosted.org/packages/97/c6/cbf8a51dde19c19aeba0d9b075095a2effb9b31fd312b1aae3ac79f8aea2/maturin-1.13.3-py3-none-win32.whl", hash = "sha256:0ef257e692cc756c87af5bea95ddfe7d3ac49d3376a7a87f728d63f06e7b6f8b", size = 8901838, upload-time = "2026-05-11T07:43:23.76Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ff/c6a50a59dc8313097d43ac5f4d74df6a500c8cb62b0dc9e054f53e203a48/maturin-1.13.3-py3-none-win_amd64.whl", hash = "sha256:def4a435ea9d2ee93b18ba579dc8c9cf898889a66f312cd379b5e374ec3e3ad6", size = 10340801, upload-time = "2026-05-11T07:43:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/6c/93/e32e79333f0902ba292b996f504f5f06be59587f7d02ab8d5ed1e3066445/maturin-1.13.3-py3-none-win_arm64.whl", hash = "sha256:2389fe92d017cea9d94e521fa0175314a4c52f79a1057b901fbc9f8686ef7d0b", size = 9706562, upload-time = "2026-05-11T07:43:31.743Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -847,13 +823,14 @@ dependencies = [ { name = "matplotlib" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "xarray", version = "2025.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "xarray", version = "2026.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] [package.optional-dependencies] dev = [ - { name = "maturin" }, { name = "packaging" }, { name = "pkginfo" }, { name = "pyright" }, @@ -869,7 +846,6 @@ dev = [ requires-dist = [ { name = "h5py", specifier = ">=3.10,<4.0" }, { name = "matplotlib", specifier = ">=3.8,<4.0" }, - { name = "maturin", marker = "extra == 'dev'", specifier = ">=1.7,<2" }, { name = "numpy", specifier = ">=2.2.6,<2.5.0" }, { name = "packaging", marker = "extra == 'dev'", specifier = ">=24.2" }, { name = "pkginfo", marker = "extra == 'dev'", specifier = ">=1.12.1.2" }, @@ -878,6 +854,7 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5,<8" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.6,<4.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0,<1" }, + { name = "scipy", specifier = ">=1.11,<2.0" }, { name = "tomli", marker = "extra == 'dev'", specifier = ">=2,<3" }, { name = "twine", marker = "extra == 'dev'", specifier = ">=5,<7" }, { name = "xarray", specifier = ">=2023.8,<2026.5.0" }, @@ -1457,6 +1434,121 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, ] +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, + { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, + { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, + { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, + { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, + { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, + { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, + { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, + { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, + { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, + { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11' and sys_platform == 'win32'", + "python_full_version >= '3.11' and sys_platform == 'emscripten'", + "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, +] + [[package]] name = "secretstorage" version = "3.5.0"