Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f28a5fd
fix(vis): make mat_vis_client optional — pymat must import without it
gerchowl Aug 5, 2026
6d04158
feat(optical): wavelength curves, surface-finish catalogue, declared …
gerchowl Aug 14, 2026
e17a812
feat(rust): rs-materials 0.3.0 — structured fields, curves, uncertain…
gerchowl Aug 14, 2026
21ad1ca
docs(adr): ADR-0004 — optical transport, surface finishes, the substa…
gerchowl Aug 14, 2026
3ff7f51
docs(data): state the dispersion-ownership rule once, at the top of s…
gerchowl Aug 14, 2026
6ff7c53
fix(loader): root materials lost grade/temper/treatment/vendor; gate …
gerchowl Aug 14, 2026
a4abc1a
docs(briefs): record the loader-parity gate and the two bugs it surfaced
gerchowl Aug 14, 2026
88ece0c
fix(schema): close three loader asymmetries found in review
gerchowl Aug 14, 2026
d87ff89
feat(data): run the refractiveindex.info enricher; derive reflectance…
gerchowl Aug 14, 2026
9c5d58a
docs(adr): sharpen ADR-0004 §3 — the test is measurement, not LUT-bac…
gerchowl Aug 14, 2026
2fc0fe8
feat(data): BaSO4 reflectance, SiPM windows, second couplant, non-LUT…
gerchowl Aug 14, 2026
c54706b
docs(adr): ADR-0004 §11 — a photodetector is not a material
gerchowl Aug 14, 2026
fcc734b
docs(briefs): record round 2 — the concrete detector and what re-orde…
gerchowl Aug 14, 2026
956d4b3
docs: retract a superseded claim; record that sensitivity is not a pr…
gerchowl Aug 14, 2026
cceaf26
feat(optical): Kubelka-Munk coefficients — the crosstalk channel, and…
gerchowl Aug 14, 2026
447fa4d
feat(optical): finite-layer K-M split — R(d), T(d), A(d), and reframe…
gerchowl Aug 14, 2026
3328e39
feat(optical): oblique incidence on the K-M accessors; resolve a stru…
gerchowl Aug 14, 2026
a56cfda
fix(optical): retract the grazing-incidence narrative — a diffuse ref…
gerchowl Aug 14, 2026
c3912f7
fix(optical): guard the thick-layer overflow; record the degeneracy t…
gerchowl Aug 14, 2026
801f459
test: mutation-audit the optical gates; close the one non-test it found
gerchowl Aug 14, 2026
8f78197
docs(briefs): round 5 — the discrepancy did not exist, and the data n…
gerchowl Aug 14, 2026
36257e6
feat(data): LSO:Ce intrinsic resolution + non-proportionality; declar…
gerchowl Aug 14, 2026
cb1d5f1
test(integrity): gate the banner-capture defect I fixed twice by hand
gerchowl Aug 14, 2026
f23a199
build(pre-commit): gate the two silent data-shape failures found in #243
gerchowl Aug 14, 2026
d7dfa9c
docs(readme): cover the new public surface — wavelength optics, absen…
gerchowl Aug 18, 2026
f97e097
fix(ci): typos allowlist for TiO2 and Hass; correct a test pinning an…
gerchowl Aug 18, 2026
85cdf13
fix(optical): remove backing_reflectance — it returned negative absor…
gerchowl Aug 18, 2026
aafb11e
style(docs): apply pymarkdown fixes — fenced code blocks and list num…
gerchowl Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,16 @@ jobs:
- name: Run tests
run: cargo test --manifest-path mat-rs/Cargo.toml

# The Python and Rust loaders parse the same TOMLs independently, and
# have drifted before (#157, and the strata brief of 2026-08-14). This
# job is the only one with both toolchains, so the parity gate lives
# here. See tests/test_rs_python_parity.py.
- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Cross-check Rust and Python loaders resolve the same values
run: uv run --extra dev pytest tests/test_rs_python_parity.py -v

pymat-mcp:
name: "Tests (pymat-mcp)"
runs-on: ubuntu-22.04
Expand Down
18 changes: 18 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,24 @@ repos:
files: ^(src/pymat/data/.*\.toml|scripts/check_licenses\.py|\.github/license-ratchet\.txt)$
pass_filenames: false

