Metadata-Version: 2.4
Name: gratinglab
Version: 0.1.0
Summary: Open platform for diffraction grating groove metrology and efficiency analysis
Author: Jake McCoy
License: BSD-3-Clause
Project-URL: Homepage, https://github.com/jamccoy/gratinglab
Keywords: diffraction,grating,efficiency,rcwa,x-ray,spectroscopy,afm,metrology,blaze,pcgrate
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: BSD License
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Physics
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.26
Requires-Dist: scipy>=1.11
Requires-Dist: pydantic>=2.6
Requires-Dist: endstation>=0.1
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: pytest-qt>=4.5; extra == "dev"
Requires-Dist: matplotlib>=3.8; extra == "dev"
Requires-Dist: pandas>=2.1; extra == "dev"
Requires-Dist: ruff>=0.16; extra == "dev"
Provides-Extra: gui
Requires-Dist: matplotlib>=3.8; extra == "gui"
Requires-Dist: PySide6-Essentials>=6.7; extra == "gui"
Requires-Dist: endstation[qt]>=0.1; extra == "gui"
Provides-Extra: metrology
Requires-Dist: matplotlib>=3.9; extra == "metrology"
Provides-Extra: refractiveindex
Requires-Dist: pyyaml>=6; extra == "refractiveindex"
Provides-Extra: mut
Requires-Dist: mutmut<3.3,>=3.2; extra == "mut"
Dynamic: license-file

# gratinglab

An open platform for diffraction grating groove metrology and efficiency analysis —
from an AFM scan of a real grating to its diffraction efficiency, without leaving the
package.

Rigorous grating efficiency analysis today means either paying for PCGrate (integral
method) or assembling a patchwork of open RCWA codes that each carry their own
conventions, normalizations, and blind spots. Nothing open lets you run the *same*
physical problem through several methods and compare honestly — and nothing open
connects a *measured* surface to any of them.

`gratinglab` is built around one idea: **the physical problem and the numerical method
are separate, and the problem spec is serializable.** One `Problem` goes to every solver,
and the disagreement between methods is made visible rather than hidden.

```
AFM scan  ->  BoundaryProfile   (gratinglab.metrology)
                      |          measured period, averaged groove, facet fit
                      v
Problem       (period + profile + materials — no solver fields, ever)
Illumination  (wavelength + direction + polarization)
                      |
                      v
Solver (plugin)  ->  Result (+ provenance)
```

The two halves are one package because the seam between them loses information when
it is a file. A PCGrate `.ggp` boundary carries a *shape* and not a grating: it cannot
hold the period, so the period gets retyped, guessed, or recovered from a sidecar
later. `BoundaryProfile.to_problem()` carries the period the scan measured, and the
fitted facet angle with it.

```python
from gratinglab.metrology.boundary import build_boundary_profile

profile = build_boundary_profile(data, scan_x_size, settings)
problem = profile.to_problem(coating="Au", blaze_angle=29.5)
```

## Status

Pre-alpha. The scalar and integral solvers, the comparison harness, the convergence
harness and the materials layer work end to end — efficiencies are absolute when a
coating is named, from an optical constant table of your own (see Install). RCWA is
contributed and not yet integrated.

## Scope

Two halves. **Metrology** (`gratinglab.metrology`) reads AFM scans — Nanoscope `.spm`
or Gwyddion text exports — and produces blaze angles per groove with row-group
statistics and an ICC correction, plus one averaged groove normalised to a period.
Tip convolution can be undone where the tip actually reached, with the unrecoverable
region marked rather than smoothed over. **Efficiency** is everything else: the problem
spec, the solvers, the comparison and convergence harnesses, the materials layer, and
the resolving power the measured groove count implies.

Both mounts are first-class:

- **In-plane / classical** — UV–Vis–NIR optical engineering.
- **Extreme off-plane (conical), grazing incidence** — soft X-ray spectroscopy, where
  PCGrate is the community standard and general-purpose open RCWA codes are weakest.

Planned method backends, in order:

| Method | Status |
|---|---|
| Scalar (Kirchhoff, X-ray blaze form) | **done**, validated |
| Integral method (boundary integral, conical, undercut-capable) | **done**: perfect conductivity validated orderwise against PCGrate, and finite conductivity (`conductivity="tabulated"`, Goray & Schmidt 2010) with absolute efficiencies and recorded absorption |
| RCWA (in-plane, conical, off-plane; Li factorization, S-matrix) | contributed, not yet integrated |
| C-method (Chandezon) | planned |

An already-computed efficiency table from an external code is a first-class "method" in
the comparison harness, so scalar-vs-RCWA-vs-integral plots are possible without a
licence for the code that produced the reference data:

```python
reference = read_scan("run.txt", method="integral")
align(sweep(problem, illumination, wavelengths, ["scalar", reference]))
```

