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://github.com/QuentinWach/micromode/actions/workflows/tests.yml)
-
+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://github.com/QuentinWach/micromode/actions/workflows/tests.yml)
+
+[](https://pypi.org/project/micromode/)
+
+
## 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 @@
+
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"""
+"""
+
+
+if __name__ == "__main__":
+ main()