# Structural gates on the data corpus (#243). Two failure modes that
# stayed invisible for months because neither produces an error:
# - a TOML key with no dataclass field is parsed then DISCARDED by the
# loader's `hasattr` guard (`[esr.optical] reflectivity` was dropped
# on every load from #147 until #243)
# - a key appended past a section banner parses correctly but reads as
# part of the next section, and the next insertion beside it captures
# it (hand-fixed twice before being written down)
# Runs pre-commit rather than only in CI because both defects appear at
# the moment an adjacent thing moves, which is the moment of the edit.
# Stdlib-only — parses properties.py with `ast`, never imports pymat.
- id: check-data-shape
name: data shape (no dropped keys, no misfiled keys)
entry: python scripts/check_data_shape.py
language: python
files: ^(src/pymat/data/.*\.toml|src/pymat/properties\.py|scripts/check_data_shape\.py)$
pass_filenames: false

# Custom PEP-8 naming: trailing-`_` reserved for keyword-collision
# avoidance only. Catches the gap ruff's N-rules don't cover.
# Stdlib-only (ast/keyword/builtins).
Expand Down
12 changes: 12 additions & 0 deletions .typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ metalness = "metalness"
# "PNGs" (plural of the PNG file format) — typos rewrites to "ONGs".
PNGs = "PNGs"
pngs = "pngs"
# "tio" is TiO2 (titanium dioxide) reflective paint — it appears in the
# Geant4 RealSurface finish names (`polishedtioair`, `etchedtioair`,
# `groundtioair`) and in the catalogue keys derived from them (#243).
# The default dictionary rewrites it to "to", which would silently rename
# three measured surfaces.
tio = "tio"
TiO = "TiO"
# G. Hass, "Filmed surfaces for reflecting optics", JOSA 45, 945 (1955) —
# a surname, cited for evaporated-aluminium reflectance. Rewritten to "Hash".
Hass = "Hass"
# "mis-parsed" / "mis-" as a prefix; the dictionary suggests "miss"/"mist".
mis = "mis"
# Deliberate misspelling appearing in a test docstring as an example
# of the kind of TOML key typo the integrity test catches. The actual
# TOML-side enforcement lives in tests/test_toml_integrity.py — this
Expand Down
3 changes: 3 additions & 0 deletions LICENSES-DATA.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,8 @@ py-materials data corpus. Per the licenses, attribution is required.
| `Wikipedia: Lutetium (CRC Handbook of Chemistry & Physics) + PDG` | CC-BY-SA-4.0 | wikipedia:Lutetium |
| `Wikipedia: Tantalum (CRC Handbook of Chemistry & Physics)` | CC-BY-SA-4.0 | wikipedia:Tantalum |
| `Wikipedia: Tantalum (CRC Handbook of Chemistry & Physics) + PDG` | CC-BY-SA-4.0 | wikipedia:Tantalum |
| `bosca_lopez_2023` | CC-BY-4.0 | 10.1038/s41598-023-32689-z |
| `enriquez_mier_y_teran_2020` | CC-BY-4.0 | 10.1186/s40658-020-00291-1 |
| `pdg_2024_atomic_nuclear_properties` | CC-BY-4.0 | pdg.lbl.gov:atomic-nuclear/shielding-concrete |
| `seifert_2012` | CC-BY-3.0 | 10.1088/1748-0221/7/09/P09004 |
| `wikipedia_aln` | CC-BY-SA-4.0 | https://en.wikipedia.org/wiki/Aluminium_nitride |
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,73 @@ assert inconel718.density == 8.22
assert inconel718.properties.mechanical.tensile_strength == 1241
```

## Wavelength-dependent optical properties

Refractive index, attenuation and emission are functions of
wavelength, not scalars. Accessors take nanometres (or a Pint
`Quantity`) and **clamp** outside the measured range rather than
extrapolating — `range_nm` tells you where the data actually stops.

```python
import pymat

bgo = pymat.bgo.properties.optical

# n(lambda) from a CC0 Sellmeier fit, not a single scalar.
assert round(bgo.n_at(420), 3) == 2.198
assert round(bgo.n_at(480), 3) == 2.154