The caller declares what physics produced an imported file, because the file itself does
not say. Comparison legends then read `scalar vs integral` rather than naming a product,
while `Provenance.version` still records exactly which program and version generated it.

### From a measured surface to a spectrograph number

The scan carries its own groove count, so resolving power needs nothing retyped:

```python
problem = profile.to_problem(coating="Au")     # n_grooves = the grooves averaged
resolving_power(problem, illumination, wavelength=2.0, order=3).r
```

`R = |m|·N` by the Rayleigh criterion, with `line_profile` giving the finite-*N* line
shape it is derived from. A problem whose `n_grooves` is unset is **refused** rather
than treated as infinite: "R is undefined" and "R is very large" are different answers.
Resolving power degraded by real groove-placement error is named as the successor and
deliberately not approximated by the ideal formula.

### The tip is not the surface

An AFM image is the surface dilated by the tip. `tip_correction='erosion'` inverts that
where the inversion exists — a cone-plus-spherical-apex tip, Villarrubia (1997) — and
reports a **certainty map** for where it does not, because the trough a tip never
entered is not recoverable by any algorithm and a correction that quietly filled it in
would be worse than no correction.

It has already earned its keep: on the one real scan whose depth and facet angle
disagree by 7.6°, erosion with the nominal probe recovers *nothing*, and reproducing
the deficit forward takes an apex worn to ~80 nm — so the nominal tip is excluded as
the explanation
([findings](docs/findings.md)).

## Install

```bash
pip install gratinglab                # solvers only: numpy, scipy, pydantic
pip install "gratinglab[metrology]"   # + AFM readers and the boundary pipeline
pip install "gratinglab[gui]"         # + the Qt windows
```

From a checkout, `pip install -e ".[dev,gui,metrology,refractiveindex]"` instead.

No optical constant tables ship with the package, so absolute efficiencies need
your own. A library is a directory of them, one material per file, named for
the material:

