diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4b4f4ab..c804f15 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main, master] +permissions: + contents: read + jobs: test-portable: name: Portable tests (Python ${{ matrix.python-version }}) @@ -50,14 +53,54 @@ jobs: run: uv run python scripts/smoke_dist.py --python "$(uv python find ${{ matrix.python-version }})" - name: Upload coverage to Codecov + id: codecov_upload uses: codecov/codecov-action@v4 if: matrix.python-version == '3.13' with: files: ./coverage.xml - fail_ci_if_error: false + fail_ci_if_error: true env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + - name: Generate local coverage badge + if: matrix.python-version == '3.13' && steps.codecov_upload.outcome == 'success' + run: uv run python scripts/generate_coverage_badge.py coverage.xml docs/assets/coverage.svg + + - name: Upload local coverage badge + if: matrix.python-version == '3.13' && steps.codecov_upload.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: coverage-badge + path: docs/assets/coverage.svg + + update-coverage-badge: + name: Update coverage badge + needs: test-portable + runs-on: ubuntu-latest + if: github.event_name == 'push' + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Download local coverage badge + uses: actions/download-artifact@v4 + with: + name: coverage-badge + path: tmp/coverage-badge + + - name: Commit local coverage badge + run: | + cp tmp/coverage-badge/coverage.svg docs/assets/coverage.svg + if ! git diff --quiet -- docs/assets/coverage.svg; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add docs/assets/coverage.svg + git commit -m "Update coverage badge [skip ci]" + git push + fi + test-full: name: Full portable tests runs-on: ubuntu-latest diff --git a/README.md b/README.md index 66dcd41..1704672 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,17 @@ # micromode -An electromagnetic mode solver using the FDFD method on a regular Yee-grid, written in native Rust. - -[![License](https://img.shields.io/github/license/QuentinWach/micromode)](LICENSE) -[![Tests](https://img.shields.io/github/actions/workflow/status/QuentinWach/micromode/tests.yml?branch=main&label=tests)](https://github.com/QuentinWach/micromode/actions/workflows/tests.yml) -![Coverage](https://img.shields.io/badge/coverage-87%25-brightgreen) +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/)**. ```bash pip install micromode ``` +[![License](https://img.shields.io/github/license/QuentinWach/micromode)](LICENSE) +[![Tests](https://img.shields.io/github/actions/workflow/status/QuentinWach/micromode/tests.yml?branch=main&label=tests)](https://github.com/QuentinWach/micromode/actions/workflows/tests.yml) +![Coverage](docs/assets/coverage.svg) +[![PyPI](https://img.shields.io/pypi/v/micromode)](https://pypi.org/project/micromode/) +![Status](https://img.shields.io/badge/status-alpha-orange) + ## Why Use It? @@ -56,7 +58,35 @@ data.to_hdf5("modes.h5") ``` -## High Performance +## Physics + +MicroMode solves the source-free frequency-domain Maxwell equations on a +rasterized Yee mode plane, + +$$ +\nabla\times\mathbf{E}=-i\omega\mu\mathbf{H}, +\qquad +\nabla\times\mathbf{H}=i\omega\epsilon\mathbf{E}, +$$ +with modal fields $\mathbf{E},\mathbf{H}\propto e^{i k_0 n_\mathrm{eff} z}.$ + +On diagonal material grids this becomes a transverse eigenproblem, + +$$ +A_\mathrm{diag} +\begin{bmatrix}E_x\\E_y\end{bmatrix} += +-n_\mathrm{eff}^2 +\begin{bmatrix}E_x\\E_y\end{bmatrix} +$$ + +while full tensor or transformed grids use a first-order tensorial form. The +detailed derivation is in [docs/physics-model.md](docs/physics-model.md), and +the public solver controls are summarized in +[docs/mode-solver-methods.md](docs/mode-solver-methods.md). + + +## Performance MicroMode is designed to make high-performance mode solving available without requiring users to install external solver stacks. The production backend is a @@ -84,14 +114,4 @@ 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. - -On the repository benchmark problem, the **pure Rust backend** solves larger grids -in the same performance class as the previous optional UMFPACK-backed path while -remaining much easier to install and distribute. For example, a release build on -an Apple Silicon development machine solves an `80x50` diagonal benchmark grid -in roughly **`90 ms` for two modes** with residuals around **`1e-12`**. Exact -timings depend on hardware and problem shape, but the important point is -architectural: MicroMode keeps the **deployability of a pure Rust package** -without giving up the sparse-solver performance expected for practical -waveguide grids. +reconstruction so work is spent on the modes that will actually be returned. \ No newline at end of file diff --git a/docs/assets/coverage.svg b/docs/assets/coverage.svg new file mode 100644 index 0000000..66e4f30 --- /dev/null +++ b/docs/assets/coverage.svg @@ -0,0 +1,21 @@ + + coverage: 87% + + + + + + + + + + + + + + coverage + coverage + 87% + 87% + + diff --git a/docs/mode-solver-methods.md b/docs/mode-solver-methods.md new file mode 100644 index 0000000..d144284 --- /dev/null +++ b/docs/mode-solver-methods.md @@ -0,0 +1,81 @@ +# Mode Solver Methods + +`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`. + +## Material Grids + +- `Materials.from_diagonal(...)` builds scalar or diagonal-anisotropic grids. + These usually use the faster diagonal sparse formulation. +- `Materials.from_components(...)` accepts full \(3\times3\) material tensors. + Any off-diagonal component routes the solve through the tensorial sparse + formulation. +- `Materials.from_slice(...)` creates a 1D mode-plane slice with an invariant + width so integrations and overlaps still have physical weights. +- `Materials.from_subpixel_diagonal(...)` downsamples a high-resolution + diagonal raster with arithmetic, harmonic, geometric, min, or max averaging. + +## Solver Controls + +- `num_modes`: number of modes returned near the requested target. +- `target_neff`: center of the shift-invert search. If omitted, MicroMode uses + a heuristic based on the largest absolute permittivity component. +- `pml`: absorbing boundary thickness and stretch profile via `PmlSpec`. +- `boundary`: low-edge PEC/PMC symmetry settings via `BoundarySpec`. +- `direction`: `"+"` or `"-"` propagation; the backward solve flips the + appropriate magnetic and longitudinal electric signs. +- `components`: optional subset of returned field components. +- `krylov_dim`: dimension of the Arnoldi search space. +- `angle_theta`, `angle_phi`, `bend_radius`, `bend_axis`: transformation-optics + controls that update \(\epsilon\) and \(\mu\) before the sparse solve. + +## Eigenpair Selection + +Internally, eigenpairs are selected with sparse shift-invert Arnoldi [1, 2]. +For a matrix \(A\) and shift \(\sigma\), Arnoldi is applied to + +$$ +(A-\sigma I)^{-1}, +\qquad +\lambda = \sigma + 1/\theta, +$$ + +where \(\theta\) is a Ritz value of the inverse-shifted operator. The diagonal +backend uses \(\sigma=-\texttt{target_neff}^2\); the tensorial backend uses +\(\sigma=\texttt{target_neff}\). + +Returned modes are sorted by decreasing real effective index, normalized to +unit transverse power, + +$$ +\int (\mathbf{E}\times\mathbf{H}^*)\cdot\hat{\mathbf{n}}\,dA, +$$ + +and orthogonalized with the unconjugated Lorentz product + +$$ +L(a,b)=\frac{1}{2}\int +\left[(\mathbf{E}_a\times\mathbf{H}_b) ++(\mathbf{E}_b\times\mathbf{H}_a)\right]\cdot\hat{\mathbf{n}}\,dA. +$$ + +## Result Helpers + +`Result` exposes the post-processing methods users normally need: `n_eff`, +`k_eff`, `mode_area`, `pol_fraction`, `pol_fraction_waveguide`, `modes_info`, +`to_dataframe()`, `overlap()`, `overlap_matrix()`, `plot_field()`, +`plot_field_components()`, `to_hdf5()`, and `Result.from_hdf5()`. + +## References + +[1] W. E. Arnoldi, "The principle of minimized iterations in the solution of the +matrix eigenvalue problem," *Quarterly of Applied Mathematics*, vol. 9, no. 1, +pp. 17-29, 1951. +[AMS record](https://www.ams.org/qam/1951-09-01/S0033-569X-1951-42792-9/). + +[2] R. B. Lehoucq, D. C. Sorensen, and C. Yang, *ARPACK Users' Guide: Solution +of Large-Scale Eigenvalue Problems with Implicitly Restarted Arnoldi Methods*, +SIAM, 1998. +doi:[10.1137/1.9780898719628](https://doi.org/10.1137/1.9780898719628). diff --git a/docs/physics-model.md b/docs/physics-model.md new file mode 100644 index 0000000..7bd531c --- /dev/null +++ b/docs/physics-model.md @@ -0,0 +1,181 @@ +# Physics Model + +MicroMode solves source-free, frequency-domain Maxwell's equations on a +rasterized mode plane, following the same FDFD starting point used by +MaxwellFDFD [1]: + +$$ +\nabla \times \mathbf{E}(\mathbf{r}) += +-i\omega\mu(\mathbf{r},\omega)\mathbf{H}(\mathbf{r}), +\qquad +\nabla \times \mathbf{H}(\mathbf{r}) += +i\omega\epsilon(\mathbf{r},\omega)\mathbf{E}(\mathbf{r}). +$$ + +Here: + +- \(\mathbf{r}\) is position in the local mode-coordinate system; +- \(\mathbf{E}\) and \(\mathbf{H}\) are the electric and magnetic mode fields; +- \(\omega\) is the angular frequency; +- \(\epsilon\) and \(\mu\) are the supplied material tensors. + +Unlike a driven FDFD field solve, MicroMode is a mode solver: there are no +electric or magnetic current sources. It assumes fields vary along the local +propagation axis as + +$$ +\mathbf{E}(x, y, z) = \mathbf{e}(x, y) e^{i k_0 n_\mathrm{eff} z}, +\qquad +\mathbf{H}(x, y, z) = \mathbf{h}(x, y) e^{i k_0 n_\mathrm{eff} z}, +$$ + +where \(k_0 = 2\pi / \lambda_0\) and \(n_\mathrm{eff}\) is the unknown complex +effective index. The transverse fields are discretized by the +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 +operators are dimensionless. On the local Yee grid, the four derivative +matrices are + +$$ +D_{xf}, D_{xb}, D_{yf}, D_{yb} +\approx +\frac{1}{k_0}\partial_x^\mathrm{forward/backward}, +\frac{1}{k_0}\partial_y^\mathrm{forward/backward}. +$$ + +Low-edge PEC/PMC boundary settings modify the derivative stencils, and PMLs +premultiply derivatives by complex stretch matrices: + +$$ +D \leftarrow S^{-1}D,\qquad +s(u) = \kappa(u) + i\frac{\sigma(u)}{\omega\epsilon_0}. +$$ + +The stretch profiles are polynomial functions controlled by `PmlSpec`. The +stretched-coordinate PML form follows the frequency-domain Maxwell literature +summarized by Shin and Fan [3]. + +## Diagonal Materials + +For diagonal material tensors, MicroMode reduces Maxwell's equations to a +transverse electric eigenproblem. With + +$$ +\mathbf{e}_t = +\begin{bmatrix} E_x \\ E_y \end{bmatrix}, +\qquad +A_\mathrm{diag} = +P_\mu Q + P_\partial Q_\epsilon, +$$ + +the solved eigenproblem is + +$$ +A_\mathrm{diag}\mathbf{e}_t = -n_\mathrm{eff}^2 \mathbf{e}_t. +$$ + +The block operators are assembled from the Yee derivatives and diagonal tensor +components: + +$$ +P_\mu = +\begin{bmatrix} +0 & \mu_{yy} \\ +-\mu_{xx} & 0 +\end{bmatrix}, +\qquad +Q_\epsilon = +\begin{bmatrix} +0 & \epsilon_{yy} \\ +-\epsilon_{xx} & 0 +\end{bmatrix}, +$$ + +$$ +P_\partial = +\begin{bmatrix} +-D_{xf}\epsilon_{zz}^{-1}D_{yb} & D_{xf}\epsilon_{zz}^{-1}D_{xb} \\ +-D_{yf}\epsilon_{zz}^{-1}D_{yb} & D_{yf}\epsilon_{zz}^{-1}D_{xb} +\end{bmatrix}, +$$ + +$$ +Q_\partial = +\begin{bmatrix} +-D_{xb}\mu_{zz}^{-1}D_{yf} & D_{xb}\mu_{zz}^{-1}D_{xf} \\ +-D_{yb}\mu_{zz}^{-1}D_{yf} & D_{yb}\mu_{zz}^{-1}D_{xf} +\end{bmatrix}, +\qquad +Q = Q_\epsilon + Q_\partial. +$$ + +After the transverse solve, the remaining field components are reconstructed +from the curl equations: + +$$ +\begin{bmatrix} H_x \\ H_y \end{bmatrix} +\propto +\frac{1}{i n_\mathrm{eff}}Q\mathbf{e}_t, +\qquad +H_z \propto \mu_{zz}^{-1}(D_{xf}E_y - D_{yf}E_x), +$$ + +$$ +E_z \propto \epsilon_{zz}^{-1}(D_{xb}H_y - D_{yb}H_x). +$$ + +## Tensorial Materials + +For full tensor media, including off-diagonal \(\epsilon\)/\(\mu\) terms and +angle or bend coordinate transforms, MicroMode switches to a first-order +tensorial eigenproblem: + +$$ +A_\mathrm{tensor} +\begin{bmatrix} E_x \\ E_y \\ H_x \\ H_y \end{bmatrix} += +n_\mathrm{eff} +\begin{bmatrix} E_x \\ E_y \\ H_x \\ H_y \end{bmatrix}. +$$ + +The longitudinal tensor couplings are eliminated through local Schur +complements such as + +$$ +\epsilon^{(s)}_{\alpha\beta} += +\epsilon_{\alpha\beta} +- +\epsilon_{\alpha z}\epsilon_{z\beta}/\epsilon_{zz}, +\qquad +\mu^{(s)}_{\alpha\beta} += +\mu_{\alpha\beta} +- +\mu_{\alpha z}\mu_{z\beta}/\mu_{zz}, +$$ + +then \(E_z\) and \(H_z\) are reconstructed with the off-diagonal coupling terms +included. This is the path used automatically for `Materials.from_components`, +angled solves, and bend solves whenever the transformed tensors are no longer +diagonal. + +## References + +[1] W. Shin, [MaxwellFDFD webpage](https://www.mit.edu/~wsshin/maxwellfdfd.html), 2015. + +[2] K. S. Yee, "Numerical solution of initial boundary value problems involving +Maxwell's equations in isotropic media," *IEEE Transactions on Antennas and +Propagation*, vol. 14, no. 3, pp. 302-307, 1966. +doi:[10.1109/TAP.1966.1138693](https://doi.org/10.1109/TAP.1966.1138693). + +[3] W. Shin and S. Fan, "Choice of the perfectly matched layer boundary +condition for frequency-domain Maxwell's equations solvers," *Journal of +Computational Physics*, vol. 231, no. 8, pp. 3406-3431, 2012. +doi:[10.1016/j.jcp.2012.01.013](https://doi.org/10.1016/j.jcp.2012.01.013). diff --git a/scripts/generate_coverage_badge.py b/scripts/generate_coverage_badge.py new file mode 100644 index 0000000..a55b12e --- /dev/null +++ b/scripts/generate_coverage_badge.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Generate a small local SVG coverage badge from coverage.py XML output.""" + +from __future__ import annotations + +import argparse +import html +from pathlib import Path +import xml.etree.ElementTree as ET + + +def main() -> None: + 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")) + args = parser.parse_args() + + percent = coverage_percent(args.coverage_xml) + args.output_svg.parent.mkdir(parents=True, exist_ok=True) + args.output_svg.write_text(render_badge("coverage", f"{percent:.0f}%", color_for_percent(percent)), encoding="utf-8") + + +def coverage_percent(path: Path) -> float: + root = ET.parse(path).getroot() + line_rate = root.attrib.get("line-rate") + if line_rate is not None: + return float(line_rate) * 100.0 + + lines_valid = int(root.attrib["lines-valid"]) + lines_covered = int(root.attrib["lines-covered"]) + if lines_valid == 0: + return 100.0 + return lines_covered / lines_valid * 100.0 + + +def color_for_percent(percent: float) -> str: + if percent >= 90.0: + return "#4c1" + if percent >= 80.0: + return "#97ca00" + if percent >= 70.0: + return "#a4a61d" + if percent >= 60.0: + return "#dfb317" + return "#e05d44" + + +def render_badge(label: str, message: str, color: str) -> str: + label = html.escape(label) + message = html.escape(message) + label_width = max(50, len(label) * 7 + 10) + message_width = max(36, len(message) * 7 + 10) + width = label_width + message_width + label_center = label_width / 2 + message_center = label_width + message_width / 2 + return f""" + {label}: {message} + + + + + + + + + + + + + + {label} + {label} + {message} + {message} + + +""" + + +if __name__ == "__main__": + main()