# The scalar is fitted near the emission peak, so it understates n
# at the blue end of the band.
assert bgo.refractive_index == 2.15
assert bgo.n_at(420) > bgo.refractive_index

# Outside the measured range the curve clamps; `range_nm` says where.
lo, hi = bgo.refractive_index_dispersion_curve.range_nm
assert bgo.n_at(lo - 100) == bgo.n_at(lo)
```

## Declared absences

`None` cannot distinguish "nobody looked" from "we looked and the
number does not exist". A declared absence records the second, with
a reason from a closed vocabulary and a note explaining the search.

```python
import pymat

# LYSO's emission spectrum exists only in paywalled figures.
assert pymat.lyso.properties.optical.emission_spectrum is None
assert pymat.lyso.is_absent("optical.emission_spectrum")
assert pymat.lyso.absent("optical.emission_spectrum").reason == "proprietary"

# A property nobody has declared anything about stays silent.
assert pymat.lyso.properties.optical.scattering_length is None
assert not pymat.lyso.is_absent("optical.scattering_length")
```

## Measured surface finishes

`pymat.surfaces` catalogues measured optical *interfaces* — the 21
LBNL and 9 DAVIS look-up tables from Geant4's `RealSurface` 2.2 data
set, plus cited diffuse and specular reflectors. A `Surface` is not a
`Material`: it has no density, formula or mass.

```python
from pymat import surfaces

s = surfaces["davis.polished_esr_grease"]
assert s.lut_surface == "PolishedESRGrease_LUT" # exact G4 enum spelling
assert s.coupling == "optical_contact"
assert s.coupling_index == 1.465 # BC-630 silicone grease