| File | From | Range |
|---|---|---|
| `Pt.txt` | a [CXRO](https://henke.lbl.gov/optical_constants/) export | X-ray |
| `Pt.yml` | a [refractiveindex.info](https://refractiveindex.info) file, renamed from its author's name | ultraviolet to infrared |
| `Pt.ari` | a PCGrate table | whatever it covers |

Save one as `~/.gratinglab/materials/Pt.yml` and `coating="Pt"` works.
`GRATINGLAB_MATERIALS` points elsewhere, and `materials.from_cxro_file(path)`
or `from_refractiveindex_file(path)` reads a file without a library. The YAML
files need `pip install "gratinglab[refractiveindex]"`. See
[`SOURCES.md`](src/gratinglab/materials/data/SOURCES.md) for why none ship.

The core stays deliberately light — a solver-only user never installs a plotting
stack — and each subpackage says which extra it needs rather than failing with a bare
`ModuleNotFoundError`.

### Where the measurement data lives

Scans are **not** in the repository. They are research data, and the same rule already
applies to the PCGrate reference corpus:

| Data | Environment variable | Default |
|---|---|---|
| AFM scans | `GRATINGLAB_AFM_DIR` | `~/Documents/afm_scans` |
| PCGrate reference corpus | `GRATINGLAB_REF_DIR` | `~/Documents/diffraction_efficiency` |

Tests that need either **skip cleanly** when it is absent, so the suite is green on a
fresh clone. Most of the metrology suite does not need it at all: it runs against a
committed synthetic scan of a *known* grating
(`tools/metrology/make_synthetic_scan.py`), which is also the control that lets a test
assert the pipeline recovers the period and blaze angle it was given.

## GUI

```bash
.venv/bin/python -m pip install -e ".[gui,metrology]"
gratinglab-gui                 # geometry, solvers, comparison
gratinglab-metrology-gui       # AFM import, blaze angles, boundary profiles
```

Qt (PySide6), so `pip install "gratinglab[gui]"` is self-sufficient — no system toolkit
to install first. PySide6 is LGPLv3 and an optional, dynamically-linked extra, which
leaves this package BSD-3.

Grating geometry lives in a dock on the left (Ctrl+G closes it and hands the width back),
and the tabs are one per concern:

- **Grating Geometry** — a rotatable 3D view of the diffraction cone, with every wave
  vector drawn as an exact unit vector. Because the orders separate in *azimuth* rather
  than polar angle — 93.8° of fan at 0.0° of polar spread for the reference off-plane
  geometry — the "down the cone axis" preset plus a uniform 27× zoom makes them readable
  at true scale. γ is never exaggerated: uniform zoom is a similarity transform, so every
  angle in the view survives it exactly, and the magnification is stated on the canvas.
  Beside it, the dispersion-plane cross-section, a `sin β` ladder carrying evanescent
  orders too, and the groove profile. The picture follows the form as you type.
- **Scalar (Kirchhoff)** — the solve, efficiency per order with per-order visibility, and
  the **provenance panel**: every validity warning verbatim, the summed-efficiency check,
  and whether convergence was actually demonstrated. Nothing is hidden there; a result
  that violates energy conservation says so. **Check convergence…** runs the sweep and
  reports the coarsest defensible setting, with a progress bar and a Cancel that really
  stops it — solvers report per wavelength, so cancellation is not a euphemism for
  looking away.
- **Integral method** — the rigorous backend, with the same panel and the same
  provenance discipline, plus its own boundary condition control (perfect conductivity
  or tabulated optical constants). It is minutes where scalar is milliseconds, so the
  window opens on the fast solver and this tab solves when you ask it to.
- **Setup** — the material library: which optical constants are installed, what range
  each covers, where they came from, and how to add your own. The coating *control*
  lives in the geometry dock, because it is a `Problem` field like the period and one
  `Problem` feeds every tab.

A tab per registered solver is generated from the registry, so a contributed backend
gets its panel by registering rather than by editing the window.

Help carries the theory pages, rendered with typeset math and real tables.

A macOS `.app` can be built with `python tools/make_app.py` (macOS 13+, which is the
PySide6 wheel's own floor); the icon is regenerated by `python tools/make_icon.py`.

## Documentation

| Document | What it holds |
|---|---|
| [`docs/roadmap.md`](docs/roadmap.md) | **Where the project is, what is next, and the open questions.** Start here |
| [`docs/conventions.md`](docs/conventions.md) | **Normative.** Signs, units, normalizations, and the reference errata |
| [`docs/theory/scalar.md`](docs/theory/scalar.md) | Full derivation for the scalar solver, its closed forms and its limits |
| [`docs/theory/integral.md`](docs/theory/integral.md) | The boundary-integral solver: conical formulation, perfect and finite conductivity |
| [`docs/theory/metrology.md`](docs/theory/metrology.md) | What the AFM pipeline assumes, what it measures, and what it does not — including what tip erosion can and cannot give back |
| [`docs/findings.md`](docs/findings.md) | Empirical results, each with the evidence that established it |
| [`docs/references.md`](docs/references.md) | The literature each part is built from, mapped to the part it serves |
| [`docs/mutation-testing.md`](docs/mutation-testing.md) | What is mutated, what is not, and how to read a survivor |
| [`benchmarks/corpus.toml`](benchmarks/corpus.toml) | Geometry for the reference corpus, most of it recovered from the data |

`conventions.md` is worth singling out: read it before writing a solver or filing a bug
about a sign. The short version is time convention `exp(-iωt)`, lossy *n* = n′ + ik,
grating equation `sin α + sin β_m = mλ/(p sin γ)`, efficiencies **absolute**, lengths in
nm, angles in degrees at the API boundary.

A theory page accompanies each solver as it lands, stating its assumptions, its
derivation in the code's own notation, and the conditions under which it stops being
trustworthy.

The [wiki](https://github.com/jamccoy/gratinglab/wiki) is a published copy of `docs/`,
not a second source — edit `docs/` and republish, or the copy people read becomes the
one nobody edits:

```bash
git clone https://github.com/jamccoy/gratinglab.wiki.git ../gratinglab.wiki
.venv/bin/python tools/publish_wiki.py --wiki ../gratinglab.wiki   # then review and push
```

## Development

```bash
.venv/bin/python -m pip install -e ".[dev,gui,metrology,refractiveindex]"
.venv/bin/python -m pytest
.venv/bin/ruff check .
```

Three practices this project relies on:

- **Physics self-checks over reference data.** `checks.check_reciprocity` and
  `checks.check_energy_balance` constrain the *model* rather than comparing against a
  formula derived the same way the solver computes it. They need no reference data and
  apply to every backend.
- **Convergence demonstrated, not assumed.** `convergence.check_convergence` sweeps
  whatever a solver declares as its accuracy knob and reports the coarsest setting it
  can defend — or reports that it found none, which is a result rather than a missing
  one. It requires *three* consecutive values to agree, because quadrature error is
  measurably non-monotone for a blazed profile and one agreement can be an accident
  the next refinement contradicts
  ([findings](docs/findings.md#quadrature-error-is-not-monotone-in-the-number-of-points)).

  ```python
  report = check_convergence(scalar, problem, illumination, wavelengths)
  report.converged_at           # 4096 -- what a production run should use
  report.scan.provenance.converged   # True, at last
  ```
- **Mutation testing.** Deliberately corrupting the physics and confirming the suite
  notices — coverage says a line ran, this asks whether anything would have noticed if
  it were wrong. It has found real gaps both times it was run; see
  [findings](docs/findings.md#mutation-testing-found-a-real-gap) and
  [docs/mutation-testing.md](docs/mutation-testing.md) for how to run it.

  ```bash
  .venv/bin/python -m pip install -e ".[mut]"
  .venv/bin/mutmut run
  ```

## License

BSD-3-Clause.