# Air-gap and index-matched coupling are physically different and
# are distinguishable — the same reflector, two measured surfaces.
air = surfaces["davis.polished_esr"]
assert air.coupling == "air_gap"
assert air.reflector_material == s.reflector_material == "esr"
```

## Material Categories

- **Metals**: Stainless steel, aluminum, copper, tungsten, lead, titanium, brass
Expand Down
213 changes: 213 additions & 0 deletions docs/briefs/strata-optical-requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# Brief: optical properties for downstream MC transport (strata)

**Author:** strata light-module work, 2026-08-14
**Consumer:** [strata](https://github.com/gerchowl/strata) — Rust Monte Carlo PET engine
**Strata-side ADR:** ADR-089 (optical/light-tracking module)
**Strata-side issue:** gerchowl/strata#1050

This brief states what strata needs from py-mat, and — equally important — what
strata must NOT ask py-mat to hold. Treat the separation of concerns section as
the contract; the work-list is the consequence.

---

## 1. Separation of concerns (the contract)

The question this brief answers: *when a PET crystal is wrapped in ESR film and
read out by a SiPM, which of those facts is a **material** fact and which is an
**assembly** fact?*

| Layer | Owns | Rationale |
|---|---|---|
| **py-mat** | *Substance* physics: what LYSO **is**. n(λ), bulk attenuation, self-absorption, emission spectrum, decay components, light yield, re-emission QE — with provenance and uncertainty. Plus the **named surface-finish catalogue** (what "PolishedESRGrease" *is* as a measured interface). | These are citable, measured, reusable properties of a substance or a substance pair. They do not change when you move a crystal. |
| **strata-data/optical/\*.parquet** | Flat, resampled, sha-pinned tables derived from py-mat + G4 RealSurface. | Build artifact, regenerable. Strata's established "physics-as-data" chain. Not authored by hand. |
| **`.strata` geometry (from build123d)** | *Assembly* facts: this volume is LYSO; **this face** carries `sipm_grease`; the other five carry `esr_wrap`. | Which face is the readout is a property of the built detector, not of LYSO. Two scanners using identical LYSO differ here. |
| **strata config TOML** | Policy: which model, which toggles, SiPM PDE/SPTR, max bounces. | Run-time choices, not facts about matter. |
| **strata kernel crates** | Algorithms. Own no data. | |

**The load-bearing line:** py-mat owns *what a material and an interface are*;
strata owns *what was built and how it is simulated*. A surface finish is a
material-pair fact (LYSO↔ESR↔air) and belongs here. The *assignment* of that
finish to a particular face of a particular crystal belongs in strata.

**Non-goals for py-mat** — do not add any of these, strata will reject them:
per-volume or per-face assignments; geometry; SiPM electronics config; sampling
tables (ICDFs); anything keyed by a strata volume id.

---

## 2. Current state (verified 2026-08-14)

Good news first — more of this exists than strata was using:

- `OpticalProperties` (`src/pymat/properties.py`) already defines
`refractive_index`, `light_yield`, `decay_time`, `rise_time`, `emission_peak`,
`emission_range`, `absorption_length`, `absorption_coefficient`,
`scattering_length`, `rayleigh_length`, `non_proportionality`,
`intrinsic_resolution_pct_at_662keV`, and more.
- Structured slots already exist and round-trip through the loader:
`emission_spectrum {wavelengths_nm, intensities}`,
`refractive_index_dispersion {wavelengths_nm, n}`,
`decay_components [{tau_ns, fraction}]`.
- T-dependence is a solved problem: `<prop>_curve = {temps_K, values}` with
`_at(T)` accessors, clamping (never extrapolating). Registered for
`refractive_index`, `light_yield`, `decay_time`.
- Inheritance works and is the right answer for finishes-as-variants:
`[stainless.s316L.electropolished]` is the working template.
- Provenance (`_sources` per property path) and uncertainty (`_stddev`,
`{nominal, stddev}`, `{min, max}`) are wired.

The gaps:

1. **Data is sparse.** `[lyso.optical]` carries four numbers
(`refractive_index=1.82`, `light_yield=32000`, `decay_time=41`,
`emission_peak=420`). No absorption length, no spectrum, no dispersion, no
decay components — despite the slots existing.
2. **No wavelength accessor.** `_at(T)` exists for temperature; there is no
`n_at(λ)` / `absorption_length_at(λ)`. The structured λ slots are inert data.
3. **`rs-materials 0.1.0` is far behind the Python side.** It exposes exactly six
`Option<f64>` optical scalars and silently drops every structured field, every
`_curve`, all uncertainty and all provenance. It also still carries
`radiation_length`/`interaction_length` under `OpticalProperties`, which the
Python side moved to `nuclear` in #157 — a live schema drift.
4. **No surface-finish concept at all.** Nowhere to say what `PolishedESR` is.

---

## 3. What strata needs (in priority order)

### P0 — the wavelength accessor + the Rust surface

Without these, everything else is unreachable from strata.

- **`WavelengthCurve`**, mirroring `TempCurve` in `curves.py` — piecewise-linear,
clamp-outside-range, validated at load. Accessors `refractive_index_at(λ)`,
`absorption_length_at(λ)`, and spectrum sampling support.
- **Grow `rs-materials`** to expose the structured fields, curves, uncertainty and
`_sources`. Cut a new version. Strata pins it.
- Fix the `radiation_length`/`interaction_length` drift while you are there.

### P0 — the surface-finish catalogue

A new top-level category (proposal: `surfaces.toml`, group `[surface.*]`). Each
entry describes an **interface**, not a bulk:

```toml
[surface.esr]
name = "3M ESR / Vikuiti specular reflector"
kind = "specular" # specular | diffuse | lut
thickness_um = 65
[surface.esr.optical]
reflectivity = 0.985 # scalar fallback
reflectivity_spectrum = { wavelengths_nm = [...], values = [...] }

[surface.esr.grease] # inherits: ESR, index-matched
name = "ESR with silicone-grease coupling"
coupling = "optical_contact"
[surface.esr.grease.optical]
coupling_index = 1.465

[surface.lyso_polished_esr_grease]
name = "Polished LYSO / ESR / grease"
kind = "lut"
lut_family = "davis" # G4 RealSurface 2.2
lut_surface = "PolishedESRGrease"
pair = ["lyso", "surface.esr.grease"]
```

Two things must be expressible, because they are physically different and strata
currently cannot tell them apart:

- **air gap** (dry-pressed wrap): photon sees crystal→air Fresnel first, then the
reflector. Produces TIR light-piping — the mechanism DOI designs exploit.
- **optical contact** (grease/glue): photon sees crystal→polymer directly.

Also: G4 RealSurface 2.2 ships 21 LBNL and 9 DAVIS measured surfaces. Strata has
those parquets already but no vocabulary to name them. The catalogue is that
vocabulary, and it should carry the citations.

### P1 — populate the scintillators

For `lyso` first, then `bgo`, `gagg`, `gso`, `nai_tl`, `csi_tl`, plastics:

| Field | Why strata needs it |
|---|---|
| `emission_spectrum` | Strata samples emission **monochromatically at the peak** today, which makes every λ-dependent term silently constant. This is the single highest-value field in this brief. |
| `refractive_index_dispersion` | Fresnel and TIR angles are n(λ); with one scalar the critical angle is wrong off-peak. |
| `absorption_length` + spectrum | Currently a hardcoded `200 mm` literal in strata's Rust. |
| `decay_components` | LYSO is not single-exponential; the slow component drives the CTR tail. |
| **self-absorption** — see below | Not currently expressible. |
| `reemit_qe` | Probability a reabsorbed photon is re-emitted. |

**New field needed: self-absorption.** LYSO:Ce has real overlap between the Ce
absorption tail and its own emission tail. On a 20 mm crystal over 5–20 bounces
this reabsorbs an estimated 10–30% of the light, and re-emission (delayed by a
fresh decay draw) puts a slow tail on the timing distribution. Strata must sample
"lost to matrix" and "reabsorbed, may re-emit" as **distinct fates**, so one
lumped `absorption_length` cannot express it. Proposal:

```toml
[lyso.Ce.optical]
absorption_length_matrix = { wavelengths_nm = [...], values = [...] } # true loss
absorption_length_reabs = { wavelengths_nm = [...], values = [...] } # Ce self-abs
reemit_qe = 0.75
```

If the split is not measurable for a given material, say so with an explicit
absent-declaration rather than folding it into one number.

### P1 — `polished_lyso` as an inherited variant

The idiomatic form, needing no new machinery:

```toml
[lyso.Ce.polished]
name = "LYSO:Ce, polished"
treatment = "polished"
[lyso.Ce.polished.optical]
default_surface = "surface.lyso_polished_esr_grease"
```

Everything else inherits. Please confirm this shape works end-to-end and add a
test, since strata intends to rely on it.

### P2 — provenance is not optional

Every number strata consumes must carry `_sources`. Strata's own contract
(`docs/foundations.md`) is that a claim without a gate is a claim that will
silently stop being true; the same standard applies to values crossing the
repo boundary. A value with no citation is worse than an absent one, because
absence is visible.

---

## 4. What strata will do on its side (so you can ignore it)

- Resample py-mat's spectra onto a shared 128-node λ grid (300–800 nm) and emit
flat `#[repr(C)]` `f32` rows into `strata-data/optical/*.parquet`, sha-pinned in
`strata-tables.manifest.toml`, generated by a deterministic script. That layout
is chosen to be device-representable for the GPU kernel; it is strata's problem,
not py-mat's.
- Precompute an emission inverse-CDF for sampling. Also strata's problem.
- Carry per-face finish **assignments** in `.strata`, referencing catalogue names
from here by string key.

py-mat should stay human-authored, cited, unit-carrying and uncertainty-aware.
Do not optimise it for the GPU; strata will do the flattening.

---

## 5. Open questions for the py-mat maintainer

1. Does the surface-finish catalogue belong in py-mat at all, or is an interface a
fundamentally different kind of object deserving its own repo? Strata's view:
it belongs here — it is measured, citable, reusable, and meaningless without
the two materials it joins. But this is your call and it should be an ADR.
2. Is `surfaces.toml` the right home, or should finishes nest under the material
they are measured against?
3. `ceramics.toml:480` gates dispersion data on issue #201 — what is the blocker,
and does it apply to scintillators?
4. Should `rs-materials` grow to full parity with the Python schema, or expose a
deliberately narrower "transport-relevant" subset? Strata prefers full parity
with an explicit subset view on top, so the Rust side never becomes the reason
a field is unusable.
Loading
Loading