diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fc1f9f..2efa5c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8a94c5c..6c59014 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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). diff --git a/.typos.toml b/.typos.toml index 7121b47..c2a6827 100644 --- a/.typos.toml +++ b/.typos.toml @@ -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 diff --git a/LICENSES-DATA.md b/LICENSES-DATA.md index ec75be2..b86d758 100644 --- a/LICENSES-DATA.md +++ b/LICENSES-DATA.md @@ -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 | diff --git a/README.md b/README.md index e84fe91..e1e1e80 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/briefs/strata-optical-requirements.md b/docs/briefs/strata-optical-requirements.md new file mode 100644 index 0000000..23d2334 --- /dev/null +++ b/docs/briefs/strata-optical-requirements.md @@ -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: `_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` 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. diff --git a/docs/briefs/strata-optical-response.md b/docs/briefs/strata-optical-response.md new file mode 100644 index 0000000..333abd8 --- /dev/null +++ b/docs/briefs/strata-optical-response.md @@ -0,0 +1,782 @@ +# Response: optical properties for downstream MC transport + +**From:** py-mat, 2026-08-14 +**Re:** `docs/briefs/strata-optical-requirements.md` (strata light-module), strata ADR-089, gerchowl/strata#1050 +**Decision record:** `docs/decisions/0004-optical-transport-and-surface-finishes.md` +**Branch:** `feat/optical-transport-schema` + +The contract is accepted with two narrowings and three factual corrections. The +P0 work is built and tested. The P1 data work ran into a wall that is itself the +most important thing in this document — read §4 before you plan the sampling +code. + +--- + +## 1. Answers to your four open questions + +### Q1 — Does the surface-finish catalogue belong in py-mat? + +**Yes.** A measured interface is citable, reusable, and meaningless without the +substances it joins; it has exactly the lifecycle of a material property. +Splitting it into a third repo would put a citation boundary in the middle of +one physical story and create a repo whose only job is to depend on this one. + +**But a `Surface` is not a `Material`,** and it is not modelled as one. It has +no density, no formula, no mass. Making it a `Material` would put objects into +`pymat.materials`, `search()` and `mass_from_volume_mm3()` for which those +operations are nonsense. It is a separate frozen dataclass with a separate +registry, sharing the `Source` / `Absent` / `WavelengthCurve` primitives. + +```python +from pymat import surfaces +s = surfaces["davis.polished_esr_grease"] +s.lut_surface # 'PolishedESRGrease_LUT' — exact G4 enum spelling +s.coupling # 'optical_contact' +s.coupling_index # 1.465 +surfaces(coupling="air_gap") # -> 19 entries +``` + +### Q2 — `surfaces.toml`, or nested under the material? + +**Top-level `surfaces.toml`, not nested.** An interface is a relation between +two substances; nesting it under one of them forces an arbitrary choice of owner +and duplicates every entry across every crystal it could touch. ESR-with-grease +would appear under `lyso`, `bgo`, `gagg`, … as copies that drift. + +Your draft schema had `pair = ["lyso", "surface.esr.grease"]`. **Dropped** — it +mixes a material reference with a surface reference in one list, and more +importantly it asserts that the LUT is LYSO-specific. It is not: Geant4 applies +these tables at any dielectric boundary. What the catalogue carries instead is +`reflector_material` and `coupling_material` (real material keys that resolve, +and there is a test that they do), plus the citation to the paper where the +measurement substrate is described. + +### Q3 — What blocks dispersion data at `ceramics.toml:480`? + +**Nothing. It is an ownership rule, not a blocker,** and you should read it as +scoped to one tool's material list rather than as a freeze. + +`scripts/enrich_from_refractiveindex.py` (#164, tests in #201) is an **add-only** +enricher that pulls CC0 dispersion from the Polyanskiy database and writes it +with a `_sources` row. It skips any material that already has dispersion, so +hand-authored data inside its scope would permanently mask the automated pull and +the two would diverge invisibly. Hence "do not hand-author here". + +It applies to scintillators, and this matters to you: + +| Material | Status | What to do | +|---|---|---| +| `nai`, `nai.Tl`, `csi`, `csi.Tl`, `csi.Na`, `bgo` | In scope, **currently empty on disk** | Run the enricher with `--write`. Do not hand-author. | +| `lyso`, `lso`, `gagg`, `labr3`, `pwo`, plastics | Out of scope | Hand-author with citations — but see §4 for LYSO. | + +For BGO specifically the data exists and is clean: Williams et al., *Appl. Opt.* +**35**, 3562 (1996), doi:10.1364/AO.35.003562, republished CC0 by +refractiveindex.info. It gives n = 2.177 at 420 nm and 2.080 at the 480 nm +emission peak — **note that the scalar 2.15 currently in the database sits +between them, so your monochromatic-at-peak sampling is off by ~3% in n at the +peak, in the direction that makes the critical angle wrong.** The action is to +run the enricher, and that is recorded in a comment on `[bgo.optical]`. + +### Q4 — Full parity for `rs-materials`, or a narrower transport view? + +**Neither. Full fidelity for the physics, plus a guarantee instead of a promise.** + +Full parity is parity with *what*? The Python dataclasses carry `printable_fdm`, +`machinability`, `rohs_compliant`, `lead_time_weeks` and a texture-identity +layer. Mirroring those into a Monte Carlo crate is a permanent maintenance and +semver obligation with no consumer. Parity as a goal also fails the moment the +two sides are edited a week apart — which is exactly how the drift you complained +about arises. + +So `rs-materials 0.3.0`: + +- **Typed in full fidelity** for optical, nuclear, mechanical, thermal — + including every structured λ slot, temperature curves, decay components, the + self-absorption split, uncertainty, `_sources` and `_absent`. +- **`Material::raw()`** returns the merged TOML table with parent overlay + applied. This is the guarantee you actually asked for: *the Rust side can + never be the reason a field is unusable*, because every field is always + reachable, typed or not. +- Not mirrored: manufacturing, compliance, sourcing, vis. Reachable via `raw()`. + +--- + +## 2. Where the brief was wrong about py-mat + +Recorded because the brief will be read again later, and because one of these +means you have a bug to fix on your side. + +1. **"rs-materials 0.1.0 … still carries `radiation_length`/`interaction_length` + under `OpticalProperties` — a live schema drift."** + Not true on `main`. The crate was at **0.2.0** and `NuclearProperties` has + existed there, with the #157 migration comment, since before the brief was + written. **You are pinned to a stale crates.io release.** Bump to 0.3.0. + +2. **"exactly six `Option` optical scalars."** Four (`refractive_index`, + `light_yield`, `decay_time`, `emission_peak`), plus three nuclear. The shape + of the complaint — everything structured is dropped — was correct and is fixed. + +3. **The real drift was on the Python side and the brief missed it.** + `sources.py:SHORT_ALIASES` still mapped `"radiation_length"` → + `"optical.radiation_length"` after #157 moved the property to `nuclear`, so + `mat.cite("radiation_length")` silently resolved to a path no TOML writes. + Fixed here. + +And three found while implementing, the first of which is the strongest possible +argument for your own §P2 point, from the opposite direction: + +1. **`[esr.optical] reflectivity = 98.5` had been on disk since #147 and was + silently dropped on every single load** — `OpticalProperties` had no + `reflectivity` field, so the loader's `hasattr` guard swallowed it. A value + can be cited, committed, reviewed, and still not be there. A corpus-wide audit + found four more (`optical.dopant`, `optical.dopant_pct`, + `compliance.flammable`, `compliance.toxic`, plus `electrical.permeability` + filed under the wrong group). All fixed, and there is now a test that fails if + any TOML key anywhere lacks a field to land in. + +2. **Every *root* material silently lost its `grade`, `temper`, `treatment` and + `vendor`.** `loader.py` wrote `grade or parent.grade if parent else None`, + which Python parses as `(grade or parent.grade) if parent else None` — so + with no parent the whole expression collapsed to `None` and the node's own + value was thrown away. `pymat.beryllium.grade` was `None` despite the TOML + saying `"S-200F"`. Eleven grades and six vendors across the corpus. Child + materials were unaffected, which is why it survived this long. + +3. **The Rust side merged child `tags` over the parent's instead of unioning + them**, so `stainless.s316L` reported 4 tags where py-mat reports 7 — + a divergence from the #132 inherit-and-extend rule. + +**Neither of those last two was found by reading code.** They fell out of a +mechanical cross-check, which is now a permanent gate: +`tests/test_rs_python_parity.py` drives the Rust loader and diffs 28 fields +across every material against the Python loader — 144 materials, ~4000 value +pairs including the `_sources` and `_absent` key sets, zero tolerance. It runs in the `rust` CI job, the only one with both +toolchains, and it was mutation-tested by reintroducing bug 5 and confirming it +fails with a readable diff. + +That gate is the real answer to the drift complaint behind your brief. The +schema already had provenance, uncertainty and curves; what it did not have was +anything checking that the two readers of the same file agreed about what it +said. Now it does, and the next time the Rust side lags you will find out from +CI rather than from a downstream simulation being quietly wrong. + +--- + +## 3. Where we narrowed the contract — two disagreements + +### 3a. The catalogue holds *measured* interfaces only + +Geant4's `G4OpticalSurfaceFinish` has 39 values and they are not the same kind +of thing. The catalogue ships **30**: the 21 LBNL LUTs that have `.dat` files +and the 9 DAVIS LUTs. Excluded on purpose: + +- **The 6 analytic UNIFIED/GLISUR finishes** (`polished`, `ground`, + `polishedfrontpainted`, …). No data file. They are model selections + parameterised by a fitted `sigma_alpha`. Not measured, not citable — **these + are yours**, in the config TOML where run-time policy already lives. +- **`polishedair`, `etchedair`, `groundair`.** These *are* `dielectric_LUT` enum + members, but `ReadLUTFile()` finds no `.dat` for them. They are named in a + comment in `surfaces.toml` so the omission is greppable rather than silent. + +If you look up `surfaces["polished"]` you get a `KeyError` that explains this +rather than a miss. + +### 3b. `default_surface` on a material is rejected + +Your P1 proposal: + +```toml +[lyso.Ce.polished] +treatment = "polished" +[lyso.Ce.polished.optical] +default_surface = "surface.lyso_polished_esr_grease" # <- rejected +``` + +**The variant is accepted, built and tested** (`pymat.lyso.Ce.polished`, +`tests/test_surfaces.py::TestInheritedVariant`, 8 assertions, plus a Rust test). +Inheritance works end to end: it picks up light yield and dopant from `lyso.Ce`, +refractive index and emission peak from `lyso`, the self-absorption channel, +the `_sources` rows, and the `_absent` declarations. + +`default_surface` is rejected **by your own contract**. You wrote: *"Which face +is the readout is a property of the built detector, not of LYSO. Two scanners +using identical LYSO differ here."* A default wrapping is the same category of +claim, only weaker, because it is applied silently. That a crystal is *polished* +is a fact about the crystal; that a polished crystal is then wrapped in ESR with +grease *rather than Teflon in an air gap* is a fact about a detector somebody +built. + +It also would not work mechanically: `optical.default_surface` inherits, so every +vendor node under `lyso.Ce.polished` would acquire a wrapping choice nobody made +for them. + +What you do instead is a two-key lookup — a material key and a surface key, +paired in `.strata` or defaulted in your config. There is a test showing both +halves resolving. If a vendor genuinely ships a pre-wrapped assembly, that is a +real product fact and we will revisit it on the vendor node, where it belongs. + +--- + +## 4. The LYSO data — read this before writing the sampler + +You called `emission_spectrum` "the single highest-value field in this brief". +We could not populate it, and the reason is not laziness. + +**There is no tabulated (wavelength, intensity) LYSO:Ce emission spectrum in a +redistributable source.** The two papers that plot one — Mao/Zhang/Zhu 2008 +(doi:10.1109/TNS.2008.922804) and Melcher & Schweitzer's original LSO +characterisation — are paywalled figures. Digitising them produces a derivative +of a proprietary figure, which this repo's licence policy does not permit and +which would in any case be an uncited number wearing a citation. + +So the field is **declared absent**, with the reason, the search, and what would +close it: + +```python +pymat.lyso.absent("optical.emission_spectrum").reason # 'proprietary' +pymat.lyso.absent("optical.emission_spectrum").note # the full story +``` + +This is the mechanism your brief asked for ("an explicit absent-declaration +rather than folding it into one number"), generalised: `Material._absent`, a +sidecar keyed by dotted property path with a **closed** reason vocabulary +(`not-measured`, `not-applicable`, `not-separable`, `proprietary`, `pending`), +inheriting exactly like `_sources`. `None` with no declaration means "nobody +looked"; `None` with a declaration means "we looked and the number does not +exist". Those should make your engine behave differently. + +**What you should do with the emission band.** These parameters ARE cited and +CC-BY (Bosca & Lopez, *Sci. Rep.* **13**, 7199 (2023), +doi:10.1038/s41598-023-32689-z): band centre 430 nm, FWHM 60 nm, two +inequivalent Ce sites (Ce1 at 393 and 427 nm, Ce2 near 460 nm), peak 420 nm from +the vendor datasheets. **Synthesise the curve on your side and label it +synthesised** — in the parquet manifest, not in a comment. A sampled band built +from cited parameters and marked as constructed is honest; the same curve shipped +from here as if it were measured is not. + +### What LYSO now carries, all cited + +| Field | Value | Source | Licence | +|---|---|---|---| +| `refractive_index` | 1.82 | Chen/Mao/Zhu 2011, doi:10.1016/j.optmat.2011.10.006 | proprietary-ref | +| `emission_range` | [380, 600] nm | Bosca & Lopez 2023 | CC-BY-4.0 | +| `absorption_length` | 200 mm | Usubov 2013, arXiv:1305.3010 | proprietary-ref | +| `absorption_length_reabs` | **588 mm** | Bosca & Lopez 2023 | **CC-BY-4.0** | +| `rise_time` (on `lyso.Ce`) | 0.072 ns | Seifert et al. 2012, JINST 7 P09004 | CC-BY-3.0 | +| `temperature_coefficient_light_yield` | −0.15 %/°C | Tully 2022, arXiv:2205.14890 | proprietary-ref | +| `intrinsic_activity_Bq_per_g` | 40 | Enríquez-Mier-y-Terán 2020, doi:10.1186/s40658-020-00291-1 | CC-BY-4.0 | +| `hygroscopic` | false | Luxium PreLude 420 | proprietary-ref | + +**Three things in that table need your attention:** + +1. **Your 200 mm literal is a Monte-Carlo convention, not a measurement.** It is + in the database now, but the source note says so in capital letters. The chain: + Usubov 2013 adopts 20 cm flat across the band, inferred from Vilardi et al. + 2006 (doi:10.1016/j.nima.2006.04.079), who measured ~10 cm **effective** + attenuation in 3.2×3.2×100 mm bars — a figure that includes surface and + wrapping losses and is therefore a lower bound on the bulk value. Other + simulation papers use 15 cm and 40 cm. Treat it as a tunable, not a constant. + +2. **The self-absorption split is real and half-measured.** Bosca & Lopez measure + α_L = 1.7×10⁻² cm⁻¹ in the emission band over 105 mm of propagation → + `absorption_length_reabs = 588 mm`, CC-BY, directly usable. The **matrix** + channel with cerium subtracted has no clean measurement anywhere we could + reach, and is declared absent rather than back-computed from + `absorption_length − absorption_length_reabs`, which would silently promote + the Monte-Carlo convention above into a measurement. + + Note what this does to your 10–30% estimate: 588 mm over a 20 mm crystal is + ~3.4% single-pass reabsorption, not 10–30%. Over 5–20 bounces the path length + is longer, so your figure may still be right, but the single-pass number is an + order of magnitude below the brief's estimate and the discrepancy is worth + resolving before you tune anything against it. + +3. **`reemit_qe` does not exist as a measured quantity.** Declared absent. The + number usually pressed into this role is Bosca & Lopez's absolute + photoluminescence quantum yield, **PLQY = 0.51** at 365 nm excitation — but + PLQY under external UV pumping is not re-emission efficiency following + self-absorption *within* the emission band. If you need a value, adopt 0.51 + explicitly as an assumption anchored to that paper, **in your run config where + the assumption is visible**, not as a database constant. + +Also declared absent for LYSO: `refractive_index_dispersion` (Sellmeier +coefficients exist in Chen 2011 and Petrosyan 2015 but both are paywalled; +refractiveindex.info carries neither LSO nor LYSO — verified twice, so this is a +real absence, not a tooling artifact) and `decay_components` (the review +literature repeats "fast 20–30 ns at 10–40%, slow ~43 ns at 60–90%" but it could +not be tied to a single measurement paper for standard uncodoped LYSO:Ce at room +temperature). + +One data correction: `lyso.Ce.saint_gobain.prelude420.light_yield` was **34000**, +an uncited round-up. The Luxium PreLude 420 data sheet says **33200**. Fixed. + +### What is deliberately *not* done yet + +`gagg`, `nai_tl`, `csi_tl` and the plastics were on the P1 list and are +**untouched**. This is a refusal, not an omission: the literature sweep behind +this PR covered LYSO and BGO only, and adding values to the others would mean +either copying them from the existing uncited numbers or asserting +`_absent` reasons for searches nobody ran. Both are worse than leaving them +alone, by the standard your own §P2 sets. They need the same treatment LYSO +got — a real source sweep — and that is a follow-up, not a five-minute edit. + +What *is* free for four of them: `nai`, `nai.Tl`, `csi`, `csi.Tl`, `csi.Na` are +inside the enricher's scope, so their dispersion is one command away. The rule +is now stated once at the top of `scintillators.toml` rather than per-material. + +--- + +## 5. What shipped + +**Python** + +- `pymat.curves.WavelengthCurve` — the λ twin of `TempCurve`, sharing its + validation and interpolation helpers. Clamps, never extrapolates; validated at + construction and therefore at load. `range_nm` tells you where the data stops, + which is what your resampler needs so it does not fabricate the tails. +- Accessors on `OpticalProperties`: `n_at(λ)`, `absorption_length_at(λ)`, + `absorption_length_matrix_at(λ)`, `absorption_length_reabs_at(λ)`, + `emission_at(λ)`, plus `*_curve` properties. All take a Pint Quantity or bare + nm. `emission_at` has **no** scalar fallback by design. + `refractive_index_at(T)` still means temperature — that is why the new ones are + named differently. +- New optical fields: `reflectivity`, `absorption_length_spectrum`, the + `_matrix`/`_reabs` split with `_spectrum` siblings, `reemit_qe`, `dopant`, + `dopant_pct`. +- `Material._absent` + `mat.absent(path)` / `mat.is_absent(path)`. +- `pymat.surfaces` — 30 measured interfaces, Mapping + callable + filterable, + mirroring the `pymat.materials` contract from #228. +- Structured spectra are validated at load, so a mismatched-length table raises + in the loader rather than at first query. + +**Data** + +- `src/pymat/data/surfaces.toml` — 21 LBNL + 9 DAVIS, every entry carrying its + exact `G4OpticalSurfaceFinish` spelling, its `G4SurfaceType`, the + `G4RealSurface-2.2` data set, and DOIs (Janecek & Moses 2010 + 10.1109/TNS.2010.2042731; Roncali & Cherry 2013 10.1088/0031-9155/58/7/2185; + Roncali/Stockhoff 2017 10.1088/1361-6560/aa6ca5; Stockhoff 2017 + 10.1088/1361-6560/aa7007). +- Air gap vs optical contact is a validated closed vocabulary. An entry declaring + `optical_contact` **must** carry `coupling_index` — the index of the filler is + the whole physical difference — and an entry declaring `air_gap` must not. + LBNL glue is Cargille Meltmount n = 1.582; DAVIS grease is BC-630 n = 1.465, + which is already a material (`bc630`) in this database. +- Worth knowing for your model: 3M ESR is a multilayer interference stack + designed for an air interface, so wet-coupling measurably *lowers* its + effective reflectivity (Kang et al., NIM A 2017, + doi:10.1016/j.nima.2017.02.032). The `*_air` and `*Grease` LUTs are separate + measurements for two independent physical reasons, not one surface with a + different gap material. +- Also: Lumirror is Toray voided PET and reflects **diffusely**. It is not ESR. + The LBNL family contains both and they behave qualitatively differently. + +**Rust — `rs-materials 0.3.0`** + +- `Curve` with `interpolate` / `range` / `covers` / `resample(lo, hi, n)` — the + last one is there so your 128-node λ grid is one call. +- `OpticalProperties` grew from 4 scalars to the full set including every + spectrum, both temperature and wavelength curves, decay components, and the + self-absorption split. `NuclearProperties`, `MechanicalProperties`, + `ThermalProperties` too. +- `Source` / `Absent` sidecars with `source_of()` / `is_absent()` / + `absent_reason()`. +- Per-field uncertainty via `stddev_of("light_yield")`. No material in the corpus + uses it yet — the schema landed in #149 but the data sweep has not happened. +- `SurfaceDb` with `by_lut_surface("PolishedESRGrease_LUT")` — the reverse index + you need when holding a Geant4 finish name — plus `family()` and + `with_coupling()`. +- `Material::raw()`, the escape hatch. +- **Two parser bugs fixed while in there.** Inheritance only ever consulted *one* + level of parent, so `lyso.Ce.saint_gobain.prelude420` silently lost everything + its grandparent declared; and property groups were replaced wholesale rather + than merged key-by-key, so a child that overrode `light_yield` dropped its + parent's `refractive_index`. Both are now tested. +- `PROPERTY_GROUPS` was missing `magnetic`, `vacuum`, `nuclear`, `vis`, `custom`. + They avoided being mis-parsed as child materials only by accident. +- **Behaviour change to know about:** `formula` no longer inherits from the + parent node. py-mat's loader does not inherit it either + (`pymat.lyso.Ce.saint_gobain.prelude420.formula` is `None`), so the old + one-level lookup was itself a silent divergence from the source of truth. If + you were relying on it, you were relying on a bug. Whether Python *should* + inherit formula is a separate question worth raising as an issue. + +**Tests:** 1069 Python (144 new, incl. the loader-parity gate), 104 Rust (67 new). License gate passes on all +8 TOMLs — `surfaces.toml` is covered automatically because the gate globs +`data/*.toml`. `CC-BY-3.0` was added to the allow-list for JINST. + +--- + +## 6. What is on your side now + +1. **Unpin `rs-materials` from 0.1.0.** Go to 0.3.0. Several of your complaints + were already fixed in 0.2.0. +2. **Synthesise the LYSO emission band from the cited Bosca & Lopez parameters, + and label it synthesised in the manifest.** Do not wait for a curve from here; + it is not coming without paid journal access. +3. **Run the refractiveindex.info enricher for `bgo`, `nai`, `nai.Tl`, `csi`, + `csi.Tl`, `csi.Na`** — or ask us to. It is one command and it will fix the ~3% + n error at BGO's emission peak. +4. **Reconcile the 10–30% reabsorption estimate** against the measured + 588 mm / 3.4%-per-pass figure before tuning against either. +5. **Decide `reemit_qe` in your config**, anchored to PLQY = 0.51, with the + assumption visible. +6. **Take the analytic UNIFIED finishes and `sigma_alpha` into your config.** + They are not coming from here. +7. **Check `Curve::covers()` before resampling.** Every curve clamps silently + outside its measured range — that is deliberate, and it means a 300–800 nm + grid laid over a 400–700 nm measurement will hand you 100 nm of fabricated + flat line unless you look. + +Open the PR discussion on ADR-0004 if you want to contest either narrowing. §3b +in particular is a judgement call, and it is your brief's own reasoning that +decided it. + +--- + +# Round 2 — the concrete detector + +strata came back with a specific module to support (8×8 LYSO 3×3×25 mm, 0.2 mm +BaSO4 septa, aluminium outer wrap, grease-coupled SiPM on the −z end) and, +more usefully, with a **sensitivity result that re-ordered the work**. + +## What changed the priorities + +Their measurement, 20k photons, identical seeds, absorption length swept: + +| absorption length | mean CE | readout end | far end | DOI ratio | +|---|---|---|---|---| +| 200 mm (MC convention) | 0.2206 | 0.6139 | 0.0730 | 8.4 : 1 | +| 588 mm (measured Ce channel) | 0.2590 | 0.6470 | 0.1039 | 6.2 : 1 | + +A 2.9× change in absorption length moves mean collection efficiency by only ++17% relative, and reflector loss ran 25% at the readout end to 63% at the far +end against 13→29% for bulk. The conclusion drawn at the time — *the reflector +dominates, so reflectance provenance outranks scintillator bulk data* — is what +re-ordered the work. + +> ⚠️ **RETRACTED in round 3, and the retraction is the more useful result.** +> That sweep was computed at an assumed reflectance of 0.97. At the cited value +> the model inverts: far-end wrap loss goes from 64% to **5%**, and bulk loss +> from 29% to **69%**. The reflector does not dominate; at a realistic +> reflectance the *crystal* does. +> +> The re-prioritisation was still correct, but **for the opposite reason to the +> one given**: reflectance provenance mattered enormously because the assumed +> value was wrong, not because reflector loss is the dominant channel. +> +> Kept visible rather than edited away, because a sensitivity analysis computed +> at an unmeasured parameter is exactly the failure mode this document is about +> — and it is one that produced a *right answer from wrong reasoning*, which is +> the hardest kind to catch. + +**The deeper finding: the two parameters interact, so no standalone sensitivity +is meaningful.** Absorption length moves collection efficiency by +17% at +R = 0.97 and by **+53%** at R = 0.999. At low reflectivity photons die at the +wrap before path length can matter; at high reflectivity they survive long +enough to accumulate path, and absorption takes over. Any statement of the form +"parameter X is second-order" is only true at whatever value of Y it was +computed at. + +The practical consequence for this repository: **the measured 588 mm +self-absorption channel is worth substantially more than the round-2 analysis +credited**, and the 200 mm lumped convention is correspondingly more dangerous +— see the strengthened source note on `[lyso.optical] absorption_length`. + +### A design tension the data now prices + +Falling out of the same 2D sweep, and worth recording because it is not +obvious: **a better reflector destroys depth-of-interaction resolution.** Across +R = 0.97 → 0.999 the depth gradient collapses from 8.4:1 to 1.6:1. Uniform light +collection is what you want for energy resolution and precisely what you must +not have for depth encoding. + +That makes the *uncertainty* on the BaSO4 number load-bearing in both +directions, not just the value — which is a good argument for shipping the +0.98–0.999 bracket rather than a point estimate. + +## BaSO4 — the number that mattered + +Grum & Luckey 1968 (doi:10.1364/AO.7.002289), the primary reference for pressed +BaSO4 as a reflectance standard: **0.999 at 420–470 nm**, 0.985 at 350 nm. +strata's working estimate was **0.97**. + +``` +0.970 ^ 40 = 0.296 +0.999 ^ 40 = 0.961 +``` + +That is not a refinement, it is a different model. Three caveats travel with it +in the TOML header, and they matter more than the headline: the cited values are +pressed powder at high packing density measured in an integrating sphere; the +paper's own BaSO4/PVA *paint* measures 0.992; and a septum is bounded by crystal +faces rather than open to a sphere. The honest bracket is ~0.98–0.999. Every +point of it is above 0.97, so the *direction* is certain even where the value is +not — which is the right way to hand over a number like this. + +## A question that decided model structure, not just a value + +Patterson 1977 (doi:10.1364/AO.16.000729) gives Kubelka-Munk coefficients: +`s = 572 cm⁻¹` at 500 nm → a ~17 µm scattering mean free path → **0.2 mm is +~12 scattering lengths, so the septum is effectively optically thick.** No need +to transport into it; a surface entry suffices. + +But only just. Coating vendors specify 0.5–0.6 mm because real coatings pack +looser than a pressed pellet. If the septum is paint or a loaded binder, light +leaks through into the neighbouring crystal — **an inter-crystal crosstalk +channel the model does not currently have**. That is a gap in the physics, not +in the data, and it would surface as crosstalk that cannot be reproduced. + +> **This claim went through three states. All three are kept, because the +> sequence is more instructive than any one of them.** +> +> 1. **Asserted** (above), unqualified: 0.2 mm is optically thick. +> 2. **Retracted** in round 3, when a measured 15% inter-crystal light share +> arrived and the K-M finite-layer solution gave T = 7.6% at 0.2 mm. I +> concluded the claim was simply wrong and said so. +> 3. **Attempted un-retraction, itself wrong.** The consumer proposed that the +> retraction was computed at an unstated condition: T = 7.6% is the +> *normal-incidence* figure, and TIR-trapped light in a 3×3×25 mm crystal +> was said to meet the side walls at ~76.7°, giving `d/cos θ ≈ 4.34 d` and +> **R = 96.9%, T = 1.35%** — the semi-infinite limit. On that basis they +> recommended un-striking the original claim. **This repository declined to +> un-strike it, on principle, before the evidence arrived.** +> 4. **Falsified.** They instrumented the mechanism's own prediction rather +> than the number it was fitted to, and measured the mean side-wall +> incidence at **47.8°, not 76.7°** — a factor 2.9 in path length. The +> reason is clean and had been in front of both of us: **the septum is +> Lambertian, and a diffuse reflector randomises direction on first +> contact.** Mean `|cos θ| = 2/3` exactly → 48.2°, *independent of aspect +> ratio*. The grazing-incidence story reasoned about the angular +> distribution of trapped light and forgot that the wall being reasoned +> about destroys that distribution. +> +> **So the retraction at state 2 stands as originally written.** At 48° the +> path multiplier is 1.49, nowhere near optically thick: a 0.2 mm septum +> transmits ~5%. The original unqualified claim was simply wrong, and the +> attempt to rescue it with a condition was wrong too. +> +> Recorded at four states rather than edited to the final one. A two-state +> record cannot represent "struck → argued back → struck again", and the +> sequence is the instructive part: **two plausible mechanisms, both +> constructed after the fit to explain a value already chosen, both surviving +> review by two parties, both killed by measurement rather than by scrutiny.** + +### What this cost, and the failure mode it belongs to + +Working from the normal-incidence number, the consumer initially identified +per-encounter transmittance with the observed light share — different +quantities, since a photon meets a septum ~40 times in this geometry — and on +that arithmetic fitted the scattering coefficient down to 47% of pellet +density. That fit was offered to this repository as a property of BaSO4. It was +declined, for the reason given in round 3: *a fit against one module is a +measurement of that module, not of the material.* Had it been accepted, an +arithmetic error would now be recorded here as a material constant, cited. + +Note what caught it, though — not the headline number, which could have been +tuned to agree, but the **shape**: at T = 0.076 per encounter the simulated +crosstalk had *further* crystals exceeding *direct* neighbours, where the +measurement falls off. A second constraint on the same data is what made the +first one falsifiable. + +And note the failure mode on this side, because it is the one named in round 3 +arriving from the other direction. `km_transmittance_at` was correct, cited, +tested, and complete for the question it answers — normal incidence. **Not a +wrong value, and not a missing one: a right one answering an adjacent +question.** The accessors now take an incidence angle for that reason. + +But the fix carried its own trap, and it is worth stating because this +repository built it: **an `incidence_deg` parameter invites exactly the error +that followed.** Kubelka-Munk `k` and `s` are *already* defined for diffuse +flux — the obliquity is averaged into them, which is the origin of the factor 2 +in the usual `K = 2k` convention — so for diffusely-illuminated layers plain +`d` is correct and multiplying by `1/cos θ` double-counts. Offering a knob +without saying loudly when *not* to turn it is another way of answering an +adjacent question. The docstrings now lead with when to leave it at zero. + +### The sharpened lesson + +Round 3 recorded: *a fit with one constraint is a reparameterisation, a fit +with two is a test.* This round sharpens it, and the consumer's phrasing is +better than mine: + +> **The second constraint has to be a prediction of the *mechanism*, not +> another property of the outcome.** + +Their further-vs-direct crosstalk shape was a genuine second constraint and did +real work — it killed the packing-density fit. But it could not distinguish +grazing incidence from anything else producing the same transmittance, because +it says nothing about *angle*. Only instrumenting the angle could, and when +they did, the mechanism died in an hour. + +For any fitted parameter: ask what **else** the proposed mechanism asserts, and +go measure *that*. + +### And a third instance, from proposing the fix + +Offered a falsification test for the double-counting concern: *check a thick +layer, where K-M must reproduce Patterson's published `R_inf`, and see whether +inserting a multiplier breaks the agreement.* The consumer ran it instead of +accepting it. **It cannot fail.** `R_inf` depends only on `k/s`; the thickness +cancels, so a thick-layer check passes identically for a multiplier of 1, 4.34 +or 10. + +So the count is three: a fit constraint that could not touch the mechanism, a +mechanism that could not be separated from its rival by any output, and then a +falsification test that could not detect the error it was proposed for. All +three were *real* checks. None was capable of failing in the relevant way. + +**A test that the wrong model passes is not a weak test, it is a non-test**, and +the way to tell is not to inspect the reasoning but to ask what result would +have come back had the thing been wrong. That question is cheap and neither of +us asked it three times running. + +Both the degeneracy and the blind check are now executable +(`TestOpticalThicknessDegeneracy`), so the limitation is stated in code rather +than remembered. + +### The degeneracy underneath all of it + +`thickness_cm` and `incidence_deg` enter the K-M solution only through their +product — the optical thickness. `(0.02 cm, 76.7°)` and `(0.087 cm, 0°)` return +identical results, byte for byte. + +That is why the falsified mechanism kept producing correct numbers, and why the +consumer's final reframing is the right shape: **the fitted quantity is optical +thickness**, one parameter constrained by one measurement, feeding two outputs +that therefore cannot disagree with each other. What it buys is a claim about +the physical build — the septum behaves as though ~4.4× its nominal 0.2 mm — +which a microscope can refute. That is the first version of this story that +predicts something outside the model it was fitted to. + +## Aluminium — derived, not stored + +The `--write` enricher run put Rakić CC0 n,k on disk, so reflectance became +derivable rather than typed: + +``` +OpticalProperties.normal_reflectance_at(420) -> 92.46 % +``` + +92.29% mean over 400–500 nm, with the interband dip at 800 nm (now a test — +if either the CC0 pull or the Fresnel derivation breaks, that shape is what +stops looking right). strata replaced its own 0.88 estimate with it. + +Derived beats stored here: a hand-entered scalar can drift from the n,k it is +supposed to be consistent with, and nothing would notice. The surface entry +carries an explicit `_absent` on `reflectivity` saying exactly that. + +## Where the line got drawn again + +**A photodetector is not a material** (ADR-0004 §11). PDE is a device response +at an operating point — it survives moving the part and does not survive +re-biasing it. The window *is* a substance and is now present in both variants, +which caught a real error: the S13360 **CS** package window is silicone at +**n = 1.41**, not the 1.55 both sides were carrying (that is the **PE** epoxy +variant). From BC-630 grease at 1.465 those are qualitatively different — CS +steps the index down and puts a TIR cone at the readout face, PE does not. + +An independent fact settles the same question without appeal to principle: +**no redistributable tabulated PDE(λ) exists for the S13360-3050CS at all.** +Datasheet figure, no table, no CC-BY paper on that exact part. Putting it here +would mean shipping a digitised proprietary figure — the thing we refused to do +for LYSO's emission spectrum. + +**`contact.grease_sipm` was requested and refused.** It would carry no measured +number of its own — the grease index is on `bc630`, the window index is on +`sipm_window_silicone` — only the pairing, and pairing is assembly. A test pins +its absence so the reasoning cannot be quietly reversed. + +## Self-inflicted bug, worth recording + +`reflectivity_spectrum` was registered in the loader's validation table with no +dataclass field behind it: validated, then silently dropped. **That is the exact +bug class this branch audited the corpus for, reintroduced by me**, in the one +direction the corpus scan cannot see — no shipped file used the slot yet, so +nothing failed. There is now a structural test that every validated slot has a +field to land in. + +The lesson generalises past this repo: a scan over *existing data* cannot find a +gap that only opens when new data arrives. The invariant has to be checked +against the schema, not against the corpus. + + +--- + +# Round 5 — the discrepancy did not exist + +The consumer retracted a fourth time, and this one resolves the whole thread: +**the measured ~15% crosstalk was per direct neighbour normalised to the +central crystal**, while the simulation reported direct-neighbour light as a +fraction of *total collected*. Those differ by roughly (neighbour count × +own-fraction) — a factor of ~3.4. + +With the target corrected, and using Patterson's coefficients unmodified at the +nominal 0.2 mm: + +| quantity | model | measured | +|---|---|---| +| crosstalk, per direct neighbour | 15.95% | ~15% | +| energy resolution FWHM @ 511 keV | 7.80% (Poisson) | ~10% | + +**Two independent measurements, one model, zero fitted parameters.** + +## What this repository got wrong + +Three mechanisms were invented to explain a discrepancy that was never there — +loose packing, grazing incidence, effective thickness. This document already +records the first two dying. The third died here, and **this repository +endorsed it**: + +> *"the first version of this story that predicts something outside the model it +> was fitted to. A microscope on the septum can refute it."* + +That endorsement was correct on its merits and wrong in outcome, in a +particular way worth recording. The microscope would have come back at 0.2 mm. +The consumer would then have concluded that Patterson's coefficients fail for +their geometry — **a false refutation of correct data, arrived at through a +sound falsification test.** One measurement away, and the data would have been +blamed. + +## The lesson, which supersedes the earlier four in scope + +> **A wrong comparison manufactures physics to explain itself, and every +> falsification test downstream of it inherits the error.** + +Rule 4 said *a test the wrong model passes is a non-test.* This is the worse +case: **a test the right model fails, because the target is wrong.** Three +plausible, physically-motivated, independently-falsifiable mechanisms all +survived review by two parties — because each was falsifiable only against a +target that was itself wrong. The falsifications were sound and useless. + +Every method in this document was applied correctly and none could see it: the +mutation audits, the angle instrumentation, the degeneracy analysis, the +mechanism-prediction rule. All downstream of the comparison. + +The tell was present twice and read as physics both times. The "wrong tail +shape" that killed mechanism (1) was the same normalisation error: totals +compared across 4 direct crystals versus 59 further ones, which inverts the +ordering combinatorially. Per crystal the shape had always been right. **A +combinatorial artefact was read as evidence and used to kill a hypothesis.** + +### The practical form + +Before modelling a discrepancy, confirm the two numbers are the same *kind* of +quantity — same normalisation, same denominator, same population. It is the +cheapest check available and it was on the consumer's own candidate list, +skipped three times because it was not interesting. **Cheap checks get skipped +in proportion to how uninteresting they are, which is uncorrelated with how +often they are the answer.** + +## What this says about the data + +Nothing in this repository moved. Patterson's `k` and `s`, Grum & Luckey's +reflectance, the two-lab spread, the 588 mm self-absorption channel, the +aluminium n,k — every value survived four retractions unchanged, and the +schema decisions that refused three fitted parameters (`s = 279 /cm`, +`default_surface`, `contact.grease_sipm`) all held. + +That is the strongest available argument for the line drawn in ADR-0004 §1: +**a fit against one module measures that module.** Had any of the three been +accepted as material data, this repository would now carry a cited constant +manufactured to explain a comparison error. diff --git a/docs/data-policy.md b/docs/data-policy.md index 4907df2..f131720 100644 --- a/docs/data-policy.md +++ b/docs/data-policy.md @@ -44,6 +44,7 @@ The `license` field on every `_sources` entry MUST be one of: |---|---|---| | `CC0` | Public domain dedication, no attribution required | Wikidata, refractiveindex.info, HEPData | | `PD-USGov` | US Government work — not copyrightable in US | NIST WebBook, NIST Cryogenic, NASA Outgassing, NIST PhysRefData | +| `CC-BY-3.0` | Creative Commons Attribution 3.0 — attribution required | JINST, several IOP/SISSA detector journals | | `CC-BY-4.0` | Creative Commons Attribution 4.0 — attribution required | Materials Project, OQMD, NOMAD, SCOAP3 | | `CC-BY-SA-4.0` | CC-BY-SA — attribution + share-alike | Some Wikipedia-derived data (rare; prefer Wikidata) | | `Geant4-SL` | Geant4 Software License — BSD-like, attribution required | Geant4 `G4NistMaterialBuilder` constants | diff --git a/docs/decisions/0004-optical-transport-and-surface-finishes.md b/docs/decisions/0004-optical-transport-and-surface-finishes.md new file mode 100644 index 0000000..2dcb628 --- /dev/null +++ b/docs/decisions/0004-optical-transport-and-surface-finishes.md @@ -0,0 +1,451 @@ +# 0004 — Optical transport: wavelength curves, the surface-finish catalogue, and the substance/assembly line + +**Status:** Accepted +**Issues:** [#243](https://github.com/MorePET/mat/issues/243) +**Input:** `docs/briefs/strata-optical-requirements.md` (strata light-module, 2026-08-14), strata ADR-089, gerchowl/strata#1050 +**Supersedes nothing.** Extends ADR-0003 (schema foundation) into the wavelength axis. + +## Context + +strata — a Rust Monte Carlo PET engine — samples scintillation light +monochromatically at the emission peak, hardcodes a 200 mm absorption length, +and has no vocabulary for "the crystal is wrapped in ESR". It filed a brief +asking py-mat to own the *substance* physics and the *named surface-finish +catalogue*, and asking it explicitly **not** to own geometry, per-face +assignments, SiPM configuration, or sampling tables. + +The brief's separation-of-concerns table is the contract under discussion. This +ADR accepts most of it, corrects three factual claims about py-mat, and narrows +the boundary in two places where the brief asks py-mat to hold something that, +by the brief's own reasoning, is not a material fact. + +## Decisions + +### 1. The line: py-mat owns substances and measured interfaces; not assemblies + +Accepted, with the wording sharpened: + +| Layer | Owns | +|---|---| +| **py-mat** | What a substance **is** — n(λ), attenuation, emission, decay, yield, with provenance and uncertainty. And what a **measured interface** is: a named, cited entry describing a crystal-face/reflector/coupling triple that somebody put on a goniometer. | +| **strata** | What was **built** (which face carries which finish), and **how it is simulated** (model choice, toggles, bounce caps, PDE, sampling tables). | + +The operative test is not "is it about light?" but **"did someone measure it, and +does the number survive moving the part?"** A LUT for polished-ESR-with-grease +survives being moved between scanners. `sigma_alpha = 0.1` chosen to make a +model fit does not — it is a knob, and knobs are strata's. + +This test is what produces the two narrowings below. + +### 2. The surface-finish catalogue belongs in py-mat — as its own type (answers Q1, Q2) + +**Q1 — does it belong here at all?** Yes. A measured interface is citable, +reusable, and meaningless without the substances it joins. It has the same +lifecycle as a material property: someone measures it, publishes it, and it +stays true. Splitting it into a third repo would put a citation boundary in the +middle of one physical story (LYSO's n, ESR's reflectivity, and the reflectance +of the interface between them are one narrative), and would create a repo whose +only job is to depend on this one. + +**But a `Surface` is not a `Material`.** It has no density, no formula, no +composition, no mass. Modelling it as a `Material` would put objects into +`pymat.materials`, `search()`, and `mass_from_volume_mm3()` for which those +operations are nonsense, and would make `Material` mean two things. So: + +- New frozen dataclass `pymat.surfaces.Surface`. +- New registry `pymat.surfaces`, mirroring the `pymat.materials` contract from + #228 (Mapping + callable + filterable). +- Shares `Source`, `Absent`, and `WavelengthCurve` with materials — the + provenance and curve primitives are about *values*, not about *materials*, and + are reused verbatim. +- Inheritance works the same way (parent table → child overlay), because the + catalogue is naturally 3 treatments × 7 wrappings and hand-repeating the + family citation 21 times is how citations rot. + +**Q2 — `surfaces.toml`, or nested under the material?** Top-level +`surfaces.toml`, not nested. An interface is a relation between two substances; +nesting it under one of them forces an arbitrary choice of owner and duplicates +every entry across every crystal it could touch. ESR-with-grease would appear +under `lyso`, `bgo`, `gagg`, … as separate copies that drift. + +### 3. The catalogue holds interfaces whose optical numbers are **measured and cited** — a narrowing + +Geant4's `G4OpticalSurfaceFinish` enum has 39 values. They are not all the same +kind of thing: + +- **6 analytic UNIFIED/GLISUR finishes** (`polished`, `ground`, + `polishedfrontpainted`, …) — no data file. These are *model selections* + parameterised by `sigma_alpha`. Not measured, not citable, no provenance to + carry. **These are strata's**, and the catalogue deliberately omits them. +- **24 LBNL LUT enum members**, of which **21 ship measured `.dat` files** + (Janecek & Moses 2010). The other 3 (`polishedair`, `etchedair`, `groundair`) + are bare-surface enum members with no measured data — named in a comment in + `surfaces.toml`, deliberately **not** given entries, because an entry with no + measurement behind it is exactly the uncited value this repo exists to prevent. +- **9 DAVIS LUT surfaces** (Roncali & Cherry 2013; Roncali/Stockhoff 2017; + Stockhoff 2017) — all measured. + +So the catalogue ships the **21 LBNL + 9 DAVIS** LUT entries, each carrying its +exact `G4OpticalSurfaceFinish` spelling, its LUT family, the +`G4RealSurface-2.2` data set it comes from, and a DOI. + +**The test is measurement, not LUT-backing.** A `lut` entry is one shape a +measured interface can take; it is not the only one. An entry qualifies when +the optical numbers it carries — a reflectance scalar or spectrum — are +measured and citable. So `model = "diffuse"` and `model = "specular"` entries +belong here too, provided their `reflectivity` / `reflectivity_spectrum` has a +source. A pressed-BaSO4 reflector or an aluminium wrap is exactly as measured +as a Janecek goniometer sweep; it simply produces `R(λ)` rather than an angular +table, and the `Surface` schema had those fields from the start. + +What stays out is unchanged and is the actual line: **an entry whose only +content is a fitted model parameter with no measurement behind it.** `polished` +with a `sigma_alpha` tuned until the simulation matched is a knob, not a fact, +and it belongs in the consuming engine's config. + +The practical consequence for a consumer: a `lut` entry hands you an angular +reflectance distribution and you use it directly; a `diffuse`/`specular` entry +hands you `R(λ)` and the *consumer* composes it with the crystal's `n(λ)` to +get the Fresnel step. That composition depends on both materials, so it belongs +on the side that knows which two are being joined — which is also why these +entries are named for the reflector and coupling, never for the crystal. + +### 4. `WavelengthCurve` — the λ twin of `TempCurve` + +New primitive in `curves.py`, sharing TempCurve's validation and interpolation +helpers (one implementation, two field-name façades): + +- Knots `(wavelengths_nm, values)`, strictly ascending, validated **at + construction and therefore at TOML load**. +- **Clamps** outside the measured range. Same reasoning as ADR-0003 §2, and + stronger here: a Sellmeier fit evaluated outside its stated validity range does + not just lose accuracy, it returns a confident number from a divergent pole. + +**Storage is unchanged.** The structured slots stay plain dicts on the +dataclasses — `refractive_index_dispersion = {wavelengths_nm, n}` and +`emission_spectrum = {wavelengths_nm, intensities}` shipped in #153/#164, are +already on disk, and round-trip through JSON in the MCP client. The curve is +built on demand by the accessors. The loader builds a throwaway curve at parse +time purely to *validate*, so a mismatched-length spectrum raises at load, not at +first query. + +**Accessors are new names, not overloads:** + +```python +opt.n_at(420) # or 420 * ureg.nm +opt.absorption_length_at(420) # Quantity, mm +opt.emission_at(430) # relative intensity +opt.absorption_length_matrix_at(420) +opt.absorption_length_reabs_at(420) +``` + +`refractive_index_at(T)` has meant *temperature* since #148. Dispatching on +argument type would silently change behaviour for every existing caller. `n_at` +is unambiguous and is what the brief asked for. + +`emission_at()` has **no scalar fallback**. `emission_peak` is one point on a +band, not a stand-in for its shape; a caller with only a peak should sample +monochromatically and know that it is doing so. + +### 5. The self-absorption split is accepted as schema + +A photon absorbed in a doped scintillator has two physically distinct fates, and +one lumped `absorption_length` cannot express the difference: + +```toml +absorption_length_matrix = 1200.0 # mm — true loss to the host lattice +absorption_length_reabs = 340.0 # mm — activator self-absorption +reemit_qe = 0.75 # P(re-emitted | reabsorbed) +``` + +Each scalar has a `_spectrum` sibling. **Populate both channels, or declare the +missing one absent** — never leave one silently unset. A lone `_reabs` with a +bare `None` next to it implies "the rest is matrix loss", which is a claim the +data usually cannot make; a lone `_reabs` next to an explicit +`_matrix = {reason = "not-measured"}` makes exactly the right claim. + +LYSO turns out to be precisely this case: its self-absorption coefficient is +measured and CC-BY-licensed, while the matrix-loss channel with cerium +subtracted has no clean measurement anywhere we could reach. Without `_absent` +(§6) that asymmetry would be inexpressible. + +This is a schema decision, not a data decision. Whether any given material's +split is *measurable* is answered per material — see §6, and see +`docs/briefs/strata-optical-response.md` for LYSO specifically. + +### 6. `_absent` — declared absences + +The brief asks for "an explicit absent-declaration rather than folding it into +one number". Accepted, and generalised beyond optics. + +`None` on a property cannot distinguish *nobody looked* from *we looked and the +number does not exist in the literature*. Those two facts should make a +downstream engine behave differently, and today they are indistinguishable. + +```toml +[lyso.Ce._absent] +"optical.absorption_length_reabs" = { reason = "not-separable", note = "..." } +``` + +- Sidecar `Material._absent: dict[str, Absent]`, keyed by dotted property path. +- Parent-overlay inheritance, exactly like `_sources`. A child that *does* have + the measurement simply sets the value. +- `reason` is a **closed set** — `not-measured`, `not-applicable`, + `not-separable`, `proprietary`, `pending` — validated at load. The point of a + declared absence is that it can be counted; free text cannot be counted. +- Accessors `mat.absent(path)` / `mat.is_absent(path)`. No `_default` fallback: + an absence is always specific to one property. + +### 7. `rs-materials` grows to **physics** parity, not full parity (answers Q4) + +The brief asks for full parity with the Python schema plus a narrower view on +top. **Rejected, and replaced with something stronger.** + +Full parity is parity with *what*, exactly? The Python dataclasses carry +`printable_fdm`, `machinability`, `rohs_compliant`, `lead_time_weeks`, and the +whole `vis` texture-identity layer. Mirroring those into a Monte Carlo transport +crate is a permanent maintenance and semver obligation with no consumer. Parity +as a goal also fails the moment the two sides are edited a week apart — which is +precisely how the drift the brief complains about happened in the first place. + +Instead: + +- **Typed, in full fidelity, for the physics domains** — optical (including + every structured λ slot, decay components, the self-absorption split), + nuclear, mechanical, thermal — plus curves, uncertainty (`nominal`/`stddev`), + and `_sources`/`_absent` provenance. This is everything a transport kernel can + use, at full resolution, with no lossy flattening. +- **`Material::raw()`** — the unparsed TOML table for the material, with parent + overlay applied. This is the guarantee the brief actually wants: *the Rust side + can never be the reason a field is unusable*, because every field is always + reachable, typed or not. A promise to maintain parity would decay; an escape + hatch does not. +- **Not mirrored:** manufacturing, compliance, sourcing, vis. Reachable via + `raw()` if anyone ever needs them. + +### 8. Where the brief's factual claims about py-mat are wrong + +Recorded here because the brief will be read again later: + +1. **"rs-materials 0.1.0 … still carries `radiation_length`/`interaction_length` + under `OpticalProperties` — a live schema drift."** Not true on `main`. The + crate is at **0.2.0**, and `NuclearProperties` has existed there, with the + #157 migration comment, since before this brief was written. strata is pinned + to a stale crates.io release; the fix is a version bump on strata's side. +2. **"exactly six `Option` optical scalars."** Four + (`refractive_index`, `light_yield`, `decay_time`, `emission_peak`), plus three + nuclear. The *shape* of the complaint — that everything structured is dropped + — is correct and is fixed by §7. +3. **The real drift was on the Python side, and the brief missed it.** + `sources.py:SHORT_ALIASES` still mapped `"radiation_length"` → + `"optical.radiation_length"` after #157 moved the property to `nuclear`, so + `mat.cite("radiation_length")` silently resolved to a path no TOML writes and + fell through to `_default`. Fixed in this PR. + +Separately, and found while implementing: `[esr.optical] reflectivity = 98.5` +has been on disk since #147, but `OpticalProperties` had no `reflectivity` +field, so the loader's `hasattr` guard **silently dropped it on every load**. +The field now exists. This is the strongest available argument for the brief's +§P2 point about provenance, from the opposite direction: a value can be cited, +committed, reviewed — and still not be there. + +### 9. `default_surface` on a material is rejected — a narrowing (re: brief §P1) + +The brief proposes: + +```toml +[lyso.Ce.polished] +treatment = "polished" +[lyso.Ce.polished.optical] +default_surface = "surface.lyso_polished_esr_grease" # ← rejected +``` + +The **variant itself is accepted and tested** — `treatment = "polished"` is a +substance-with-treatment fact, the inheritance shape works end to end, and there +is now a test pinning it (`tests/test_surfaces.py::TestInheritedVariant`). + +`default_surface` is rejected by the brief's own contract. That a crystal is +*polished* is a fact about the crystal. That a polished crystal is *then wrapped +in ESR with grease, rather than in Teflon with an air gap* is a fact about the +detector somebody built — the brief's own words: "Which face is the readout is a +property of the built detector, not of LYSO. Two scanners using identical LYSO +differ here." A default wrapping is the same category of claim, only weaker, +because it is silently applied. + +Nor would it work mechanically: `optical.default_surface` inherits, so every +vendor and every child variant under `lyso.Ce.polished` would silently acquire a +wrapping choice that no one made for them. + +strata gets the same ergonomics by pairing a material key with a surface key in +its `.strata` file, or by putting a default in its config TOML — where run-time +policy already lives. + +If a *product* is genuinely shipped pre-wrapped (a vendor part number for an +ESR-wrapped array, not a treatment), that is a real substance-side fact and we +will revisit it on the vendor node where it belongs. + +### 10. The `#201` dispersion gate is an ownership rule, not a blocker (answers Q3) + +`ceramics.toml:480` says *"DO NOT populate `refractive_index_dispersion` here — +the #201 refractiveindex.info enricher owns bulk dispersion; sapphire is not yet +in its scope."* + +There is no blocker. `scripts/enrich_from_refractiveindex.py` (#164, tests in +#201) is an **add-only** automated enricher that pulls CC0 dispersion from the +Polyanskiy database and writes it together with a `_sources` row. It skips any +material that already has dispersion, so hand-authored data in its scope would +permanently mask the automated pull and the two would diverge invisibly. + +**Does it apply to scintillators? Partly, and this matters:** + +- **In scope, hand-authoring forbidden:** `nai`, `nai.Tl`, `csi`, `csi.Tl`, + `csi.Na`, `bgo`. All six currently have *no* dispersion on disk — the enricher + has not been run to `--write`. That is the action, not hand-authoring. +- **Out of scope, hand-authoring allowed:** `lyso`, `lso`, `gagg`, `labr3`, + `pwo`, plastics. refractiveindex.info has no LYSO entry. These are hand-authored + with citations, exactly as the sapphire comment intends. + +The rule generalises: **automated enrichers own the property paths they write.** +Where an enricher covers a path, hand-authoring is forbidden; where it does not, +hand-authoring with a citation is the expectation. The sapphire comment was +right and should be read as scoped to the enricher's material list, not as a +freeze on dispersion data. + +### 11. A photodetector is not a material — PDE does not belong here + +Asked directly by strata, and decided here rather than by default. + +**A SiPM does not go in py-mat.** Three reasons, in order of weight: + +1. **PDE is not a property of a substance.** It is a manufactured device's + response at an operating point — PDE(λ, V_over, T). Change the overvoltage, + which is a run-time choice in a config file, and PDE moves by tens of + percent; Hamamatsu's own "40% at 450 nm" is quoted *at V_over = 3 V* and is + roughly 50% at 5–6 V. Against the §1 test — did someone measure it, and does + the number survive moving the part? — PDE survives being moved and does not + survive being re-biased. Bias is policy. +2. **DCR, crosstalk and afterpulse are worse on the same axis.** All are + strongly temperature- and voltage-dependent, and dark count rate varies + 2–3× unit to unit *within one part number*. Those are facts about a specific + die at a specific temperature, not facts about matter. +3. **A `Material` has density, formula, composition.** What is the chemical + formula of an S13360-3050CS? The question does not type-check. The device is + an assembly: silicon epi, quench resistors, a window, a package. + +**But the parts of it that are substances do belong here, and are now present:** + +- `sipm_window_silicone` (n = 1.41) and `sipm_window_epoxy` (n = 1.55). The + window is what an optical photon actually crosses — the boundary is + grease→window, not grease→"SiPM" — so the Fresnel step at the readout face is + computed from cited indices on both sides. +- The crystal↔photodetector interface already exists as `davis.detector`. + +**Where PDE(λ) goes:** the consuming engine's config, for now. Not a separate +devices repo — one datasheet curve does not justify a repo's overhead (CI, +releases, versioning, a second citation policy). The revisit trigger is real +and stated: ~5 devices, or a second consumer needing the same curve. + +`pymat.curves.WavelengthCurve` is public and importable precisely so a consumer +can hold that curve with the same clamp-never-extrapolate contract and the same +load-time validation used here, without the device itself crossing the line. + +There is a supporting fact worth recording: **no redistributable tabulated +PDE(λ) exists for the S13360-3050CS at all.** The datasheet gives a figure, not +a table, and no CC-BY paper we could find measures that exact part. So even a +consumer who wanted this in py-mat would be putting a digitised proprietary +figure here — which §P2's own standard forbids. + +### 12. Ship brackets and caveats, not point estimates — sensitivity is not a property of one parameter + +Added after a downstream result that inverted its own conclusion. + +The consuming engine measured the sensitivity of light collection to bulk +absorption length, found +17%, and concluded absorption was second-order and +reflectance was what mattered. That re-ordered work in this repository — and it +was **right for the wrong reason**. The sweep had been run at an *assumed* +reflectance of 0.97. At the cited value the model inverts: far-end wrap loss +falls from 64% to 5% while bulk loss rises from 29% to 69%. And the two +parameters interact, so the absorption sensitivity is +17% at R = 0.97 but +**+53%** at R = 0.999. + +The generalisable point: **a sensitivity analysis computed at an unmeasured +parameter measures the assumption, not the physics.** "X is second-order" is +never a property of X alone; it is a property of X at whatever value of Y was +assumed, and if Y is the number without provenance then the conclusion inherits +that gap silently. + +This has two consequences for what this repository ships: + +1. **A bracket beats a point estimate when the quantity is consumed + non-linearly.** BaSO4 reflectance enters as `R^~40`, so the difference + between 0.97 and 0.999 is the difference between 0.30 and 0.96. Shipping + 0.999 with "pressed powder, integrating sphere, the same authors' paint + measures 0.992" let the consumer pick 0.99 mid-bracket for reasons they could + write down. A bare 0.999 would have been *more* precise and *less* usable. +2. **The uncertainty can be load-bearing in both directions at once.** The same + sweep showed a better reflector *destroys* depth-of-interaction resolution — + the depth gradient collapses from 8.4:1 to 1.6:1 — because uniform light + collection is what energy resolution wants and exactly what depth encoding + must not have. So the range matters to the design, not just the mean. + +Which is why `_sources` notes on this branch carry the measurement conditions +and the known-worse variants rather than just the citation, and why a caveat +that reads as verbose is pinned by a test (`test_absorption_length_carries_its_ +sensitivity_warning`). The caveat is the part a later editor tidies away, and it +is the part that was load-bearing. + +## Non-goals (unchanged from the brief, and honoured) + +No geometry. No per-volume or per-face assignment. No SiPM or electronics +configuration. No sampling tables or inverse-CDFs. Nothing keyed by a strata +volume id. No GPU-shaped flattening — py-mat stays human-authored, cited, +unit-carrying and uncertainty-aware; strata does the resampling. + +## Consequences + +- `curves.py` grows a second curve type; TempCurve's public API is byte-identical + (validation and interpolation moved to shared helpers). +- `OpticalProperties` grows `reflectivity`, six `_spectrum`/split fields, + `reemit_qe`, and eight accessors. All additive; no existing field changes type. +- A new data file, `surfaces.toml`, is picked up automatically by the + `check_licenses.py` gate (it globs `data/*.toml`), so the 30 catalogue entries + are license-checked from day one. +- `rs-materials` takes a minor version bump. strata must unpin from 0.1.0. +- Materials may now assert that a value does not exist, and that assertion is + itself checkable. + +## Alternatives considered + +- **`Surface` as a `Material` in `surfaces.toml`.** Cheapest to build — the + loader, inheritance, `_sources` and registry all come free. Rejected because it + makes `Material` mean two things and puts massless, formula-less objects into + `pymat.materials`, `search()` and `mass_from_volume_mm3()`. +- **Finishes nested under the material they are measured against.** Rejected: + an interface is a relation, so nesting picks an arbitrary owner and duplicates + every entry across every crystal it could touch. +- **Overloading `refractive_index_at()` on argument type** (Quantity-in-Kelvin + vs Quantity-in-nm). Rejected as a silent behaviour change for every caller + since #148. +- **Promoting the structured λ slots from `dict` to `WavelengthCurve` on the + dataclass.** Rejected: breaks the JSON round-trip in the MCP client and the + on-disk shape written by the #164 enricher, for no gain the accessors do not + already provide. +- **Full Python-schema parity in `rs-materials`.** Rejected in favour of typed + physics plus `raw()` — see §7. +- **Free-text `_absent` reasons.** Rejected: the point of a declared absence is + that it can be counted. + +## Upgrade trigger + +Revisit when any of these becomes true: + +- A vendor ships a genuinely pre-wrapped assembly as a catalogue part, making + "as-shipped finish" a substance-side product fact (§9). +- Someone publishes a redistributable tabulated LYSO:Ce emission spectrum, or + paid access to Chen 2011 makes the Sellmeier coefficients available — both are + currently `_absent` and both would flip to real data. +- A measured interface appears that is not in `G4RealSurface`, forcing the + catalogue's `lut_*` identity fields to become optional in practice rather than + just in the type. +- Non-physics fields acquire a Rust consumer, at which point `raw()` stops being + sufficient and §7's line moves. diff --git a/mat-rs/Cargo.lock b/mat-rs/Cargo.lock index 815866c..a511f9b 100644 --- a/mat-rs/Cargo.lock +++ b/mat-rs/Cargo.lock @@ -112,7 +112,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rs-materials" -version = "0.2.0" +version = "0.3.0" dependencies = [ "approx", "regex", diff --git a/mat-rs/Cargo.toml b/mat-rs/Cargo.toml index c0a780c..63447f4 100644 --- a/mat-rs/Cargo.toml +++ b/mat-rs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rs-materials" -version = "0.2.0" +version = "0.3.0" edition = "2024" description = "Material database and formula parsing for Monte Carlo particle transport" license = "MIT" diff --git a/mat-rs/data/README.md b/mat-rs/data/README.md index fde3124..6fc8d7e 100644 --- a/mat-rs/data/README.md +++ b/mat-rs/data/README.md @@ -20,4 +20,11 @@ Add the TOML in `src/pymat/data/.toml`, then add a symlink here: ln -s ../../src/pymat/data/.toml mat-rs/data/.toml ``` -And register the category in `mat-rs/src/db.rs` via the `BUILTIN_DATA` array. +And register the category in `mat-rs/src/db.rs` via the `BUILTIN_TOML` array. + +## `surfaces.toml` is not a material category + +`surfaces.toml` (#243) is symlinked here on the same terms, but it holds +`Surface` entries — measured optical interfaces — not materials. It is embedded +by `mat-rs/src/surface.rs` and read by `SurfaceDb`, not by `MaterialDb`, so it +appears in neither `BUILTIN_TOML` nor `CATEGORIES`. diff --git a/mat-rs/data/surfaces.toml b/mat-rs/data/surfaces.toml new file mode 120000 index 0000000..032cb79 --- /dev/null +++ b/mat-rs/data/surfaces.toml @@ -0,0 +1 @@ +../../src/pymat/data/surfaces.toml \ No newline at end of file diff --git a/mat-rs/examples/dump_parity.rs b/mat-rs/examples/dump_parity.rs new file mode 100644 index 0000000..2cedbda --- /dev/null +++ b/mat-rs/examples/dump_parity.rs @@ -0,0 +1,69 @@ +//! Dump every material's resolved fields, one line each, for cross-checking +//! against the Python loader. +//! +//! This exists so that `tests/test_rs_python_parity.py` can prove the two +//! loaders resolve the same TOML to the same values. The two sides drifting +//! apart — the Rust crate silently lagging the Python schema — is the failure +//! this repo has already been bitten by (#157, and again in the strata brief of +//! 2026-08-14), so it is worth a gate rather than a convention. +//! +//! Output is intentionally dumb and line-oriented: `key|field=value|...`, with +//! values rendered via `{:?}` so `None` and `Some(x)` are unambiguous. +//! +//! cargo run --example dump_parity + +/// Sorted, comma-joined keys — so a dropped provenance row shows up as a diff +/// rather than as nothing at all. +fn sorted_join<'a>(keys: impl Iterator) -> String { + let mut v: Vec<&str> = keys.map(|s| s.as_str()).collect(); + v.sort_unstable(); + v.join(",") +} + +fn main() { + let db = rs_materials::MaterialDb::builtin(); + let mut keys: Vec<&str> = db.keys().collect(); + keys.sort_unstable(); + + for key in keys { + let m = db.get(key).expect("key came from the db"); + let o = m.optical(); + let n = m.nuclear(); + let t = m.thermal(); + println!( + "{key}|name={}|formula={:?}|density={:?}|grade={:?}|temper={:?}|treatment={:?}\ + |vendor={:?}|n={:?}|ly={:?}|dt={:?}|rt={:?}|ep={:?}|refl={:?}|transp={:?}\ + |abs={:?}|reabs={:?}|matrix={:?}|reemit={:?}|dopant={:?}|dopant_pct={:?}\ + |hygro={:?}|radlen={:?}|intlen={:?}|activity={:?}|melt={:?}|tc={:?}|tags={}|srckeys={}|abskeys={}", + m.name, + m.formula, + m.density, + m.grade, + m.temper, + m.treatment, + m.vendor, + o.and_then(|x| x.refractive_index), + o.and_then(|x| x.light_yield), + o.and_then(|x| x.decay_time), + o.and_then(|x| x.rise_time), + o.and_then(|x| x.emission_peak), + o.and_then(|x| x.reflectivity), + o.and_then(|x| x.transparency), + o.and_then(|x| x.absorption_length), + o.and_then(|x| x.absorption_length_reabs), + o.and_then(|x| x.absorption_length_matrix), + o.and_then(|x| x.reemit_qe), + o.and_then(|x| x.dopant.clone()), + o.and_then(|x| x.dopant_pct), + o.and_then(|x| x.hygroscopic), + n.and_then(|x| x.radiation_length), + n.and_then(|x| x.interaction_length), + n.and_then(|x| x.intrinsic_activity_bq_per_g), + t.and_then(|x| x.melting_point), + t.and_then(|x| x.thermal_conductivity), + m.tags.join(","), + sorted_join(m.sources.keys()), + sorted_join(m.absent.keys()), + ); + } +} diff --git a/mat-rs/src/curves.rs b/mat-rs/src/curves.rs new file mode 100644 index 0000000..11fc262 --- /dev/null +++ b/mat-rs/src/curves.rs @@ -0,0 +1,268 @@ +//! Piecewise-linear property curves — the Rust mirror of `pymat.curves`. +//! +//! One type covers both axes. On the Python side `TempCurve` and +//! `WavelengthCurve` are distinct so that `_at(T)` and `n_at(lambda)` cannot be +//! confused at a call site; here the axis is fixed by the field the curve hangs +//! off, so a single `Curve` with a documented abscissa unit is enough. +//! +//! **Out-of-range abscissae are CLAMPED, not extrapolated.** This mirrors +//! ADR-0003 §2 / ADR-0004 §4: data extrapolated past its measured range is a +//! lie, and clamping is conservative and visibly wrong rather than subtly +//! wrong. Use [`Curve::range`] to find out where the measurement actually +//! stops — a resampler that silently clamps a 128-node grid onto a curve +//! measured over 60 nm has fabricated the other 68 nodes. + +/// Value-column spellings accepted for a wavelength curve, mirroring +/// `pymat.curves._WL_VALUE_KEYS`. The non-canonical ones exist because the +/// structured optical slots shipped before the curve primitive did. +pub const WAVELENGTH_VALUE_KEYS: &[&str] = &["values", "n", "intensities"]; + +/// A piecewise-linear curve over a strictly-ascending abscissa. +/// +/// `xs` is Kelvin for temperature curves and nanometres for wavelength curves. +#[derive(Debug, Clone, PartialEq)] +pub struct Curve { + xs: Vec, + ys: Vec, +} + +impl Curve { + /// Build a curve, validating the knots. + /// + /// Returns `None` when the knots are empty, length-mismatched, or not + /// strictly ascending — the same three conditions that raise `ValueError` + /// at load time on the Python side. + pub fn new(xs: Vec, ys: Vec) -> Option { + if xs.is_empty() || xs.len() != ys.len() { + return None; + } + // `partial_cmp` rather than `>=` so NaN is rejected too: `a >= b` is + // false for NaN, which would let a NaN knot through validation. + if xs + .windows(2) + .any(|w| !matches!(w[0].partial_cmp(&w[1]), Some(std::cmp::Ordering::Less))) + { + return None; + } + Some(Self { xs, ys }) + } + + /// Evaluate at `x`. Outside the knot range this CLAMPS to the nearest knot. + pub fn interpolate(&self, x: f64) -> f64 { + if x <= self.xs[0] { + return self.ys[0]; + } + if x >= self.xs[self.xs.len() - 1] { + return self.ys[self.ys.len() - 1]; + } + // Knots are strictly ascending, so partition_point gives the first + // index with xs[i] > x; the bracketing pair is (i-1, i). + let i = self.xs.partition_point(|&k| k <= x); + let (x0, x1) = (self.xs[i - 1], self.xs[i]); + let (y0, y1) = (self.ys[i - 1], self.ys[i]); + y0 + (x - x0) / (x1 - x0) * (y1 - y0) + } + + /// `(min, max)` of the measured abscissa — the span outside which + /// [`Curve::interpolate`] clamps. + pub fn range(&self) -> (f64, f64) { + (self.xs[0], self.xs[self.xs.len() - 1]) + } + + /// True when `x` falls inside the measured range (i.e. the result of + /// `interpolate(x)` is interpolated rather than clamped). + pub fn covers(&self, x: f64) -> bool { + let (lo, hi) = self.range(); + (lo..=hi).contains(&x) + } + + /// The knot abscissae. + pub fn xs(&self) -> &[f64] { + &self.xs + } + + /// The knot ordinates. + pub fn ys(&self) -> &[f64] { + &self.ys + } + + /// Number of knots. + pub fn len(&self) -> usize { + self.xs.len() + } + + /// Always false — a `Curve` cannot be constructed empty. + pub fn is_empty(&self) -> bool { + false + } + + /// Resample onto a uniform grid of `n` points spanning `lo..=hi`. + /// + /// Convenience for downstream flattening (strata resamples every spectrum + /// onto a shared lambda grid). Values outside the measured range are + /// clamped — check [`Curve::covers`] first if that matters. + pub fn resample(&self, lo: f64, hi: f64, n: usize) -> Vec { + if n == 0 { + return Vec::new(); + } + if n == 1 { + return vec![self.interpolate(lo)]; + } + let step = (hi - lo) / (n - 1) as f64; + (0..n) + .map(|i| self.interpolate(lo + step * i as f64)) + .collect() + } + + /// Parse from a TOML table of the form `{ = [...], = [...] }`. + pub fn from_toml(table: &toml::Table, x_key: &str, y_key: &str) -> Option { + let xs = float_array(table.get(x_key)?)?; + let ys = float_array(table.get(y_key)?)?; + Self::new(xs, ys) + } + + /// Parse a wavelength curve, accepting every value-column spelling used + /// on disk: `values`, `n` (dispersion), `intensities` (emission spectra). + /// + /// Returns `None` when the table names **more than one** of them. Picking + /// the first would make the file's meaning depend on this function's + /// internal ordering, and a spectrum silently interpolated on the wrong + /// column is worse than no spectrum. The Python side raises on the same + /// input (`WavelengthCurve.from_toml`); prefer + /// [`Curve::from_wavelength_toml_keyed`] where the caller knows the slot. + pub fn from_wavelength_toml(table: &toml::Table) -> Option { + let mut present = WAVELENGTH_VALUE_KEYS + .iter() + .filter(|k| table.contains_key(**k)); + let y_key = present.next()?; + if present.next().is_some() { + return None; // ambiguous — refuse rather than guess + } + Self::from_toml(table, "wavelengths_nm", y_key) + } + + /// Parse a wavelength curve with an explicitly named ordinate column. + /// + /// This is what the loader uses: each structured slot knows its own column + /// (`refractive_index_dispersion` -> `n`, `emission_spectrum` -> + /// `intensities`), so a file that writes the wrong one fails here instead + /// of being interpolated against the wrong data. + pub fn from_wavelength_toml_keyed(table: &toml::Table, y_key: &str) -> Option { + Self::from_toml(table, "wavelengths_nm", y_key) + } + + /// Parse a temperature curve (`{ temps_K = [...], values = [...] }`). + pub fn from_temp_toml(table: &toml::Table) -> Option { + Self::from_toml(table, "temps_K", "values") + } +} + +/// Coerce a TOML array of numbers (ints or floats) to `Vec`. +pub(crate) fn float_array(value: &toml::Value) -> Option> { + value + .as_array()? + .iter() + .map(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn interpolates_between_knots() { + let c = Curve::new(vec![400.0, 500.0], vec![1.0, 2.0]).unwrap(); + assert!((c.interpolate(450.0) - 1.5).abs() < 1e-12); + } + + #[test] + fn returns_exact_knot_values() { + let c = Curve::new(vec![400.0, 500.0, 600.0], vec![1.0, 2.0, 3.0]).unwrap(); + assert_eq!(c.interpolate(400.0), 1.0); + assert_eq!(c.interpolate(500.0), 2.0); + assert_eq!(c.interpolate(600.0), 3.0); + } + + #[test] + fn clamps_outside_range() { + let c = Curve::new(vec![400.0, 500.0], vec![1.8, 1.7]).unwrap(); + assert_eq!(c.interpolate(100.0), 1.8); + assert_eq!(c.interpolate(900.0), 1.7); + } + + #[test] + fn single_knot_is_constant() { + let c = Curve::new(vec![420.0], vec![1.82]).unwrap(); + assert_eq!(c.interpolate(1.0), 1.82); + assert_eq!(c.interpolate(420.0), 1.82); + assert_eq!(c.interpolate(9000.0), 1.82); + } + + #[test] + fn rejects_invalid_knots() { + assert!(Curve::new(vec![], vec![]).is_none()); + assert!(Curve::new(vec![1.0, 2.0], vec![1.0]).is_none()); + assert!(Curve::new(vec![2.0, 1.0], vec![1.0, 2.0]).is_none()); + // Equal adjacent knots make interpolation ambiguous. + assert!(Curve::new(vec![1.0, 1.0], vec![1.0, 2.0]).is_none()); + } + + #[test] + fn range_and_covers() { + let c = Curve::new(vec![400.0, 600.0], vec![1.0, 2.0]).unwrap(); + assert_eq!(c.range(), (400.0, 600.0)); + assert!(c.covers(500.0)); + assert!(!c.covers(300.0)); + assert!(!c.covers(700.0)); + } + + #[test] + fn resample_hits_endpoints() { + let c = Curve::new(vec![0.0, 10.0], vec![0.0, 10.0]).unwrap(); + let g = c.resample(0.0, 10.0, 11); + assert_eq!(g.len(), 11); + assert_eq!(g[0], 0.0); + assert_eq!(g[10], 10.0); + assert!((g[5] - 5.0).abs() < 1e-12); + } + + #[test] + fn wavelength_toml_accepts_each_column_spelling() { + for (key, val) in [("values", 1.5), ("n", 1.5), ("intensities", 1.5)] { + let t: toml::Table = + toml::from_str(&format!("wavelengths_nm = [400, 500]\n{key} = [1.0, 2.0]")) + .unwrap(); + let c = Curve::from_wavelength_toml(&t).expect(key); + assert!((c.interpolate(450.0) - val).abs() < 1e-12, "{key}"); + } + } + + #[test] + fn wavelength_toml_refuses_ambiguous_columns() { + // Two value columns: picking one would make the file's meaning depend + // on this function's internal ordering. Python raises here. + let t: toml::Table = + toml::from_str("wavelengths_nm = [400, 500]\nvalues = [1, 2]\nn = [3, 4]").unwrap(); + assert!(Curve::from_wavelength_toml(&t).is_none()); + // ...but an explicit key resolves it. + let c = Curve::from_wavelength_toml_keyed(&t, "n").unwrap(); + assert_eq!(c.interpolate(400.0), 3.0); + } + + #[test] + fn wavelength_toml_needs_a_value_column() { + let t: toml::Table = toml::from_str("wavelengths_nm = [400, 500]").unwrap(); + assert!(Curve::from_wavelength_toml(&t).is_none()); + } + + #[test] + fn many_knots_bracket_correctly() { + let xs: Vec = (0..100).map(|i| i as f64).collect(); + let ys: Vec = (0..100).map(|i| (i * 2) as f64).collect(); + let c = Curve::new(xs, ys).unwrap(); + assert!((c.interpolate(50.5) - 101.0).abs() < 1e-12); + assert!((c.interpolate(0.5) - 1.0).abs() < 1e-12); + assert!((c.interpolate(98.5) - 197.0).abs() < 1e-12); + } +} diff --git a/mat-rs/src/db.rs b/mat-rs/src/db.rs index 501f358..03b5cd0 100644 --- a/mat-rs/src/db.rs +++ b/mat-rs/src/db.rs @@ -3,8 +3,13 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use crate::curves::{Curve, float_array}; use crate::error::MatError; -use crate::material::{Material, NuclearProperties, OpticalProperties}; +use crate::material::{ + DecayComponent, Material, MechanicalProperties, NuclearProperties, OpticalProperties, + ThermalProperties, +}; +use crate::provenance::{Absent, Source, overlay, parse_absent, parse_sources}; /// Embedded TOML data files (compiled into the binary). const BUILTIN_TOML: &[(&str, &str)] = &[ @@ -28,16 +33,39 @@ const CATEGORIES: &[&str] = &[ "gases", ]; -/// Property-group keys that should NOT be treated as child materials. +/// Property-group keys that are NOT child materials. +/// +/// Must stay in sync with the Python loader's list. `magnetic`, `vacuum`, +/// `nuclear`, `vis` and `custom` were missing here and only avoided being +/// mis-parsed as child materials because `is_leaf_property` happened to reject +/// them — an accident that would break the moment any of them gained a +/// sub-table. Fixed in #243. const PROPERTY_GROUPS: &[&str] = &[ "mechanical", "thermal", "electrical", "optical", - "pbr", + "magnetic", + "vacuum", + "nuclear", "manufacturing", "compliance", "sourcing", + "vis", + "custom", + "pbr", +]; + +/// Material-node scalar keys that are not child materials either. +const LEAF_KEYS: &[&str] = &[ + "name", + "formula", + "composition", + "grade", + "temper", + "treatment", + "vendor", + "tags", ]; /// The material database. `Send + Sync` for `Arc` sharing. @@ -114,6 +142,11 @@ impl MaterialDb { self.materials.keys().map(|s| s.as_str()) } + /// All materials. + pub fn values(&self) -> impl Iterator { + self.materials.values() + } + /// Total number of materials loaded. pub fn len(&self) -> usize { self.materials.len() @@ -128,173 +161,391 @@ impl MaterialDb { /// Parse top-level TOML keys as root materials and recurse into children. fn parse_top_level(table: &toml::Table, out: &mut HashMap) { for (key, value) in table { + if key.starts_with('_') { + continue; + } if let Some(mat_table) = value.as_table() { - let mat = build_material(key, key, mat_table, None); - out.insert(key.clone(), mat); - parse_children(key, mat_table, out, mat_table); + resolve_node( + key, + key, + mat_table, + &toml::Table::new(), + &HashMap::new(), + &HashMap::new(), + out, + ); } } } -/// Recursively parse child materials from nested TOML tables. -fn parse_children( - parent_key: &str, - table: &toml::Table, +/// Recursively resolve a material node, applying parent inheritance. +/// +/// `inherited` is the merged property state flowing down from ancestors — the +/// Rust equivalent of the Python loader's `deepcopy(parent_props)` overlay. +/// Previously this crate only ever consulted ONE level of parent, so a +/// grandchild (`lyso.Ce.saint_gobain.prelude420`) silently lost anything its +/// grandparent declared. Fixed in #243. +fn resolve_node( + full_key: &str, + local_key: &str, + node: &toml::Table, + inherited: &toml::Table, + parent_sources: &HashMap, + parent_absent: &HashMap, out: &mut HashMap, - parent_table: &toml::Table, ) { - for (child_key, child_value) in table { - if PROPERTY_GROUPS.contains(&child_key.as_str()) { + let merged = merge_node(inherited, node); + + let sources = match node.get("_sources").and_then(|v| v.as_table()) { + Some(t) => overlay(parent_sources, parse_sources(t)), + None => parent_sources.clone(), + }; + let absent = match node.get("_absent").and_then(|v| v.as_table()) { + Some(t) => overlay(parent_absent, parse_absent(t)), + None => parent_absent.clone(), + }; + + let name = merged + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(local_key) + .to_string(); + let str_field = |k: &str| merged.get(k).and_then(|v| v.as_str()).map(str::to_string); + + let mechanical = group(&merged, "mechanical").map(parse_mechanical); + let density = mechanical.as_ref().and_then(|m| m.density); + + let material = Material { + key: full_key.to_string(), + name, + formula: str_field("formula"), + composition: extract_composition(&merged), + density, + treatment: str_field("treatment"), + grade: str_field("grade"), + temper: str_field("temper"), + vendor: str_field("vendor"), + tags: merged + .get("tags") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|t| t.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(), + optical: group(&merged, "optical").map(parse_optical), + nuclear: group(&merged, "nuclear").map(parse_nuclear), + mechanical, + thermal: group(&merged, "thermal").map(parse_thermal), + sources: sources.clone(), + absent: absent.clone(), + raw: merged.clone(), + }; + out.insert(full_key.to_string(), material); + + // A child must not inherit its parent's identity. + let mut child_inherited = merged; + for identity in ["name", "formula", "composition"] { + child_inherited.remove(identity); + } + + for (child_key, child_value) in node { + if child_key.starts_with('_') + || PROPERTY_GROUPS.contains(&child_key.as_str()) + || LEAF_KEYS.contains(&child_key.as_str()) + { continue; } if let Some(child_table) = child_value.as_table() { - // Skip if this looks like a property (has no sub-tables that aren't property groups) - if is_leaf_property(child_table) { - continue; - } - let full_key = format!("{parent_key}.{child_key}"); - let mat = build_material(&full_key, child_key, child_table, Some(parent_table)); - out.insert(full_key.clone(), mat); - parse_children(&full_key, child_table, out, child_table); + resolve_node( + &format!("{full_key}.{child_key}"), + child_key, + child_table, + &child_inherited, + &sources, + &absent, + out, + ); } } } -/// Check if a table is a leaf property value (not a child material). -/// A child material typically has property group sub-tables or a `name` key. -fn is_leaf_property(table: &toml::Table) -> bool { - // If it has a `name` key, it's definitely a material - if table.contains_key("name") { - return false; - } - // If it has any property group sub-tables, it's a material - for key in PROPERTY_GROUPS { - if table.contains_key(*key) { - return false; +/// Overlay a node's own fields and property groups onto the inherited state. +/// +/// Property groups merge key-by-key rather than wholesale: a child that sets +/// `[lyso.Ce.optical] light_yield` must keep its parent's `refractive_index`, +/// which a whole-table replacement would drop. +fn merge_node(inherited: &toml::Table, node: &toml::Table) -> toml::Table { + let mut merged = inherited.clone(); + for (key, value) in node { + if key.starts_with('_') { + continue; } - } - // If it has any sub-table children that aren't property groups, it's a material - for (key, val) in table { - if val.is_table() && !PROPERTY_GROUPS.contains(&key.as_str()) { - return false; + if PROPERTY_GROUPS.contains(&key.as_str()) + && let Some(own_group) = value.as_table() + { + let base = merged.get(key).and_then(|v| v.as_table()).cloned(); + let mut group = base.unwrap_or_default(); + for (gk, gv) in own_group { + group.insert(gk.clone(), gv.clone()); + } + merged.insert(key.clone(), toml::Value::Table(group)); + continue; + } + // `tags` is the one leaf key that UNIONS with the parent rather than + // replacing it — a child declares only what is new and inherits the + // rest (#132). Replacing would make `stainless.s316L` claim four tags + // where py-mat reports seven, and `raw()` promises the merged view. + if key == "tags" { + merged.insert(key.clone(), toml::Value::Array(union_tags(&merged, value))); + continue; + } + if LEAF_KEYS.contains(&key.as_str()) { + merged.insert(key.clone(), value.clone()); } } - true + merged } -/// Build a Material from a TOML table, optionally inheriting from a parent. -fn build_material( - full_key: &str, - local_key: &str, - table: &toml::Table, - parent_table: Option<&toml::Table>, -) -> Material { - let name = table - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or(local_key) - .to_string(); - - let formula = table - .get("formula") - .and_then(|v| v.as_str()) - .or_else(|| { - parent_table - .and_then(|p| p.get("formula")) - .and_then(|v| v.as_str()) - }) - .map(|s| s.to_string()); +/// Order-preserving union of inherited tags and a node's own: parent context +/// first, then the child's specific labels, duplicates dropped. +fn union_tags(merged: &toml::Table, own: &toml::Value) -> Vec { + let mut out: Vec = merged + .get("tags") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + if let Some(own_tags) = own.as_array() { + for tag in own_tags { + if !out.contains(tag) { + out.push(tag.clone()); + } + } + } + out +} - let composition = - extract_composition(table).or_else(|| parent_table.and_then(extract_composition)); +fn group<'a>(table: &'a toml::Table, name: &str) -> Option<&'a toml::Table> { + table.get(name).and_then(|v| v.as_table()) +} - let density = extract_density(table).or_else(|| parent_table.and_then(extract_density)); +// --------------------------------------------------------------------------- +// Scalar / structured extraction +// --------------------------------------------------------------------------- + +/// Read a scalar, accepting the plain form, the `_value` form, and the +/// `{ nominal, stddev }` / `{ min, max }` uncertainty tables. +/// +/// Returns `(nominal, stddev)`. +fn number(table: &toml::Table, key: &str) -> Option<(f64, Option)> { + let raw = table + .get(key) + .or_else(|| table.get(&format!("{key}_value")))?; + + let as_f64 = |v: &toml::Value| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)); + + let (nominal, mut stddev) = if let Some(t) = raw.as_table() { + let nominal = t.get("nominal").and_then(as_f64).or_else(|| { + // {min, max} with no nominal -> midpoint, half-range as stddev. + let lo = t.get("min").and_then(as_f64)?; + let hi = t.get("max").and_then(as_f64)?; + Some((lo + hi) / 2.0) + })?; + let sd = t.get("stddev").and_then(as_f64).or_else(|| { + let lo = t.get("min").and_then(as_f64)?; + let hi = t.get("max").and_then(as_f64)?; + Some((hi - lo) / 2.0) + }); + (nominal, sd) + } else { + (as_f64(raw)?, None) + }; - let optical = extract_optical(table, parent_table); - let nuclear = extract_nuclear(table, parent_table); + // Sibling `_stddev` sugar. + if stddev.is_none() { + stddev = table.get(&format!("{key}_stddev")).and_then(as_f64); + } + Some((nominal, stddev)) +} - Material { - key: full_key.to_string(), - name, - formula, - composition, - density, - optical, - nuclear, +/// Read a scalar into `out`, recording any stddev in `sd`. +fn scalar(table: &toml::Table, key: &str, sd: &mut HashMap) -> Option { + let (nominal, stddev) = number(table, key)?; + if let Some(s) = stddev { + sd.insert(key.to_string(), s); } + Some(nominal) } -fn extract_composition(table: &toml::Table) -> Option> { - let comp = table.get("composition")?; - let comp_table = comp.as_table()?; - let mut map = HashMap::new(); - for (k, v) in comp_table { - if let Some(f) = v.as_float().or_else(|| v.as_integer().map(|i| i as f64)) { - map.insert(k.clone(), f); - } +/// The ordinate column each structured wavelength slot uses on disk. Mirrors +/// `pymat.loader._WAVELENGTH_SLOTS` — naming the column explicitly means a file +/// that writes the wrong one yields no curve instead of a curve interpolated +/// against the wrong data. +fn wl_value_key(slot: &str) -> &'static str { + match slot { + "refractive_index_dispersion" => "n", + "emission_spectrum" => "intensities", + _ => "values", } - if map.is_empty() { None } else { Some(map) } } -fn extract_density(table: &toml::Table) -> Option { - let mech = table.get("mechanical")?.as_table()?; - // Try *_value format first, then plain - mech.get("density_value") - .or_else(|| mech.get("density")) - .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64))) +fn wl_curve(table: &toml::Table, key: &str) -> Option { + Curve::from_wavelength_toml_keyed(table.get(key)?.as_table()?, wl_value_key(key)) } -fn extract_optical( - table: &toml::Table, - parent_table: Option<&toml::Table>, -) -> Option { - let opt_table = table.get("optical").and_then(|v| v.as_table()); - let parent_opt = parent_table - .and_then(|p| p.get("optical")) - .and_then(|v| v.as_table()); - - if opt_table.is_none() && parent_opt.is_none() { - return None; +fn temp_curve(table: &toml::Table, key: &str) -> Option { + Curve::from_temp_toml(table.get(key)?.as_table()?) +} + +fn parse_optical(t: &toml::Table) -> OpticalProperties { + let mut sd = HashMap::new(); + OpticalProperties { + refractive_index: scalar(t, "refractive_index", &mut sd), + light_yield: scalar(t, "light_yield", &mut sd), + decay_time: scalar(t, "decay_time", &mut sd), + rise_time: scalar(t, "rise_time", &mut sd), + emission_peak: scalar(t, "emission_peak", &mut sd), + emission_range: t.get("emission_range").and_then(|v| { + let a = float_array(v)?; + (a.len() == 2).then(|| (a[0], a[1])) + }), + transparency: scalar(t, "transparency", &mut sd), + reflectivity: scalar(t, "reflectivity", &mut sd), + reflectivity_spectrum: wl_curve(t, "reflectivity_spectrum"), + transparency_spectrum: wl_curve(t, "transparency_spectrum"), + absorption_length: scalar(t, "absorption_length", &mut sd), + absorption_coefficient: scalar(t, "absorption_coefficient", &mut sd), + scattering_length: scalar(t, "scattering_length", &mut sd), + rayleigh_length: scalar(t, "rayleigh_length", &mut sd), + absorption_length_matrix: scalar(t, "absorption_length_matrix", &mut sd), + absorption_length_reabs: scalar(t, "absorption_length_reabs", &mut sd), + reemit_qe: scalar(t, "reemit_qe", &mut sd), + dopant: t.get("dopant").and_then(|v| v.as_str()).map(str::to_string), + dopant_pct: scalar(t, "dopant_pct", &mut sd), + non_proportionality: scalar(t, "non_proportionality", &mut sd), + intrinsic_resolution_pct_at_662kev: scalar( + t, + "intrinsic_resolution_pct_at_662keV", + &mut sd, + ), + temperature_coefficient_light_yield: scalar( + t, + "temperature_coefficient_light_yield", + &mut sd, + ), + hygroscopic: t.get("hygroscopic").and_then(|v| v.as_bool()), + refractive_index_dispersion: wl_curve(t, "refractive_index_dispersion"), + km_k: t + .get("kubelka_munk") + .and_then(|v| v.as_table()) + .and_then(|tt| Curve::from_wavelength_toml_keyed(tt, "k")), + km_s: t + .get("kubelka_munk") + .and_then(|v| v.as_table()) + .and_then(|tt| Curve::from_wavelength_toml_keyed(tt, "s")), + extinction: t + .get("refractive_index_dispersion") + .and_then(|v| v.as_table()) + .and_then(|tt| Curve::from_wavelength_toml_keyed(tt, "k")), + emission_spectrum: wl_curve(t, "emission_spectrum"), + absorption_length_spectrum: wl_curve(t, "absorption_length_spectrum"), + absorption_length_matrix_spectrum: wl_curve(t, "absorption_length_matrix_spectrum"), + absorption_length_reabs_spectrum: wl_curve(t, "absorption_length_reabs_spectrum"), + refractive_index_curve: temp_curve(t, "refractive_index_curve"), + light_yield_curve: temp_curve(t, "light_yield_curve"), + decay_time_curve: temp_curve(t, "decay_time_curve"), + decay_components: parse_decay_components(t), + stddev: sd, } +} - let get = |key: &str| -> Option { - opt_table - .and_then(|t| t.get(key)) - .or_else(|| parent_opt.and_then(|t| t.get(key))) - .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64))) +fn parse_decay_components(t: &toml::Table) -> Vec { + let Some(array) = t.get("decay_components").and_then(|v| v.as_array()) else { + return Vec::new(); }; + array + .iter() + .filter_map(|entry| { + let e = entry.as_table()?; + let g = |k: &str| { + e.get(k) + .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64))) + }; + Some(DecayComponent { + tau_ns: g("tau_ns")?, + fraction: g("fraction")?, + }) + }) + .collect() +} + +fn parse_nuclear(t: &toml::Table) -> NuclearProperties { + let mut sd = HashMap::new(); + NuclearProperties { + radiation_length: scalar(t, "radiation_length", &mut sd), + interaction_length: scalar(t, "interaction_length", &mut sd), + moliere_radius: scalar(t, "moliere_radius", &mut sd), + z_eff: scalar(t, "Z_eff", &mut sd), + mean_excitation_energy_ev: scalar(t, "mean_excitation_energy_eV", &mut sd), + intrinsic_activity_bq_per_g: scalar(t, "intrinsic_activity_Bq_per_g", &mut sd), + stddev: sd, + } +} - Some(OpticalProperties { - refractive_index: get("refractive_index"), - light_yield: get("light_yield"), - decay_time: get("decay_time"), - emission_peak: get("emission_peak"), - }) +fn parse_mechanical(t: &toml::Table) -> MechanicalProperties { + let mut sd = HashMap::new(); + MechanicalProperties { + density: scalar(t, "density", &mut sd), + youngs_modulus: scalar(t, "youngs_modulus", &mut sd), + poissons_ratio: scalar(t, "poissons_ratio", &mut sd), + stddev: sd, + } } -fn extract_nuclear( - table: &toml::Table, - parent_table: Option<&toml::Table>, -) -> Option { - let nuc_table = table.get("nuclear").and_then(|v| v.as_table()); - let parent_nuc = parent_table - .and_then(|p| p.get("nuclear")) - .and_then(|v| v.as_table()); - - if nuc_table.is_none() && parent_nuc.is_none() { - return None; +fn parse_thermal(t: &toml::Table) -> ThermalProperties { + let mut sd = HashMap::new(); + ThermalProperties { + thermal_conductivity: scalar(t, "thermal_conductivity", &mut sd), + specific_heat: scalar(t, "specific_heat", &mut sd), + thermal_expansion: scalar(t, "thermal_expansion", &mut sd), + melting_point: scalar(t, "melting_point", &mut sd), + thermal_conductivity_curve: temp_curve(t, "thermal_conductivity_curve"), + specific_heat_curve: temp_curve(t, "specific_heat_curve"), + thermal_expansion_curve: temp_curve(t, "thermal_expansion_curve"), + stddev: sd, } +} - let get = |key: &str| -> Option { - nuc_table - .and_then(|t| t.get(key)) - .or_else(|| parent_nuc.and_then(|t| t.get(key))) - .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64))) +/// One composition entry's fraction. +/// +/// Entries are usually plain numbers but may be `{nominal, stddev}` or +/// `{min, max}` tables — the alloy TOMLs use ranges for trace elements. The +/// old parser only handled the plain form and dropped the rest, so an alloy +/// specified with ranges came through with holes in its composition. +fn composition_fraction(value: &toml::Value) -> Option { + if let Some(f) = value + .as_float() + .or_else(|| value.as_integer().map(|i| i as f64)) + { + return Some(f); + } + let inner = value.as_table()?; + let as_f64 = |k: &str| { + inner + .get(k) + .and_then(|x| x.as_float().or_else(|| x.as_integer().map(|i| i as f64))) }; + as_f64("nominal").or_else(|| Some((as_f64("min")? + as_f64("max")?) / 2.0)) +} - Some(NuclearProperties { - radiation_length: get("radiation_length"), - interaction_length: get("interaction_length"), - moliere_radius: get("moliere_radius"), - }) +fn extract_composition(table: &toml::Table) -> Option> { + let comp_table = table.get("composition")?.as_table()?; + let mut map = HashMap::new(); + for (k, v) in comp_table { + if let Some(fraction) = composition_fraction(v) { + map.insert(k.clone(), fraction); + } + } + if map.is_empty() { None } else { Some(map) } } diff --git a/mat-rs/src/lib.rs b/mat-rs/src/lib.rs index c10ae8f..fb7a488 100644 --- a/mat-rs/src/lib.rs +++ b/mat-rs/src/lib.rs @@ -6,6 +6,50 @@ //! (density, formula, composition, optical/scintillator data) for use in //! Rust-based physics engines like strata. //! +//! ## Scope +//! +//! The **physics** domains are typed in full fidelity — optical, nuclear, +//! mechanical, thermal — including wavelength-resolved spectra, temperature +//! curves, uncertainty, `_sources` provenance and `_absent` declarations. +//! `manufacturing`, `compliance`, `sourcing` and `vis` are deliberately not +//! typed; they remain reachable through [`Material::raw`], so no field in the +//! database is ever unusable from Rust. See ADR-0004 §7. +//! +//! ## Optical transport +//! +//! ``` +//! use rs_materials::MaterialDb; +//! +//! let db = MaterialDb::builtin(); +//! let lyso = db.get("lyso").unwrap(); +//! let opt = lyso.optical().unwrap(); +//! +//! // Wavelength accessors fall back to the scalar and clamp outside the +//! // measured range — they never extrapolate. +//! assert_eq!(opt.n_at(420.0), Some(1.82)); +//! +//! // Self-absorption is a distinct fate from matrix loss. +//! assert_eq!(opt.absorption_length_reabs, Some(588.0)); +//! +//! // A value can be absent *and say why*. +//! assert!(lyso.is_absent("optical.emission_spectrum")); +//! ``` +//! +//! ## Surface finishes +//! +//! ``` +//! use rs_materials::{Coupling, SurfaceDb}; +//! +//! let surfaces = SurfaceDb::builtin(); +//! let s = surfaces.by_lut_surface("PolishedESRGrease_LUT").unwrap(); +//! assert!(s.is_optical_contact()); +//! assert_eq!(s.coupling_index, Some(1.465)); +//! +//! // Air-gap and index-filled coupling are physically different and are +//! // distinguishable here. +//! assert_eq!(surfaces.with_coupling(Coupling::AirGap).len(), 21); +//! ``` +//! //! ## Quick start //! //! ``` @@ -23,16 +67,25 @@ //! assert_eq!(elems[0], ("Lu".into(), 1.8)); //! ``` +pub mod curves; pub mod db; pub mod elements; pub mod error; pub mod formula; pub mod material; +pub mod provenance; +pub mod surface; // Re-exports for convenience. +pub use curves::Curve; pub use db::MaterialDb; pub use error::MatError; pub use formula::{ atom_to_mass_fractions, formula_to_mass_fractions, mass_to_atom_fractions, parse_formula, }; -pub use material::{Material, OpticalProperties}; +pub use material::{ + DecayComponent, Material, MechanicalProperties, NuclearProperties, OpticalProperties, + ThermalProperties, +}; +pub use provenance::{Absent, Source}; +pub use surface::{Coupling, LutFamily, Surface, SurfaceDb}; diff --git a/mat-rs/src/material.rs b/mat-rs/src/material.rs index e6683c6..d6fc256 100644 --- a/mat-rs/src/material.rs +++ b/mat-rs/src/material.rs @@ -1,32 +1,431 @@ //! Material types and property structs. +//! +//! # What is typed here, and what is not +//! +//! This crate types the **physics** domains in full fidelity — optical, +//! nuclear, mechanical, thermal — including structured spectra, temperature +//! curves, uncertainty and provenance. It deliberately does **not** mirror +//! `manufacturing`, `compliance`, `sourcing` or `vis`: machinability and RoHS +//! status are real facts, but they are not transport-kernel inputs, and every +//! mirrored field is a permanent maintenance and semver obligation. +//! +//! Nothing is unreachable as a result. [`Material::raw`] hands back the merged +//! TOML table for any material, so a consumer that needs an untyped field can +//! always get it. That is a guarantee; a promise to maintain full parity would +//! decay the first time the two sides were edited a week apart. See ADR-0004 §7. use std::collections::HashMap; -/// Optical / scintillator properties relevant for radiation physics. -#[derive(Debug, Clone, Default)] +use crate::curves::Curve; +use crate::provenance::{Absent, Source}; + +/// Path-length multiplier `1/cos(theta)` for a slab at incidence `theta`. +/// +/// Capped at 40 (~88.6 degrees): beyond that a plane-parallel slab model has +/// stopped describing anything real, and a finite large number is less +/// misleading than an infinity. +pub fn obliquity_factor(incidence_deg: f64) -> f64 { + let theta = incidence_deg.abs().to_radians(); + if theta >= std::f64::consts::FRAC_PI_2 { + return 40.0; + } + (1.0 / theta.cos()).min(40.0) +} + +/// One exponential component of a multi-exponential scintillation decay. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct DecayComponent { + /// Time constant, ns. + pub tau_ns: f64, + /// Fractional amplitude. Fractions across all components sum to ~1. + pub fraction: f64, +} + +/// Optical / scintillator properties relevant for photon transport. +/// +/// Scalars are the nominal value; per-field standard deviations live in +/// [`OpticalProperties::stddev`] rather than wrapping every field in a +/// `Value` struct, which would make the common path (`opt.light_yield`) worse +/// for the sake of the rare one. +#[derive(Debug, Clone, Default, PartialEq)] pub struct OpticalProperties { - /// Index of refraction. + /// Index of refraction (scalar; see `refractive_index_dispersion` for n(lambda)). pub refractive_index: Option, /// Scintillation light yield (photons/MeV). pub light_yield: Option, /// Primary decay time (ns). pub decay_time: Option, + /// Rise time (ns). + pub rise_time: Option, /// Peak emission wavelength (nm). pub emission_peak: Option, + /// `(min, max)` emission wavelengths (nm). + pub emission_range: Option<(f64, f64)>, + /// Transmission, % (0-100). + pub transparency: Option, + /// Bulk reflectivity, % (0-100). + pub reflectivity: Option, + /// Reflectivity vs lambda, % — diffuse reflectors are strongly + /// wavelength-dependent at the blue end, where scintillators emit. + pub reflectivity_spectrum: Option, + /// Transmission vs lambda, % at the path length named in `_sources`. + pub transparency_spectrum: Option, + /// Lumped bulk attenuation length (mm). + pub absorption_length: Option, + /// Absorption coefficient (1/cm). + pub absorption_coefficient: Option, + /// Scattering length (cm). + pub scattering_length: Option, + /// Rayleigh scattering length (cm). + pub rayleigh_length: Option, + + // --- the self-absorption split (ADR-0004 §5) ------------------------- + /// Host-matrix attenuation length (mm) — true loss. Photon is gone. + pub absorption_length_matrix: Option, + /// Activator self-absorption length (mm). The photon may be re-emitted + /// after a fresh decay draw, with probability `reemit_qe`. + pub absorption_length_reabs: Option, + /// Probability in `[0, 1]` that a reabsorbed photon is re-emitted. + pub reemit_qe: Option, + + // --- activator --------------------------------------------------------- + /// Activator element, e.g. `"Ce"`, `"Tl"`. + pub dopant: Option, + /// Activator concentration, mol %. + pub dopant_pct: Option, + + // --- detector-physics scalars ----------------------------------------- + pub non_proportionality: Option, + pub intrinsic_resolution_pct_at_662kev: Option, + pub temperature_coefficient_light_yield: Option, + pub hygroscopic: Option, + + // --- wavelength-resolved (nm abscissa) -------------------------------- + /// n(lambda). + pub refractive_index_dispersion: Option, + /// k(lambda) — the extinction column of the same dispersion table. + pub extinction: Option, + /// Kubelka-Munk absorption coefficient k(lambda), 1/cm. + pub km_k: Option, + /// Kubelka-Munk scattering coefficient s(lambda), 1/cm. + pub km_s: Option, + /// Relative emission intensity vs lambda. + pub emission_spectrum: Option, + /// Lumped attenuation length vs lambda (mm). + pub absorption_length_spectrum: Option, + /// Matrix-loss length vs lambda (mm). + pub absorption_length_matrix_spectrum: Option, + /// Self-absorption length vs lambda (mm). + pub absorption_length_reabs_spectrum: Option, + + // --- temperature-resolved (K abscissa) -------------------------------- + pub refractive_index_curve: Option, + pub light_yield_curve: Option, + pub decay_time_curve: Option, + + /// Multi-exponential decay, when published. + pub decay_components: Vec, + + /// Per-field standard deviations, keyed by field name. + pub stddev: HashMap, +} + +impl OpticalProperties { + /// Standard deviation for a field name, if the data carries one. + pub fn stddev_of(&self, field: &str) -> Option { + self.stddev.get(field).copied() + } + + /// Refractive index at a wavelength (nm). Dispersion beats the scalar; + /// outside the measured range the dispersion curve clamps. + pub fn n_at(&self, wavelength_nm: f64) -> Option { + match &self.refractive_index_dispersion { + Some(c) => Some(c.interpolate(wavelength_nm)), + None => self.refractive_index, + } + } + + /// Lumped attenuation length (mm) at a wavelength (nm). + pub fn absorption_length_at(&self, wavelength_nm: f64) -> Option { + match &self.absorption_length_spectrum { + Some(c) => Some(c.interpolate(wavelength_nm)), + None => self.absorption_length, + } + } + + /// Matrix-loss length (mm) at a wavelength (nm). + pub fn absorption_length_matrix_at(&self, wavelength_nm: f64) -> Option { + match &self.absorption_length_matrix_spectrum { + Some(c) => Some(c.interpolate(wavelength_nm)), + None => self.absorption_length_matrix, + } + } + + /// Self-absorption length (mm) at a wavelength (nm). + pub fn absorption_length_reabs_at(&self, wavelength_nm: f64) -> Option { + match &self.absorption_length_reabs_spectrum { + Some(c) => Some(c.interpolate(wavelength_nm)), + None => self.absorption_length_reabs, + } + } + + /// Reflectivity (%) at a wavelength (nm). Spectrum beats the scalar. + pub fn reflectivity_at(&self, wavelength_nm: f64) -> Option { + match &self.reflectivity_spectrum { + Some(c) => Some(c.interpolate(wavelength_nm)), + None => self.reflectivity, + } + } + + /// Transmission (%) at a wavelength (nm). Spectrum beats the scalar. + pub fn transparency_at(&self, wavelength_nm: f64) -> Option { + match &self.transparency_spectrum { + Some(c) => Some(c.interpolate(wavelength_nm)), + None => self.transparency, + } + } + + /// Extinction coefficient at a wavelength (nm), or None if transparent. + pub fn k_at(&self, wavelength_nm: f64) -> Option { + Some(self.extinction.as_ref()?.interpolate(wavelength_nm)) + } + + /// Normal-incidence reflectance from vacuum, PERCENT. + /// + /// `R = ((n-1)^2 + k^2) / ((n+1)^2 + k^2)`. Derived rather than stored, so + /// it cannot drift from the n,k it comes from. This is the smooth, + /// optically-thick, normal-incidence ceiling — a real wrap measures lower. + pub fn normal_reflectance_at(&self, wavelength_nm: f64) -> Option { + let n = self.n_at(wavelength_nm)?; + let k = self.k_at(wavelength_nm).unwrap_or(0.0); + Some(100.0 * ((n - 1.0).powi(2) + k * k) / ((n + 1.0).powi(2) + k * k)) + } + + /// Kubelka-Munk thick-layer reflectance, PERCENT. + /// + /// `R_inf = 1 + k/s - sqrt((k/s)^2 + 2k/s)`. + pub fn km_reflectance_infinite_at(&self, wavelength_nm: f64) -> Option { + let k = self.km_k.as_ref()?.interpolate(wavelength_nm); + let s = self.km_s.as_ref()?.interpolate(wavelength_nm); + if s <= 0.0 { + return None; + } + let x = k / s; + Some(100.0 * (1.0 + x - (x * x + 2.0 * x).sqrt())) + } + + /// `(R, T, A)` in PERCENT for a finite layer — every photon's fate. + /// + /// Sums to 100 by construction. `T` is the inter-crystal crosstalk channel + /// in a segmented detector, `A` is the only true loss, and `R` is the + /// reflectance the layer actually delivers — which is NOT + /// [`km_reflectance_infinite_at`] unless the layer is optically thick. + /// + /// **The layer is against a non-reflecting (black) backing** — a + /// transmitted photon is gone from this interface's point of view, which + /// is the correct model for an inter-crystal septum. + /// + /// There is deliberately no `backing` parameter. With a reflective + /// backing, K-M's `R` is the reflectance of the COMPOSITE (layer plus + /// backing), while `T` is the layer's own transmittance; they are not two + /// parts of one photon budget, so `A := 100 - R - T` stops meaning + /// "absorbed" and can go negative. An earlier revision exposed such a + /// parameter and documented a conservation property it did not have. + /// + /// **`incidence_deg` is for COLLIMATED light at a known angle.** For + /// diffuse illumination pass 0. + /// + /// Two traps, both of which have caught real consumers: + /// + /// 1. A diffuse reflector ERASES the incident angular distribution. After + /// one contact with a Lambertian surface, direction is cosine-distributed + /// about that normal with mean `|cos theta| = 2/3` (48.2 degrees), + /// regardless of how the light arrived. "High-aspect crystal, therefore + /// grazing incidence" is wrong as soon as the wall is diffuse. + /// 2. Kubelka-Munk `k` and `s` are already defined for DIFFUSE flux — the + /// obliquity is baked in (hence the factor 2 in the usual `K = 2k`). + /// Multiplying by `1/cos` on top of that double-counts. + /// + /// Note the limit: with `k = 0` the thick-layer reflectance is exactly 1, + /// not 0.999. Absorption is the only thing that puts R_inf below unity. + pub fn km_split_at( + &self, + wavelength_nm: f64, + thickness_cm: f64, + incidence_deg: f64, + ) -> Option<(f64, f64, f64)> { + let k = self.km_k.as_ref()?.interpolate(wavelength_nm); + let s = self.km_s.as_ref()?.interpolate(wavelength_nm); + if s <= 0.0 || thickness_cm <= 0.0 { + return None; + } + let thickness_cm = thickness_cm * obliquity_factor(incidence_deg); + if k == 0.0 { + let sd = s * thickness_cm; + return Some((100.0 * sd / (1.0 + sd), 100.0 / (1.0 + sd), 0.0)); + } + let a = 1.0 + k / s; + let b = (a * a - 1.0).sqrt(); + let bsd = b * s * thickness_cm; + // Optically thick limit. `cosh` overflows an f64 above ~710 and `coth` + // is 1.0 to machine precision by ~20, so branch before the arithmetic + // can blow up. EXACT, not an approximation: coth -> 1 gives + // R = 1/(a+b), which is identically R_inf. + if bsd > 20.0 { + let r = 1.0 / (a + b); + return Some((100.0 * r, 0.0, 100.0 * (1.0 - r))); + } + let coth = bsd.cosh() / bsd.sinh(); + let r = 1.0 / (a + b * coth); + let t = b / (a * bsd.sinh() + b * bsd.cosh()); + Some((100.0 * r, 100.0 * t, 100.0 * (1.0 - r - t))) + } + + /// Reflectance (%) of a FINITE layer — the number a real reflector gives. + /// + /// Prefer this over [`km_reflectance_infinite_at`] for any physical layer. + pub fn km_reflectance_at( + &self, + wavelength_nm: f64, + thickness_cm: f64, + incidence_deg: f64, + ) -> Option { + Some( + self.km_split_at(wavelength_nm, thickness_cm, incidence_deg)? + .0, + ) + } + + /// Kubelka-Munk diffuse transmittance through a FINITE layer, PERCENT. + /// + /// Answers a different question from [`km_reflectance_infinite_at`]: + /// reflectance converges to its thick-layer limit quickly, transmittance + /// does not. A layer can be "optically thick" for reflectance and still + /// transmit several percent — in a segmented detector that is the + /// inter-crystal crosstalk channel. + pub fn km_transmittance_at( + &self, + wavelength_nm: f64, + thickness_cm: f64, + incidence_deg: f64, + ) -> Option { + Some( + self.km_split_at(wavelength_nm, thickness_cm, incidence_deg)? + .1, + ) + } + + /// Relative emission intensity at a wavelength (nm). + /// + /// There is deliberately no scalar fallback: `emission_peak` is one point + /// on a band, not a stand-in for its shape. A caller with only a peak + /// should sample monochromatically and know that it is doing so. + pub fn emission_at(&self, wavelength_nm: f64) -> Option { + Some(self.emission_spectrum.as_ref()?.interpolate(wavelength_nm)) + } + + /// Refractive index at a temperature (K). Curve beats the scalar. + pub fn refractive_index_at_temp(&self, temp_k: f64) -> Option { + match &self.refractive_index_curve { + Some(c) => Some(c.interpolate(temp_k)), + None => self.refractive_index, + } + } + + /// Light yield at a temperature (K). Curve beats the scalar. + pub fn light_yield_at_temp(&self, temp_k: f64) -> Option { + match &self.light_yield_curve { + Some(c) => Some(c.interpolate(temp_k)), + None => self.light_yield, + } + } + + /// Decay time at a temperature (K). Curve beats the scalar. + pub fn decay_time_at_temp(&self, temp_k: f64) -> Option { + match &self.decay_time_curve { + Some(c) => Some(c.interpolate(temp_k)), + None => self.decay_time, + } + } } /// Nuclear / radiation-physics scalars (#157). /// /// Moved here from `OpticalProperties` to mirror the Python schema — /// `radiation_length` and friends are nuclear physics, not optics. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct NuclearProperties { - /// Radiation length X₀ (cm). + /// Radiation length X0 (cm). pub radiation_length: Option, - /// Nuclear interaction length λ (cm). + /// Nuclear interaction length lambda (cm). pub interaction_length: Option, - /// Molière radius (cm). + /// Moliere radius (cm). pub moliere_radius: Option, + /// Effective atomic number. + pub z_eff: Option, + /// Geant4 mean excitation energy (eV). + pub mean_excitation_energy_ev: Option, + /// Intrinsic activity (Bq/g) — LYSO's 176-Lu content, for instance. + pub intrinsic_activity_bq_per_g: Option, + /// Per-field standard deviations. + pub stddev: HashMap, +} + +impl NuclearProperties { + pub fn stddev_of(&self, field: &str) -> Option { + self.stddev.get(field).copied() + } +} + +/// Mechanical properties a transport or geometry stage may need. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct MechanicalProperties { + /// Density (g/cm^3). + pub density: Option, + /// Young's modulus (GPa). + pub youngs_modulus: Option, + /// Poisson's ratio. + pub poissons_ratio: Option, + /// Per-field standard deviations. + pub stddev: HashMap, +} + +impl MechanicalProperties { + pub fn stddev_of(&self, field: &str) -> Option { + self.stddev.get(field).copied() + } +} + +/// Thermal properties, including temperature-dependent curves. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ThermalProperties { + /// Thermal conductivity (W/(m*K)). + pub thermal_conductivity: Option, + /// Specific heat (J/(kg*K)). + pub specific_heat: Option, + /// Linear thermal expansion (1/K). + pub thermal_expansion: Option, + /// Melting point (degC). + pub melting_point: Option, + pub thermal_conductivity_curve: Option, + pub specific_heat_curve: Option, + pub thermal_expansion_curve: Option, + /// Per-field standard deviations. + pub stddev: HashMap, +} + +impl ThermalProperties { + pub fn stddev_of(&self, field: &str) -> Option { + self.stddev.get(field).copied() + } + + /// Thermal conductivity at a temperature (K). Curve beats the scalar. + pub fn thermal_conductivity_at(&self, temp_k: f64) -> Option { + match &self.thermal_conductivity_curve { + Some(c) => Some(c.interpolate(temp_k)), + None => self.thermal_conductivity, + } + } } /// A material with its physical properties. @@ -43,16 +442,40 @@ pub struct Material { /// Elemental composition as `{symbol: fraction}`. /// Interpretation (mass vs atom) depends on the source data. pub composition: Option>, - /// Density in g/cm³. + /// Density in g/cm^3. Kept at the top level for backward compatibility; + /// also present on `mechanical`. pub density: Option, + /// Surface treatment, e.g. `"polished"`, `"electropolished"`. + pub treatment: Option, + /// Grade designation. + pub grade: Option, + /// Temper designation (e.g. `"T6"`). + pub temper: Option, + /// Vendor key. + pub vendor: Option, + /// Multi-axial filterable labels (#132), orthogonal to the TOML hierarchy. + /// Children inherit their ancestors' tags and extend — parent context + /// first, then the child's own, duplicates dropped. + pub tags: Vec, /// Optical / scintillator properties. pub optical: Option, /// Nuclear / radiation-physics scalars. pub nuclear: Option, + /// Mechanical properties. + pub mechanical: Option, + /// Thermal properties. + pub thermal: Option, + /// Provenance, keyed by dotted property path (`"optical.light_yield"`). + pub sources: HashMap, + /// Declared absences, keyed by dotted property path. + pub absent: HashMap, + /// The merged TOML table this material was built from, parent fields + /// included. The escape hatch for anything this crate does not type. + pub(crate) raw: toml::Table, } impl Material { - /// Density in g/cm³ (convenience accessor). + /// Density in g/cm^3 (convenience accessor). pub fn density(&self) -> Option { self.density } @@ -79,4 +502,59 @@ impl Material { pub fn nuclear(&self) -> Option<&NuclearProperties> { self.nuclear.as_ref() } + + /// Return the mechanical properties, if any. + pub fn mechanical(&self) -> Option<&MechanicalProperties> { + self.mechanical.as_ref() + } + + /// Return the thermal properties, if any. + pub fn thermal(&self) -> Option<&ThermalProperties> { + self.thermal.as_ref() + } + + /// Provenance for a dotted property path, falling back to `_default`. + pub fn source_of(&self, path: &str) -> Option<&Source> { + self.sources + .get(path) + .or_else(|| self.sources.get("_default")) + } + + /// Declared absence for a dotted property path. + /// + /// Unlike [`Material::source_of`] there is no `_default` fallback — an + /// absence is always specific to one property. + pub fn absent_reason(&self, path: &str) -> Option<&Absent> { + self.absent.get(path) + } + + /// True when `path` carries an explicit absence declaration. + /// + /// Distinguishes "nobody looked" (`false`, value is simply `None`) from + /// "we looked and the number does not exist" (`true`). + pub fn is_absent(&self, path: &str) -> bool { + self.absent.contains_key(path) + } + + /// The merged TOML table backing this material, with parent fields applied. + /// + /// The escape hatch of ADR-0004 §7: every field in the database is + /// reachable from Rust even when this crate does not type it. + /// + /// ``` + /// # use rs_materials::MaterialDb; + /// let db = MaterialDb::builtin(); + /// let peek = db.get("peek").unwrap(); + /// // `manufacturing` is not typed by this crate — but it is not lost. + /// let printable = peek + /// .raw() + /// .get("manufacturing") + /// .and_then(|m| m.as_table()) + /// .and_then(|m| m.get("printable_fdm")) + /// .and_then(|v| v.as_bool()); + /// assert_eq!(printable, Some(true)); + /// ``` + pub fn raw(&self) -> &toml::Table { + &self.raw + } } diff --git a/mat-rs/src/provenance.rs b/mat-rs/src/provenance.rs new file mode 100644 index 0000000..bf6fd3d --- /dev/null +++ b/mat-rs/src/provenance.rs @@ -0,0 +1,91 @@ +//! Provenance and declared absences — the Rust mirror of `pymat.sources`. +//! +//! Both are sidecars keyed by dotted property path (`"optical.light_yield"`), +//! carried on [`crate::Material`] and [`crate::Surface`] rather than wrapped +//! around each value. A value stays an `f64`; where it came from is metadata +//! *about* the value (ADR-0003 §1). + +use std::collections::HashMap; + +/// Where a value came from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Source { + /// Short BibTeX-style key, e.g. `"bosca_lopez_2023"`. + pub citation: String, + /// One of `doi`, `qid`, `handbook`, `vendor`, `measured`. + pub kind: String, + /// The reference itself — a DOI, a Wikidata QID, a URL, a handbook page. + pub reference: String, + /// `CC0`, `PD-USGov`, `CC-BY-3.0`, `CC-BY-4.0`, `CC-BY-SA-4.0`, + /// `Geant4-SL`, or `proprietary-reference-only`. + pub license: String, + /// Free-text detail — measurement conditions, caveats, corroboration. + pub note: Option, +} + +impl Source { + pub(crate) fn from_toml(table: &toml::Table) -> Option { + let get = |k: &str| table.get(k).and_then(|v| v.as_str()).map(str::to_string); + Some(Self { + citation: get("citation")?, + kind: get("kind")?, + reference: get("ref")?, + license: get("license")?, + note: get("note"), + }) + } +} + +/// Why a value is missing. +/// +/// The negative twin of [`Source`]. A `None` property with no `Absent` entry +/// means "we have not said anything about this"; a `None` with an entry means +/// "we looked, and here is why there is no number". A transport engine should +/// treat those differently — the first is a gap in the database, the second is +/// a fact about the literature (ADR-0004 §6). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Absent { + /// One of `not-measured`, `not-applicable`, `not-separable`, + /// `proprietary`, `pending`. Validated on the Python side at load. + pub reason: String, + /// What was searched for, what was found instead, what would fill the gap. + pub note: Option, +} + +impl Absent { + pub(crate) fn from_toml(table: &toml::Table) -> Option { + Some(Self { + reason: table.get("reason")?.as_str()?.to_string(), + note: table + .get("note") + .and_then(|v| v.as_str()) + .map(str::to_string), + }) + } +} + +/// Parse a `_sources` table into `{path: Source}`. +pub(crate) fn parse_sources(table: &toml::Table) -> HashMap { + table + .iter() + .filter_map(|(k, v)| Some((k.clone(), Source::from_toml(v.as_table()?)?))) + .collect() +} + +/// Parse an `_absent` table into `{path: Absent}`. +pub(crate) fn parse_absent(table: &toml::Table) -> HashMap { + table + .iter() + .filter_map(|(k, v)| Some((k.clone(), Absent::from_toml(v.as_table()?)?))) + .collect() +} + +/// Overlay `child` onto `parent`; child wins on collision. +pub(crate) fn overlay( + parent: &HashMap, + child: HashMap, +) -> HashMap { + let mut out = parent.clone(); + out.extend(child); + out +} diff --git a/mat-rs/src/surface.rs b/mat-rs/src/surface.rs new file mode 100644 index 0000000..c5b175b --- /dev/null +++ b/mat-rs/src/surface.rs @@ -0,0 +1,376 @@ +//! Measured optical surface finishes — the Rust mirror of `pymat.surfaces`. +//! +//! A [`Surface`] is an *interface*, not a bulk: a crystal face with a given +//! treatment, a reflector, and whatever fills the gap between them. It has no +//! density and no formula, so it is a separate type with a separate database +//! rather than a [`crate::Material`] (ADR-0004 §2). +//! +//! The catalogue holds **measured** interfaces only — the 21 LBNL and 9 DAVIS +//! look-up tables shipped in the Geant4 `G4RealSurface` 2.2 data set. Geant4's +//! six analytic UNIFIED finishes (`polished`, `ground`, …) carry no measured +//! data and are model selections, so they belong to the consuming engine's +//! configuration, not here (ADR-0004 §3). +//! +//! Assignments — which face of which crystal carries which finish — are not +//! here either. That is a fact about a built detector. + +use std::collections::HashMap; + +use crate::curves::Curve; +use crate::error::MatError; +use crate::provenance::{Absent, Source, overlay, parse_absent, parse_sources}; + +/// What fills the gap between a crystal face and its reflector. +/// +/// The distinction is physically load-bearing: an air gap means the photon +/// meets a crystal→air Fresnel step first (large index contrast, small +/// critical angle, strong total-internal-reflection light-piping — the +/// mechanism depth-of-interaction designs exploit), while optical contact +/// means it meets the coupling polymer directly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Coupling { + /// A reflector is present, with air between it and the face. + AirGap, + /// The gap is index-filled (grease, glue, meltmount). + OpticalContact, + /// No reflector at all — a bare face against the ambient. + None, +} + +impl Coupling { + fn parse(s: &str) -> Option { + match s { + "air_gap" => Some(Self::AirGap), + "optical_contact" => Some(Self::OpticalContact), + "none" => Some(Self::None), + _ => None, + } + } +} + +/// Which measured look-up-table family an entry belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LutFamily { + /// Janecek & Moses 2010, goniometer measurements. `dielectric_LUT`. + Lbnl, + /// Roncali & Cherry 2013, AFM topography. `dielectric_LUTDAVIS`. + Davis, +} + +impl LutFamily { + fn parse(s: &str) -> Option { + match s { + "lbnl" => Some(Self::Lbnl), + "davis" => Some(Self::Davis), + _ => None, + } + } +} + +/// One measured optical interface. +#[derive(Debug, Clone)] +pub struct Surface { + /// Catalogue key, e.g. `"davis.polished_esr_grease"`. + pub key: String, + /// Human-readable name. + pub name: String, + /// Measured LUT family. + pub lut_family: Option, + /// The exact `G4OpticalSurfaceFinish` enum spelling, verbatim. + /// + /// Casing is inconsistent across families (`polishedvm2000glue` for LBNL, + /// `PolishedESRGrease_LUT` for DAVIS) because Geant4's is. Match on it + /// as-is; do not normalise. + pub lut_surface: Option, + /// The exact `G4SurfaceType` the finish is valid with. + pub g4_surface_type: Option, + /// Data-set release, e.g. `"G4RealSurface-2.2"`. + pub lut_dataset: Option, + /// Crystal-face preparation: `polished`, `etched`, `ground`, `rough`. + pub treatment: Option, + /// Human label for the reflector. + pub reflector: Option, + /// Material key for the reflector, when this database has one. + pub reflector_material: Option, + /// What fills the gap. + pub coupling: Option, + /// Material key for the coupling medium, when this database has one. + pub coupling_material: Option, + /// Refractive index of the coupling medium. + pub coupling_index: Option, + /// Reflectivity, % (0-100). + pub reflectivity: Option, + /// Reflectivity vs wavelength (nm abscissa, % ordinate). + pub reflectivity_spectrum: Option, + /// Reflector film thickness (um). + pub thickness_um: Option, + /// Free-text note. + pub note: Option, + /// Provenance keyed by field name. + pub sources: HashMap, + /// Declared absences keyed by field name. + pub absent: HashMap, +} + +impl Surface { + /// True when the gap is index-filled rather than air. + pub fn is_optical_contact(&self) -> bool { + self.coupling == Some(Coupling::OpticalContact) + } + + /// True when a reflector sits behind an air gap — the configuration that + /// produces total-internal-reflection light-piping. + pub fn is_air_gap(&self) -> bool { + self.coupling == Some(Coupling::AirGap) + } + + /// Reflectivity (%) at a wavelength (nm). Spectrum beats scalar, clamped. + pub fn reflectivity_at(&self, wavelength_nm: f64) -> Option { + match &self.reflectivity_spectrum { + Some(c) => Some(c.interpolate(wavelength_nm)), + None => self.reflectivity, + } + } + + /// Provenance for a field name, falling back to `_default`. + pub fn source_of(&self, field: &str) -> Option<&Source> { + self.sources + .get(field) + .or_else(|| self.sources.get("_default")) + } + + /// Declared absence for a field name. + pub fn absent_reason(&self, field: &str) -> Option<&Absent> { + self.absent.get(field) + } +} + +/// The surface-finish catalogue. `Send + Sync` for `Arc` sharing. +#[derive(Debug, Clone)] +pub struct SurfaceDb { + surfaces: HashMap, +} + +const BUILTIN_SURFACES: &str = include_str!("../data/surfaces.toml"); + +impl SurfaceDb { + /// Load the built-in catalogue (embedded at compile time). + pub fn builtin() -> Self { + let table: toml::Table = + toml::from_str(BUILTIN_SURFACES).expect("embedded surfaces.toml should always parse"); + Self { + surfaces: parse_catalogue(&table), + } + } + + /// Load a catalogue from a `surfaces.toml` on disk. + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + let raw = std::fs::read_to_string(path).map_err(|e| MatError::TomlRead { + path: path.to_path_buf(), + source: e, + })?; + let table: toml::Table = toml::from_str(&raw).map_err(|e| MatError::TomlParse { + path: path.to_path_buf(), + source: e, + })?; + Ok(Self { + surfaces: parse_catalogue(&table), + }) + } + + /// Get a surface by key. Accepts `davis.rough` or `surface.davis.rough`. + pub fn get(&self, key: &str) -> Result<&Surface, MatError> { + let norm = key.strip_prefix("surface.").unwrap_or(key); + self.surfaces + .get(norm) + .ok_or_else(|| MatError::NotFound(key.to_string())) + } + + /// Look up by the exact `G4OpticalSurfaceFinish` enum spelling. + /// + /// This is the reverse index a consumer holding a Geant4 finish name needs. + pub fn by_lut_surface(&self, lut_surface: &str) -> Option<&Surface> { + self.surfaces + .values() + .find(|s| s.lut_surface.as_deref() == Some(lut_surface)) + } + + /// All entries in a family. + pub fn family(&self, family: LutFamily) -> Vec<&Surface> { + self.surfaces + .values() + .filter(|s| s.lut_family == Some(family)) + .collect() + } + + /// All entries with a given coupling. + pub fn with_coupling(&self, coupling: Coupling) -> Vec<&Surface> { + self.surfaces + .values() + .filter(|s| s.coupling == Some(coupling)) + .collect() + } + + /// All catalogue keys. + pub fn keys(&self) -> impl Iterator { + self.surfaces.keys().map(|s| s.as_str()) + } + + /// All entries. + pub fn values(&self) -> impl Iterator { + self.surfaces.values() + } + + /// Number of entries. + pub fn len(&self) -> usize { + self.surfaces.len() + } + + /// Whether the catalogue is empty. + pub fn is_empty(&self) -> bool { + self.surfaces.is_empty() + } +} + +impl Default for SurfaceDb { + fn default() -> Self { + Self::builtin() + } +} + +/// Fields that configure a node rather than naming a child surface. +const SURFACE_FIELDS: &[&str] = &[ + "name", + "model", + "treatment", + "lut_family", + "lut_surface", + "g4_surface_type", + "lut_dataset", + "reflector", + "reflector_material", + "coupling", + "coupling_material", + "coupling_index", + "reflectivity", + "reflectivity_spectrum", + "thickness_um", + "note", + "abstract", +]; + +fn parse_catalogue(root: &toml::Table) -> HashMap { + let mut out = HashMap::new(); + if let Some(surface_root) = root.get("surface").and_then(|v| v.as_table()) { + for (key, node) in surface_root { + if let Some(table) = node.as_table() { + resolve( + key, + table, + &toml::Table::new(), + &HashMap::new(), + &HashMap::new(), + &mut out, + ); + } + } + } + out +} + +fn resolve( + key: &str, + node: &toml::Table, + inherited: &toml::Table, + parent_sources: &HashMap, + parent_absent: &HashMap, + out: &mut HashMap, +) { + // Overlay this node's own fields onto whatever flowed down from the family. + let mut merged = inherited.clone(); + for field in SURFACE_FIELDS { + if let Some(v) = node.get(*field) { + merged.insert((*field).to_string(), v.clone()); + } + } + + let sources = match node.get("_sources").and_then(|v| v.as_table()) { + Some(t) => overlay(parent_sources, parse_sources(t)), + None => parent_sources.clone(), + }; + let absent = match node.get("_absent").and_then(|v| v.as_table()) { + Some(t) => overlay(parent_absent, parse_absent(t)), + None => parent_absent.clone(), + }; + + let is_abstract = merged + .get("abstract") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !is_abstract { + out.insert( + key.to_string(), + build_surface(key, &merged, &sources, &absent), + ); + } + + // Identity fields must not flow to children — they are what makes each + // entry a distinct measurement. + let mut child_inherited = merged; + for identity in ["name", "lut_surface", "note", "abstract"] { + child_inherited.remove(identity); + } + + for (child_key, child_value) in node { + if SURFACE_FIELDS.contains(&child_key.as_str()) || child_key.starts_with('_') { + continue; + } + if let Some(child_table) = child_value.as_table() { + resolve( + &format!("{key}.{child_key}"), + child_table, + &child_inherited, + &sources, + &absent, + out, + ); + } + } +} + +fn build_surface( + key: &str, + t: &toml::Table, + sources: &HashMap, + absent: &HashMap, +) -> Surface { + let s = |k: &str| t.get(k).and_then(|v| v.as_str()).map(str::to_string); + let f = |k: &str| { + t.get(k) + .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64))) + }; + Surface { + key: key.to_string(), + name: s("name").unwrap_or_else(|| key.to_string()), + lut_family: s("lut_family").as_deref().and_then(LutFamily::parse), + lut_surface: s("lut_surface"), + g4_surface_type: s("g4_surface_type"), + lut_dataset: s("lut_dataset"), + treatment: s("treatment"), + reflector: s("reflector"), + reflector_material: s("reflector_material"), + coupling: s("coupling").as_deref().and_then(Coupling::parse), + coupling_material: s("coupling_material"), + coupling_index: f("coupling_index"), + reflectivity: f("reflectivity"), + reflectivity_spectrum: t + .get("reflectivity_spectrum") + .and_then(|v| v.as_table()) + .and_then(Curve::from_wavelength_toml), + thickness_um: f("thickness_um"), + note: s("note"), + sources: sources.clone(), + absent: absent.clone(), + } +} diff --git a/mat-rs/tests/optical.rs b/mat-rs/tests/optical.rs new file mode 100644 index 0000000..d9a555c --- /dev/null +++ b/mat-rs/tests/optical.rs @@ -0,0 +1,766 @@ +//! Integration tests for the #243 schema growth: structured fields, curves, +//! uncertainty, provenance, declared absences, and the `raw()` escape hatch. +//! +//! Before #243 this crate exposed four `Option` optical scalars and +//! silently dropped everything else. These tests pin what it now carries. + +use rs_materials::{MaterialDb, SurfaceDb}; + +fn db() -> MaterialDb { + MaterialDb::builtin() +} + +// --------------------------------------------------------------------------- +// The nuclear/optical split (#157) — verify no drift in either direction +// --------------------------------------------------------------------------- + +#[test] +fn radiation_length_is_nuclear_not_optical() { + let db = db(); + let lyso = db.get("lyso").unwrap(); + let nuc = lyso.nuclear().unwrap(); + assert_eq!(nuc.radiation_length, Some(1.14)); + assert_eq!(nuc.interaction_length, Some(25.0)); + + // Nothing named radiation_length survives under optical. + assert!( + lyso.raw() + .get("optical") + .and_then(|o| o.as_table()) + .is_some() + ); + assert!( + lyso.raw()["optical"] + .as_table() + .unwrap() + .get("radiation_length") + .is_none(), + "radiation_length must not reappear under [optical]" + ); +} + +#[test] +fn intrinsic_activity_is_carried() { + let db = db(); + let nuc = db.get("lyso").unwrap().nuclear().unwrap(); + assert_eq!(nuc.intrinsic_activity_bq_per_g, Some(40.0)); +} + +// --------------------------------------------------------------------------- +// Structured optical fields +// --------------------------------------------------------------------------- + +#[test] +fn self_absorption_channel_is_exposed() { + let db = db(); + let opt = db.get("lyso").unwrap().optical().unwrap(); + assert_eq!(opt.absorption_length_reabs, Some(588.0)); + assert_eq!(opt.absorption_length_reabs_at(420.0), Some(588.0)); + // The matrix channel is not measured for LYSO and must NOT be invented. + assert_eq!(opt.absorption_length_matrix, None); +} + +#[test] +fn lumped_absorption_length_is_exposed() { + let db = db(); + let opt = db.get("lyso").unwrap().optical().unwrap(); + assert_eq!(opt.absorption_length, Some(200.0)); + assert_eq!(opt.absorption_length_at(500.0), Some(200.0)); +} + +#[test] +fn rise_time_and_dopant_are_exposed() { + let db = db(); + let opt = db.get("lyso.Ce").unwrap().optical().unwrap(); + assert_eq!(opt.rise_time, Some(0.072)); + assert_eq!(opt.dopant.as_deref(), Some("Ce")); + assert_eq!(opt.dopant_pct, Some(0.1)); +} + +#[test] +fn reflectivity_is_exposed() { + // `[esr.optical] reflectivity = 98.5` was on disk since #147 and dropped + // by BOTH loaders until #243. + let db = db(); + let opt = db.get("esr").unwrap().optical().unwrap(); + assert_eq!(opt.reflectivity, Some(98.5)); +} + +#[test] +fn emission_range_is_exposed() { + let db = db(); + let opt = db.get("lyso").unwrap().optical().unwrap(); + assert_eq!(opt.emission_range, Some((380.0, 600.0))); +} + +#[test] +fn hygroscopic_flag_is_exposed() { + let db = db(); + assert_eq!( + db.get("lyso").unwrap().optical().unwrap().hygroscopic, + Some(false) + ); +} + +#[test] +fn emission_at_has_no_scalar_fallback() { + // LYSO has an emission_peak but no spectrum — `emission_at` must return + // None rather than pretending the peak is the band. + let db = db(); + let opt = db.get("lyso").unwrap().optical().unwrap(); + assert_eq!(opt.emission_peak, Some(420.0)); + assert_eq!(opt.emission_at(420.0), None); +} + +#[test] +fn n_at_falls_back_to_the_scalar_when_no_dispersion() { + let db = db(); + let opt = db.get("lyso").unwrap().optical().unwrap(); + assert_eq!(opt.n_at(420.0), Some(1.82)); +} + +// --------------------------------------------------------------------------- +// Inheritance — including the grandchild case the old parser lost +// --------------------------------------------------------------------------- + +#[test] +fn grandchild_inherits_from_grandparent() { + let db = db(); + // prelude420 is lyso -> Ce -> saint_gobain -> prelude420. Its own table + // sets only light_yield/decay_time/refractive_index; everything else must + // come down the chain. + let p = db.get("lyso.Ce.saint_gobain.prelude420").unwrap(); + assert_eq!(p.density(), Some(7.1)); // from lyso.mechanical + let opt = p.optical().unwrap(); + assert_eq!(opt.light_yield, Some(33200.0)); // own + assert_eq!(opt.refractive_index, Some(1.81)); // own, overriding lyso's 1.82 + assert_eq!(opt.emission_peak, Some(420.0)); // from lyso + assert_eq!(opt.absorption_length_reabs, Some(588.0)); // from lyso + assert_eq!(opt.dopant.as_deref(), Some("Ce")); // from lyso.Ce +} + +#[test] +fn property_groups_merge_key_by_key() { + // lyso.Ce sets light_yield but not refractive_index; a whole-table + // replacement would drop the parent's value. + let db = db(); + let opt = db.get("lyso.Ce").unwrap().optical().unwrap(); + assert_eq!(opt.light_yield, Some(33000.0)); // own + assert_eq!(opt.refractive_index, Some(1.82)); // inherited +} + +#[test] +fn inherited_variant_carries_its_treatment() { + let db = db(); + let p = db.get("lyso.Ce.polished").unwrap(); + assert_eq!(p.treatment.as_deref(), Some("polished")); + assert_eq!(p.name, "LYSO:Ce, polished"); + assert_eq!(p.optical().unwrap().light_yield, Some(33000.0)); +} + +#[test] +fn a_child_does_not_inherit_its_parents_name() { + let db = db(); + assert_eq!(db.get("lyso").unwrap().name, "LYSO"); + assert_eq!(db.get("lyso.Ce").unwrap().name, "LYSO:Ce"); +} + +// --------------------------------------------------------------------------- +// Provenance and declared absences +// --------------------------------------------------------------------------- + +#[test] +fn sources_are_exposed() { + let db = db(); + let lyso = db.get("lyso").unwrap(); + let src = lyso.source_of("optical.absorption_length_reabs").unwrap(); + assert_eq!(src.citation, "bosca_lopez_2023"); + assert_eq!(src.kind, "doi"); + assert_eq!(src.reference, "10.1038/s41598-023-32689-z"); + assert_eq!(src.license, "CC-BY-4.0"); + assert!(src.note.is_some()); +} + +#[test] +fn sources_inherit_to_children() { + let db = db(); + let polished = db.get("lyso.Ce.polished").unwrap(); + assert_eq!( + polished + .source_of("optical.absorption_length_reabs") + .unwrap() + .citation, + "bosca_lopez_2023" + ); +} + +#[test] +fn declared_absences_are_exposed() { + let db = db(); + let lyso = db.get("lyso").unwrap(); + assert!(lyso.is_absent("optical.emission_spectrum")); + assert!(lyso.is_absent("optical.reemit_qe")); + let a = lyso.absent_reason("optical.reemit_qe").unwrap(); + assert_eq!(a.reason, "not-measured"); + assert!(a.note.as_ref().unwrap().contains("PLQY")); +} + +#[test] +fn absence_is_distinguishable_from_silence() { + let db = db(); + let lyso = db.get("lyso").unwrap(); + // Both are None on the value... + assert_eq!(lyso.optical().unwrap().reemit_qe, None); + assert_eq!(lyso.optical().unwrap().scattering_length, None); + // ...but only one was searched for. + assert!(lyso.is_absent("optical.reemit_qe")); + assert!(!lyso.is_absent("optical.scattering_length")); +} + +#[test] +fn absences_inherit_to_children() { + let db = db(); + assert!( + db.get("lyso.Ce.polished") + .unwrap() + .is_absent("optical.emission_spectrum") + ); +} + +// --------------------------------------------------------------------------- +// Uncertainty +// --------------------------------------------------------------------------- + +#[test] +fn nominal_stddev_tables_are_parsed() { + // The canonical uncertainty form from ADR-0003 §3. No material in the + // shipped corpus uses it yet — the schema landed in #149 but the data + // sweep has not happened — so this exercises the parser directly rather + // than asserting corpus presence, which would pass vacuously today and + // fail confusingly the day someone adds the first stddev. + let dir = std::env::temp_dir().join("rs_materials_stddev_test"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("metals.toml"), + r#" + [x] + name = "X" + [x.optical] + light_yield = { nominal = 33000.0, stddev = 1500.0 } + decay_time = 41.0 + "#, + ) + .unwrap(); + let db = MaterialDb::open(&dir).unwrap(); + let opt = db.get("x").unwrap().optical().unwrap(); + assert_eq!(opt.light_yield, Some(33000.0)); + assert_eq!(opt.stddev_of("light_yield"), Some(1500.0)); + // A plain scalar carries no uncertainty, and must not invent one. + assert_eq!(opt.decay_time, Some(41.0)); + assert_eq!(opt.stddev_of("decay_time"), None); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn formula_does_not_inherit_matching_python() { + // py-mat's loader reads `formula` from the node only, with no parent + // fallback — `pymat.lyso.Ce.saint_gobain.prelude420.formula` is None. + // This crate previously did a one-level parent lookup, which was a + // silent divergence from the source of truth. Now aligned (#243). + let db = db(); + assert_eq!( + db.get("lyso.Ce").unwrap().formula(), + Some("Lu1.8Y0.2SiO5:Ce") + ); + assert_eq!( + db.get("lyso.Ce.saint_gobain.prelude420").unwrap().formula(), + None + ); +} + +#[test] +fn min_max_becomes_nominal_and_half_range() { + let table: toml::Table = toml::from_str( + r#" + [x] + name = "X" + [x.mechanical] + density = { min = 2.0, max = 4.0 } + "#, + ) + .unwrap(); + let dir = std::env::temp_dir().join("rs_materials_minmax_test"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("metals.toml"), toml::to_string(&table).unwrap()).unwrap(); + let db = MaterialDb::open(&dir).unwrap(); + let m = db.get("x").unwrap(); + assert_eq!(m.density(), Some(3.0)); + assert_eq!(m.mechanical().unwrap().stddev_of("density"), Some(1.0)); + std::fs::remove_dir_all(&dir).ok(); +} + +// --------------------------------------------------------------------------- +// Curves +// --------------------------------------------------------------------------- + +#[test] +fn temperature_curves_are_parsed_and_clamp() { + let table: toml::Table = toml::from_str( + r#" + [x] + name = "X" + [x.thermal] + thermal_conductivity = 167.0 + thermal_conductivity_curve = { temps_K = [77, 293, 500], values = [105, 167, 192] } + "#, + ) + .unwrap(); + let dir = std::env::temp_dir().join("rs_materials_curve_test"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("metals.toml"), toml::to_string(&table).unwrap()).unwrap(); + let db = MaterialDb::open(&dir).unwrap(); + let th = db.get("x").unwrap().thermal().unwrap(); + assert_eq!(th.thermal_conductivity_at(293.0), Some(167.0)); + assert_eq!(th.thermal_conductivity_at(10.0), Some(105.0)); // clamped + assert_eq!(th.thermal_conductivity_at(9000.0), Some(192.0)); // clamped + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn wavelength_curves_parse_every_on_disk_spelling() { + let table: toml::Table = toml::from_str( + r#" + [x] + name = "X" + [x.optical] + refractive_index_dispersion = { wavelengths_nm = [400, 500], n = [1.9, 1.8] } + emission_spectrum = { wavelengths_nm = [400, 500], intensities = [0.5, 1.0] } + absorption_length_spectrum = { wavelengths_nm = [400, 500], values = [10.0, 20.0] } + "#, + ) + .unwrap(); + let dir = std::env::temp_dir().join("rs_materials_wl_test"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("metals.toml"), toml::to_string(&table).unwrap()).unwrap(); + let db = MaterialDb::open(&dir).unwrap(); + let opt = db.get("x").unwrap().optical().unwrap(); + assert_eq!(opt.n_at(450.0), Some(1.85)); + assert_eq!(opt.emission_at(450.0), Some(0.75)); + assert_eq!(opt.absorption_length_at(450.0), Some(15.0)); + // Dispersion beats the (absent) scalar, and the measured range is visible. + let (lo, hi) = opt.refractive_index_dispersion.as_ref().unwrap().range(); + assert_eq!((lo, hi), (400.0, 500.0)); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn decay_components_parse() { + let table: toml::Table = toml::from_str( + r#" + [x] + name = "X" + [x.optical] + decay_components = [{ tau_ns = 36.0, fraction = 0.9 }, { tau_ns = 600.0, fraction = 0.1 }] + "#, + ) + .unwrap(); + let dir = std::env::temp_dir().join("rs_materials_decay_test"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("metals.toml"), toml::to_string(&table).unwrap()).unwrap(); + let db = MaterialDb::open(&dir).unwrap(); + let comps = &db.get("x").unwrap().optical().unwrap().decay_components; + assert_eq!(comps.len(), 2); + assert_eq!(comps[0].tau_ns, 36.0); + assert_eq!(comps[1].fraction, 0.1); + std::fs::remove_dir_all(&dir).ok(); +} + +// --------------------------------------------------------------------------- +// The raw() escape hatch — ADR-0004 §7 +// --------------------------------------------------------------------------- + +#[test] +fn raw_reaches_untyped_groups() { + let db = db(); + let peek = db.get("peek").unwrap(); + let manufacturing = peek.raw().get("manufacturing").and_then(|v| v.as_table()); + assert!( + manufacturing.is_some(), + "raw() must reach groups this crate does not type" + ); +} + +#[test] +fn raw_has_parent_fields_applied() { + let db = db(); + let ce = db.get("lyso.Ce").unwrap(); + // `mechanical` is declared only on the parent. + let density = ce + .raw() + .get("mechanical") + .and_then(|m| m.as_table()) + .and_then(|m| m.get("density_value")) + .and_then(|v| v.as_float()); + assert_eq!(density, Some(7.1)); +} + +// --------------------------------------------------------------------------- +// Surface catalogue +// --------------------------------------------------------------------------- + +#[test] +fn surface_catalogue_loads_thirty_lut_entries() { + let sdb = SurfaceDb::builtin(); + assert_eq!(sdb.family(rs_materials::LutFamily::Lbnl).len(), 21); + assert_eq!(sdb.family(rs_materials::LutFamily::Davis).len(), 9); +} + +#[test] +fn non_lut_entries_carry_a_reflector_instead_of_a_table() { + // ADR-0004 §3: the test is measurement, not LUT-backing. These carry no + // angular table; their measured content is the reflector's reflectance, + // which lives on the material they name. + let sdb = SurfaceDb::builtin(); + let non_lut: Vec<&rs_materials::Surface> = + sdb.values().filter(|s| s.lut_family.is_none()).collect(); + assert_eq!(non_lut.len(), 2); + for s in &non_lut { + assert!(s.lut_surface.is_none(), "{}", s.key); + assert!(s.reflector_material.is_some(), "{}", s.key); + } + + let baso4 = sdb.get("diffuse.baso4_air").unwrap(); + assert_eq!(baso4.reflector_material.as_deref(), Some("baso4")); + assert!(baso4.is_air_gap()); + + // ...and the reflectance really is reachable on that material. + let mdb = MaterialDb::builtin(); + let opt = mdb.get("baso4").unwrap().optical().unwrap(); + assert_eq!(opt.reflectivity, Some(99.9)); + let curve = opt.reflectivity_spectrum.as_ref().expect("BaSO4 spectrum"); + assert!((curve.interpolate(420.0) - 99.90).abs() < 0.01); +} + +#[test] +fn readout_stack_indices_resolve() { + // grease -> window. CS steps down (TIR at that boundary), PE steps up. + let db = MaterialDb::builtin(); + let n = |k: &str| { + db.get(k) + .unwrap() + .optical() + .unwrap() + .refractive_index + .unwrap() + }; + let grease = n("bc630"); + assert_eq!(grease, 1.465); + assert!(n("sipm_window_silicone") < grease); + assert!(n("sipm_window_epoxy") > grease); +} + +#[test] +fn surface_lookup_by_key_and_prefix() { + let sdb = SurfaceDb::builtin(); + let a = sdb.get("davis.polished_esr_grease").unwrap(); + let b = sdb.get("surface.davis.polished_esr_grease").unwrap(); + assert_eq!(a.key, b.key); +} + +#[test] +fn surface_lookup_by_g4_finish_name() { + let sdb = SurfaceDb::builtin(); + let s = sdb.by_lut_surface("PolishedESRGrease_LUT").unwrap(); + assert_eq!(s.key, "davis.polished_esr_grease"); + assert!(s.is_optical_contact()); + assert_eq!(s.coupling_index, Some(1.465)); + + let lbnl = sdb.by_lut_surface("polishedvm2000glue").unwrap(); + assert_eq!(lbnl.coupling_index, Some(1.582)); + assert_eq!(lbnl.g4_surface_type.as_deref(), Some("dielectric_LUT")); +} + +#[test] +fn air_gap_and_optical_contact_are_distinguishable() { + let sdb = SurfaceDb::builtin(); + assert_eq!(sdb.with_coupling(rs_materials::Coupling::AirGap).len(), 21); + assert_eq!( + sdb.with_coupling(rs_materials::Coupling::OpticalContact) + .len(), + 8 + ); + + let air = sdb.by_lut_surface("polishedvm2000air").unwrap(); + let glue = sdb.by_lut_surface("polishedvm2000glue").unwrap(); + assert!(air.is_air_gap()); + assert!(!air.is_optical_contact()); + assert!(glue.is_optical_contact()); + assert_eq!(air.coupling_index, None); + assert_eq!(air.reflector_material, glue.reflector_material); +} + +#[test] +fn every_surface_carries_provenance() { + let sdb = SurfaceDb::builtin(); + for s in sdb.values() { + if s.lut_family.is_some() { + assert!(s.source_of("lut_surface").is_some(), "{}", s.key); + assert!(s.source_of("lut_family").is_some(), "{}", s.key); + } else { + // Non-LUT entries cite the reflector whose reflectance they use. + assert!(s.source_of("reflector_material").is_some(), "{}", s.key); + } + if s.coupling_index.is_some() { + assert!(s.source_of("coupling_index").is_some(), "{}", s.key); + } + } +} + +#[test] +fn surface_absences_are_exposed() { + let sdb = SurfaceDb::builtin(); + let s = sdb.get("davis.detector").unwrap(); + assert_eq!(s.coupling, None); + assert_eq!(s.absent_reason("coupling").unwrap().reason, "not-measured"); +} + +#[test] +fn analytic_finishes_are_not_in_the_catalogue() { + let sdb = SurfaceDb::builtin(); + for name in ["polished", "ground", "polishedbackpainted", "polishedair"] { + assert!( + sdb.by_lut_surface(name).is_none(), + "{name} is not a measured surface and must not be catalogued" + ); + } +} + +#[test] +fn surface_material_refs_resolve_against_the_material_db() { + let sdb = SurfaceDb::builtin(); + let mdb = db(); + for s in sdb.values() { + if let Some(key) = &s.reflector_material { + assert!(mdb.get(key).is_ok(), "{}: unknown reflector {key}", s.key); + } + if let Some(key) = &s.coupling_material { + assert!(mdb.get(key).is_ok(), "{}: unknown coupling {key}", s.key); + } + } +} + +#[test] +fn surface_db_is_send_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + assert_send_sync::(); +} + +// --------------------------------------------------------------------------- +// Kubelka-Munk — the crosstalk channel +// --------------------------------------------------------------------------- + +#[test] +fn kubelka_munk_reproduces_pattersons_published_reflectance() { + let db = db(); + let opt = db.get("baso4").unwrap().optical().unwrap(); + for (wl, published) in [(300.0, 96.24), (500.0, 98.15), (700.0, 98.46)] { + let got = opt.km_reflectance_infinite_at(wl).unwrap(); + assert!( + (got - published).abs() < 0.01, + "{wl} nm: {got} vs {published}" + ); + } +} + +#[test] +fn a_thin_septum_transmits_even_though_reflectance_has_converged() { + // The finding that opened the crosstalk channel: "optically thick" for + // reflectance does not mean opaque. + let db = db(); + let opt = db.get("baso4").unwrap().optical().unwrap(); + let t02 = opt.km_transmittance_at(420.0, 0.02, 0.0).unwrap(); + assert!(t02 > 5.0, "0.2 mm septum transmits {t02}%, expected >5%"); + let t06 = opt.km_transmittance_at(420.0, 0.06, 0.0).unwrap(); + assert!(t06 < t02 && t06 > 1.0); +} + +#[test] +fn the_two_baso4_reflectance_routes_disagree_and_that_is_recorded() { + let db = db(); + let m = db.get("baso4").unwrap(); + let opt = m.optical().unwrap(); + let grum = opt.reflectivity_at(420.0).unwrap(); + let patterson = opt.km_reflectance_infinite_at(420.0).unwrap(); + assert!(grum > patterson); + assert!((grum - 99.90).abs() < 0.01); + assert!((patterson - 97.18).abs() < 0.05); + let note = m + .source_of("optical.kubelka_munk") + .unwrap() + .note + .as_ref() + .unwrap(); + assert!(note.contains("TWO-SOURCE DISAGREEMENT")); +} + +#[test] +fn km_split_closes_and_matches_the_finite_reflectance() { + let db = db(); + let opt = db.get("baso4").unwrap().optical().unwrap(); + for mm in [0.1_f64, 0.2, 0.5, 1.0] { + let (r, t, a) = opt.km_split_at(420.0, mm / 10.0, 0.0).unwrap(); + assert!((r + t + a - 100.0).abs() < 1e-9, "{mm} mm: {r}+{t}+{a}"); + assert!(r >= 0.0 && t >= 0.0 && a >= 0.0); + } + // A 0.2 mm septum reflects ~92%, well below the ~97% thick-layer limit. + let r = opt.km_reflectance_at(420.0, 0.02, 0.0).unwrap(); + assert!((r - 91.9).abs() < 0.1, "R(0.2mm) = {r}"); + assert!(r < opt.km_reflectance_infinite_at(420.0).unwrap()); +} + +#[test] +fn a_long_enough_path_reaches_the_semi_infinite_limit() { + // Pure mathematics of the accessor. 76.7 degrees is an ARBITRARY long-path + // example, NOT a claim about any real geometry — an earlier version of this + // test asserted it was the physical angle inside a wrapped crystal, and + // that was retracted twice. A Lambertian septum randomises direction to a + // mean of 48.2 degrees regardless of crystal shape; see + // `a_thin_septum_is_not_optically_thick_at_realistic_angles`. + let db = db(); + let opt = db.get("baso4").unwrap().optical().unwrap(); + let r_normal = opt.km_reflectance_at(420.0, 0.02, 0.0).unwrap(); + let r_grazing = opt.km_reflectance_at(420.0, 0.02, 76.7).unwrap(); + let r_inf = opt.km_reflectance_infinite_at(420.0).unwrap(); + assert!((r_normal - 91.9).abs() < 0.1, "normal: {r_normal}"); + assert!((r_grazing - 96.9).abs() < 0.1, "grazing: {r_grazing}"); + assert!(r_inf - r_grazing < 0.5); + assert!(r_inf - r_normal > 5.0); + + let t_grazing = opt.km_transmittance_at(420.0, 0.02, 76.7).unwrap(); + assert!( + (t_grazing - 1.35).abs() < 0.05, + "T at 76.7 deg: {t_grazing}" + ); +} + +#[test] +fn obliquity_by_angle_equals_obliquity_by_thickness() { + let db = db(); + let opt = db.get("baso4").unwrap().optical().unwrap(); + // 1/cos(60 deg) = 2, so 0.02 cm at 60 deg == 0.04 cm at normal. + let by_angle = opt.km_split_at(420.0, 0.02, 60.0).unwrap(); + let by_thickness = opt.km_split_at(420.0, 0.04, 0.0).unwrap(); + assert!((by_angle.0 - by_thickness.0).abs() < 1e-9); + assert!((by_angle.1 - by_thickness.1).abs() < 1e-9); +} + +#[test] +fn a_thin_septum_is_not_optically_thick_at_realistic_angles() { + // A diffuse reflector erases the angular distribution it is given: after + // one Lambertian contact the mean is <|cos|> = 2/3, i.e. 48.2 degrees, with + // no dependence on crystal aspect ratio. At that angle a 0.2 mm septum + // still transmits ~5%. + let db = db(); + let opt = db.get("baso4").unwrap().optical().unwrap(); + let t_real = opt.km_transmittance_at(420.0, 0.02, 48.19).unwrap(); + let t_normal = opt.km_transmittance_at(420.0, 0.02, 0.0).unwrap(); + assert!((t_real - 5.1).abs() < 0.1, "T at Lambertian mean: {t_real}"); + assert!((t_normal - 7.6).abs() < 0.1, "T at normal: {t_normal}"); + + let r_inf = opt.km_reflectance_infinite_at(420.0).unwrap(); + let r_real = opt.km_reflectance_at(420.0, 0.02, 48.19).unwrap(); + assert!( + r_inf - r_real > 2.5, + "still far from the semi-infinite limit" + ); +} + +#[test] +fn obliquity_factor_matches_one_over_cos() { + use rs_materials::material::obliquity_factor; + assert!((obliquity_factor(0.0) - 1.0).abs() < 1e-12); + assert!((obliquity_factor(60.0) - 2.0).abs() < 1e-12); + // Lambertian mean angle -> 1.5, which is 1/. Note this is NOT the + // mean path multiplier: <1/cos> = 2 for the same distribution. + assert!((obliquity_factor(48.19) - 1.5).abs() < 1e-3); + assert_eq!(obliquity_factor(90.0), 40.0); +} + +#[test] +fn thickness_and_angle_are_degenerate() { + // They enter only as the product d/cos(theta). "77 degrees at 0.2 mm" and + // "0.87 mm at normal" are the same optical thickness, which is why a wrong + // mechanism kept producing right numbers: no check on the OUTPUT can tell + // them apart. + let db = db(); + let opt = db.get("baso4").unwrap().optical().unwrap(); + let by_angle = opt.km_split_at(420.0, 0.02, 76.7).unwrap(); + let d_eff = 0.02 * rs_materials::material::obliquity_factor(76.7); + let by_thickness = opt.km_split_at(420.0, d_eff, 0.0).unwrap(); + assert!((by_angle.0 - by_thickness.0).abs() < 1e-9); + assert!((by_angle.1 - by_thickness.1).abs() < 1e-9); +} + +#[test] +fn thick_layer_survives_extreme_thickness() { + // The hyperbolic form overflows an f64 for large optical thickness; the + // thick limit is branched before it can, and the branch is exact. + let db = db(); + let opt = db.get("baso4").unwrap().optical().unwrap(); + let r_inf = opt.km_reflectance_infinite_at(420.0).unwrap(); + for d in [1.0_f64, 5.0, 50.0, 1e4, 1e6] { + let (r, t, a) = opt.km_split_at(420.0, d, 0.0).unwrap(); + assert!((r + t + a - 100.0).abs() < 1e-9, "d={d}"); + assert!(t >= 0.0); + assert!(r <= r_inf + 1e-9); + } + assert!((opt.km_reflectance_at(420.0, 1e6, 0.0).unwrap() - r_inf).abs() < 1e-12); +} + +#[test] +fn child_sidecars_merge_with_disjoint_parent_entries() { + // Found by mutation audit: replacing the `_absent` overlay with a plain + // replacement left BOTH language suites green, and the cross-language + // parity gate green too. The parity gate compares what the CORPUS + // exercises, and no shipped material declares its own `_absent` under a + // parent that also has one — so the merge branch is dead data-side and the + // gate cannot see it. This is a synthetic fixture for that reason. + let dir = std::env::temp_dir().join("rs_materials_sidecar_merge"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("metals.toml"), + r#" + [x] + name = "X" + [x._absent] + "optical.reemit_qe" = { reason = "not-measured" } + [x._sources] + "optical.light_yield" = { citation = "a", kind = "doi", ref = "10.1/a", license = "CC0" } + [x.child] + name = "Child" + [x.child._absent] + "optical.emission_spectrum" = { reason = "proprietary" } + [x.child._sources] + "optical.decay_time" = { citation = "b", kind = "doi", ref = "10.1/b", license = "CC0" } + "#, + ) + .unwrap(); + let db = MaterialDb::open(&dir).unwrap(); + let child = db.get("x.child").unwrap(); + + // Own entries present... + assert!(child.is_absent("optical.emission_spectrum")); + assert_eq!(child.source_of("optical.decay_time").unwrap().citation, "b"); + // ...and inherited ones NOT dropped. + assert!( + child.is_absent("optical.reemit_qe"), + "inherited absence was dropped when the child declared its own" + ); + assert_eq!( + child.source_of("optical.light_yield").unwrap().citation, + "a", + "inherited source was dropped when the child declared its own" + ); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/scripts/check_data_shape.py b/scripts/check_data_shape.py new file mode 100644 index 0000000..34feae3 --- /dev/null +++ b/scripts/check_data_shape.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Structural gates on the data corpus — the two failure modes that stayed +invisible longest in #243. + +Both are cheap, both are checkable without knowing what any value MEANS, and +both had to be found by accident before being written down. They run as a +pre-commit hook because they fire at the moment of the edit, which is exactly +when the adjacent thing moves. + + python scripts/check_data_shape.py # both checks + python scripts/check_data_shape.py --drop # just the field check + python scripts/check_data_shape.py --place # just the placement check + +## Check 1: no silently-dropped keys + +The loader assigns with `if hasattr(prop_obj, key)`. A TOML key with no +matching dataclass field is therefore parsed, validated, and thrown away — no +error, no warning, nothing. `[esr.optical] reflectivity = 98.5` was on disk +from #147 and dropped on every single load until #243; a corpus audit then +found four more. + +A value can be cited, committed, reviewed, and still not be there. + +## Check 2: no key filed under the wrong section banner + +`scripts/enrich_from_refractiveindex.py` appends a key at the end of a +material's span, which lands it AFTER the following section banner. It parses +correctly — it still belongs to the preceding table — so nothing fails. But it +READS as part of the next section, and the next person to insert a table beside +it captures it into theirs. That nearly happened in #243. + +Fixed by hand in metals.toml, then again in scintillators.toml, before anyone +wrote it down as an invariant. Fixing the same instance twice without gating it +is its own smell. + +Stdlib-only by design, like `check_licenses.py` — it parses `properties.py` +with `ast` rather than importing `pymat`, so pre-commit can run it in a clean +isolated interpreter with nothing installed. +""" + +from __future__ import annotations + +import argparse +import ast +import sys +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover — Python 3.10 path + import tomli as tomllib + +REPO_ROOT = Path(__file__).resolve().parent.parent +DATA_DIR = REPO_ROOT / "src" / "pymat" / "data" +PROPERTIES = REPO_ROOT / "src" / "pymat" / "properties.py" + +# `surfaces.toml` is not a material catalogue — its nodes are `Surface` +# entries with their own field vocabulary and loader (ADR-0004 §2). +NON_MATERIAL_TOMLS = {"surfaces.toml"} + +# Keys inside a material node that are not property groups. +LEAF_KEYS = {"name", "formula", "composition", "grade", "temper", "treatment", "vendor", "tags"} +# Groups the loader knows but this check does not model field-by-field. +SKIP_GROUPS = {"vis", "custom"} + + +# --------------------------------------------------------------------------- +# Check 1 — every TOML key has a field to land in +# --------------------------------------------------------------------------- + + +def dataclass_fields() -> dict[str, set[str]]: + """`{group_name: {field, ...}}`, read from `properties.py` via `ast`. + + Derives the group→class mapping from `AllProperties`' own annotations, so + adding a property group needs no edit here. + """ + tree = ast.parse(PROPERTIES.read_text()) + classes: dict[str, set[str]] = {} + for node in tree.body: + if isinstance(node, ast.ClassDef): + classes[node.name] = { + stmt.target.id + for stmt in node.body + if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name) + } + groups: dict[str, set[str]] = {} + for stmt in tree.body: + if isinstance(stmt, ast.ClassDef) and stmt.name == "AllProperties": + for item in stmt.body: + if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name): + ann = item.annotation + if isinstance(ann, ast.Name) and ann.id in classes: + groups[item.target.id] = classes[ann.id] + return groups + + +def dropped_keys() -> list[str]: + """Keys present in a shipped material TOML with no receiving field.""" + groups = dataclass_fields() + problems: list[str] = [] + + def walk(node: object, path: str) -> None: + if not isinstance(node, dict): + return + for key, value in node.items(): + if key.startswith("_"): + continue + if key in groups and isinstance(value, dict): + fields = groups[key] + for prop in value: + if prop.startswith("_") or prop.endswith(("_stddev", "_unit")): + continue + base = prop[:-6] if prop.endswith("_value") else prop + if base not in fields: + problems.append(f"{path}.{key}.{base}") + elif isinstance(value, dict) and key not in SKIP_GROUPS and key not in LEAF_KEYS: + walk(value, f"{path}.{key}") + + for toml_path in sorted(DATA_DIR.glob("*.toml")): + if toml_path.name in NON_MATERIAL_TOMLS: + continue + with open(toml_path, "rb") as fh: + doc = tomllib.load(fh) + for key, value in doc.items(): + walk(value, f"{toml_path.name}:{key}") + return problems + + +# --------------------------------------------------------------------------- +# Check 2 — no key separated from its table by a section banner +# --------------------------------------------------------------------------- + + +def is_section_banner(comment: str) -> bool: + """True for a section divider (`# ======`), false for prose. + + This distinction is what makes the check usable. Most values in these files + carry an explanatory comment; flagging those would make the check noise, + and a noisy check gets deleted rather than obeyed. + """ + body = comment.lstrip("#").strip() + return len(body) >= 8 and set(body) <= set("=-— ") + + +def misplaced_keys(text: str) -> list[tuple[int, str, str]]: + """`(line_no, key, owning_table)` for keys a banner separates from their header.""" + table: str | None = None + saw_banner = False + depth = 0 + out: list[tuple[int, str, str]] = [] + for lineno, raw in enumerate(text.splitlines(), 1): + line = raw.strip() + if depth > 0: # inside a multi-line array + depth += line.count("[") - line.count("]") + continue + if line.startswith("["): + table, saw_banner = line, False + continue + if line.startswith("#"): + saw_banner = saw_banner or is_section_banner(line) + continue + if not line or "=" not in line: + continue + if saw_banner and table is not None: + out.append((lineno, line.split("=")[0].strip(), table)) + depth += line.count("[") - line.count("]") + return out + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--drop", action="store_true", help="only the dropped-key check") + parser.add_argument("--place", action="store_true", help="only the placement check") + args = parser.parse_args() + run_drop = args.drop or not args.place + run_place = args.place or not args.drop + + failures = 0 + + if run_drop: + dropped = dropped_keys() + if dropped: + failures += 1 + print("Silently-dropped keys — parsed, then discarded by the loader:") + for item in dropped: + print(f" {item}") + print(" Add the field to the relevant dataclass in src/pymat/properties.py.\n") + + if run_place: + offenders: list[str] = [] + for toml_path in sorted(DATA_DIR.glob("*.toml")): + for lineno, key, table in misplaced_keys(toml_path.read_text()): + offenders.append(f" {toml_path.name}:{lineno} {key!r} belongs to {table}") + if offenders: + failures += 1 + print("Keys filed under the wrong section banner:") + print("\n".join(offenders)) + print(" They parse correctly but read as part of the NEXT section, and an") + print(" insertion beside them would capture them. Move them up to their table.\n") + + if failures: + return 1 + print("Data shape OK: no dropped keys, no misfiled keys.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_licenses.py b/scripts/check_licenses.py index d42c2f7..3d40701 100644 --- a/scripts/check_licenses.py +++ b/scripts/check_licenses.py @@ -37,6 +37,7 @@ ALLOWED = { "CC0", "PD-USGov", + "CC-BY-3.0", "CC-BY-4.0", "CC-BY-SA-4.0", # Geant4 Software License — BSD-like, attribution required. Added in @@ -50,7 +51,7 @@ # `unknown` is parseable but rejected — transitional value, blocked at merge. # Licenses requiring attribution in LICENSES-DATA.md. -ATTRIBUTION_REQUIRED = {"CC-BY-4.0", "CC-BY-SA-4.0"} +ATTRIBUTION_REQUIRED = {"CC-BY-3.0", "CC-BY-4.0", "CC-BY-SA-4.0"} def load_ratchet() -> set[str]: diff --git a/scripts/mutation_audit.py b/scripts/mutation_audit.py new file mode 100644 index 0000000..3276dc9 --- /dev/null +++ b/scripts/mutation_audit.py @@ -0,0 +1,125 @@ +"""Mutation audit — break the thing a gate guards, run the suite, see if it notices. + +A test the wrong model passes is not a weak test, it is a NON-TEST. Reading a +test cannot tell you which you have; only running it against the failure it +claims to catch can. This script does that mechanically. + + python scripts/mutation_audit.py + +Each entry patches one line of source, runs the full suite, restores the file, +and reports whether anything failed. `** NON-TEST **` means the suite is blind +to that defect. + +Found on first use (#243): `_absent` sidecar inheritance. A child declaring its +own absence could silently drop every absence it inherited, and all 1217 tests +stayed green — the existing override test used the SAME key on parent and +child, where a merge and a replacement are indistinguishable. The cross-language +parity gate missed it too, because no shipped material exercises that branch. + +Add an entry whenever you add a gate you are relying on. The anchors are exact +source lines and will need updating as the code moves; a SKIP means the anchor +drifted, not that the gate is fine. +""" + +import pathlib +import subprocess + +ROOT = pathlib.Path(__file__).resolve().parent.parent +PY = ROOT / ".venv/bin/python" + +MUTATIONS = [ + # (label, file, old, new) + ( + "obliquity 1/cos -> cos", + "src/pymat/properties.py", + " return min(40.0, 1.0 / math.cos(theta))", + " return min(40.0, math.cos(theta))", + ), + ( + "R_inf sign flip", + "src/pymat/properties.py", + " return 100.0 * (1.0 + x - math.sqrt(x * x + 2.0 * x))", + " return 100.0 * (1.0 + x + math.sqrt(x * x + 2.0 * x))", + ), + ( + "thick branch 1/(a+b) -> 1/(a-b)", + "src/pymat/properties.py", + " r = 1.0 / (a + b)", + " r = 1.0 / (a - b) if a != b else 1.0", + ), + ( + "K-M drops absorption (k:=0)", + "src/pymat/properties.py", + ' curve = _as_wl_curve(self.kubelka_munk, "k")\n return None if curve is None else curve.interpolate(_to_nm(wavelength))', # noqa: E501 - anchor must match source verbatim + ' curve = _as_wl_curve(self.kubelka_munk, "k")\n return None if curve is None else 0.0', # noqa: E501 - anchor must match source verbatim + ), + ( + "Fresnel R drops k term", + "src/pymat/properties.py", + " r = ((n - 1.0) ** 2 + k**2) / ((n + 1.0) ** 2 + k**2)", + " r = ((n - 1.0) ** 2) / ((n + 1.0) ** 2)", + ), + ( + "WavelengthCurve extrapolates instead of clamping", + "src/pymat/curves.py", + " if x <= xs[0]:\n if x < xs[0]:", + " if False:\n if x < xs[0]:", + ), + ( + "curve sort validation removed", + "src/pymat/curves.py", + " for a, b in zip(xs, xs[1:]):\n if not a < b:", + " for a, b in zip(xs, xs[1:]):\n if False:", + ), + ( + "wavelength Quantity magnitude taken raw", + "src/pymat/properties.py", + " return float(wavelength.to(ureg.nanometer).magnitude)", + " return float(wavelength.magnitude)", + ), + ( + "_absent stops inheriting", + "src/pymat/loader.py", + " absent = {**parent_absent, **parse_absent_table(raw_absent)}", + " absent = dict(parse_absent_table(raw_absent))", + ), + ( + "surface optical_contact index check removed", + "src/pymat/surfaces.py", + ' if self.coupling == "optical_contact" and self.coupling_index is None:', + " if False:", + ), +] + +results = [] +for label, relpath, old, new in MUTATIONS: + f = ROOT / relpath + original = f.read_text() + if old not in original: + results.append((label, "SKIP - anchor not found")) + continue + f.write_text(original.replace(old, new, 1)) + try: + proc = subprocess.run( + [str(PY), "-m", "pytest", "tests/", "-x", "-q", "--no-header", "-p", "no:randomly"], + cwd=ROOT, + capture_output=True, + text=True, + timeout=900, + ) + detected = proc.returncode != 0 + tail = [line for line in proc.stdout.splitlines() if "passed" in line or "failed" in line] + results.append( + ( + label, + ("PASS (detected)" if detected else "** NON-TEST **") + + (" " + tail[-1].strip() if tail else ""), + ) + ) + finally: + f.write_text(original) + +print(f"\n{'MUTATION':<46} RESULT") +print("-" * 100) +for label, r in results: + print(f"{label:<46} {r}") diff --git a/src/pymat/__init__.py b/src/pymat/__init__.py index 580a10b..53ec8d5 100644 --- a/src/pymat/__init__.py +++ b/src/pymat/__init__.py @@ -61,7 +61,8 @@ ThermalProperties, ) from .search import search -from .sources import Source +from .sources import Absent, Source +from .surfaces import Surface, surfaces from .units import ureg from .vis import FinishEntry, Vis, VisDeltas @@ -82,6 +83,9 @@ "ComplianceProperties", "SourcingProperties", "Source", + "Absent", + "Surface", + "surfaces", "ureg", "load_toml", "load_category", @@ -181,6 +185,7 @@ "ltcc951", "sapphire", "si3n4", + "baso4", "concrete_ordinary", ], "electronics": ["fr4", "rogers", "kapton", "copper_pcb", "solder"], diff --git a/src/pymat/core.py b/src/pymat/core.py index 925bf37..7250b74 100644 --- a/src/pymat/core.py +++ b/src/pymat/core.py @@ -17,7 +17,7 @@ pass from .properties import AllProperties -from .sources import Source, resolve_path +from .sources import Absent, Source, resolve_path # Type variable for generic object application T = TypeVar("T") @@ -138,6 +138,10 @@ class _MaterialInternal: # Provenance (#150) — keyed by dotted property path; `_default` fallback. _sources: Dict[str, Source] = field(default_factory=dict, repr=False) + # Declared absences (#243) — keyed by dotted property path. Same + # parent-overlay inheritance as `_sources`. See `Material.absent()`. + _absent: Dict[str, Absent] = field(default_factory=dict, repr=False) + # Multi-axial filterable tags (#132). Orthogonal to the TOML # hierarchy — chemistry / function / industry / treatment / # regulation labels that consumers filter on. Children INHERIT @@ -741,6 +745,27 @@ def cite(self, path: Optional[str] = None) -> str: seen.setdefault(src.citation, src) return "\n\n".join(s.to_bibtex() for s in seen.values()) + def absent(self, path: str) -> Optional[Absent]: + """Return the declared absence for a property path, or None (#243). + + A `None` property value with no `Absent` entry means "we have not + said anything about this". A `None` with an `Absent` entry means + "we looked; here is why there is no number". Downstream engines + should treat the two differently — the first is a gap in the + database, the second is a fact about the literature. + + Accepts short aliases (`"decay_time"`) or fully-qualified paths. + Unlike `source_of`, there is no `_default` fallback: an absence is + always specific to one property. + """ + if not self._absent: + return None + return self._absent.get(resolve_path(path)) + + def is_absent(self, path: str) -> bool: + """True when `path` carries an explicit absence declaration (#243).""" + return self.absent(path) is not None + def __repr__(self) -> str: """String representation showing path and density.""" density_str = f"ρ={self.density} g/cm³" if self.density else "ρ=?" @@ -828,6 +853,7 @@ def __init__( parent: Optional["Material"] = None, _key: Optional[str] = None, _sources: Optional[Dict[str, Source]] = None, + _absent: Optional[Dict[str, Absent]] = None, tags: Optional[List[str]] = None, ): # Call parent init without density @@ -851,6 +877,7 @@ def __init__( parent=parent, _key=_key, _sources=_sources or {}, + _absent=_absent or {}, tags=list(tags) if tags is not None else [], ) diff --git a/src/pymat/curves.py b/src/pymat/curves.py index d4af716..b909f02 100644 --- a/src/pymat/curves.py +++ b/src/pymat/curves.py @@ -1,28 +1,84 @@ -"""Temperature-dependent property curves (#148). +"""Piecewise-linear property curves. -A `TempCurve` holds piecewise-linear `(temps_K, values)` knots for a -single property. Out-of-range temps are CLAMPED, not extrapolated — -engineering data extrapolated below its measured range is a lie, and -clamping is conservative and visibly wrong rather than subtly wrong -(per ADR-0003 §2 edge-case table). +Two flavours, one interpolation rule: -Validation is at construction (and therefore at TOML load) — unsorted -or mismatched-length arrays raise `ValueError` immediately, not at -query time. Empty curves raise as well. +- `TempCurve` — temperature-dependent properties (#148). Knots are + `(temps_K, values)`. +- `WavelengthCurve` — wavelength-dependent optical properties (#243). + Knots are `(wavelengths_nm, values)`. -Used by sibling fields like `_curve: Optional[TempCurve]` on -the property dataclasses. +Out-of-range abscissae are CLAMPED, not extrapolated — engineering data +extrapolated beyond its measured range is a lie, and clamping is +conservative and visibly wrong rather than subtly wrong (per ADR-0003 §2 +edge-case table). ADR-0004 §4 extends the same rule to wavelength: a +dispersion table measured over 400-700 nm says nothing about 250 nm, and +a Sellmeier fit evaluated outside its stated validity range is worse than +useless because it looks like data. + +Validation is at construction (and therefore at TOML load) — unsorted or +mismatched-length arrays raise `ValueError` immediately, not at query +time. Empty curves raise as well. + +Used by sibling fields like `_curve: Optional[TempCurve]` and by +the wavelength accessors (`n_at`, `absorption_length_at`, `emission_at`) +on `OpticalProperties`. """ from __future__ import annotations import logging from dataclasses import dataclass -from typing import Any, List +from typing import Any, List, Optional, Sequence, Tuple logger = logging.getLogger(__name__) +def _validate_knots(xs: Sequence[float], ys: Sequence[float], cls: str, x_name: str) -> None: + """Shared knot validation for every piecewise-linear curve. + + Raises `ValueError` on empty, length-mismatched, or non-strictly-ascending + abscissae. Equal-adjacent knots make interpolation ambiguous; reject them. + """ + if not xs: + raise ValueError(f"{cls} requires at least one knot") + if len(xs) != len(ys): + raise ValueError(f"{cls} {x_name} and values must be same length: {len(xs)} vs {len(ys)}") + for a, b in zip(xs, xs[1:]): + if not a < b: + raise ValueError(f"{cls} {x_name} must be strictly sorted ascending; got {list(xs)}") + + +def _interp_clamped( + xs: Sequence[float], ys: Sequence[float], x: float, cls: str, x_name: str, unit: str +) -> float: + """Evaluate a piecewise-linear curve at `x`, clamping outside the knot range.""" + if x <= xs[0]: + if x < xs[0]: + logger.debug( + "%s: %s=%s %s below min knot %s %s; clamping", cls, x_name, x, unit, xs[0], unit + ) + return ys[0] + if x >= xs[-1]: + if x > xs[-1]: + logger.debug( + "%s: %s=%s %s above max knot %s %s; clamping", cls, x_name, x, unit, xs[-1], unit + ) + return ys[-1] + for i in range(len(xs) - 1): + x0, x1 = xs[i], xs[i + 1] + if x0 <= x <= x1: + y0, y1 = ys[i], ys[i + 1] + return y0 + (x - x0) / (x1 - x0) * (y1 - y0) + # Unreachable — the clamp branches above cover every case. + raise RuntimeError(f"{cls}: failed to bracket {x_name}={x}") # pragma: no cover + + +def _require_table(raw: Any, cls: str) -> dict: + if not isinstance(raw, dict): + raise ValueError(f"{cls} TOML must be a table, got {type(raw).__name__}") + return raw + + @dataclass(frozen=True) class TempCurve: """Piecewise-linear temperature-dependent property curve. @@ -36,54 +92,95 @@ class TempCurve: values: List[float] def __post_init__(self) -> None: - if not self.temps_K: - raise ValueError("TempCurve requires at least one knot") - if len(self.temps_K) != len(self.values): - raise ValueError( - f"TempCurve temps_K and values must be same length: " - f"{len(self.temps_K)} vs {len(self.values)}" - ) - # Strict-sorted check (ascending). Equal-adjacent knots would make - # interpolation ambiguous; reject. - for a, b in zip(self.temps_K, self.temps_K[1:]): - if not a < b: - raise ValueError( - f"TempCurve temps_K must be strictly sorted ascending; got {self.temps_K}" - ) + _validate_knots(self.temps_K, self.values, "TempCurve", "temps_K") def interpolate(self, temp_K: float) -> float: """Evaluate the curve at `temp_K`. Out-of-range clamps to nearest knot.""" - if temp_K <= self.temps_K[0]: - if temp_K < self.temps_K[0]: - logger.debug( - "TempCurve: T=%s K below min knot %s K; clamping", - temp_K, - self.temps_K[0], - ) - return self.values[0] - if temp_K >= self.temps_K[-1]: - if temp_K > self.temps_K[-1]: - logger.debug( - "TempCurve: T=%s K above max knot %s K; clamping", - temp_K, - self.temps_K[-1], - ) - return self.values[-1] - # Linear interp between bracketing knots - for i in range(len(self.temps_K) - 1): - t0, t1 = self.temps_K[i], self.temps_K[i + 1] - if t0 <= temp_K <= t1: - v0, v1 = self.values[i], self.values[i + 1] - frac = (temp_K - t0) / (t1 - t0) - return v0 + frac * (v1 - v0) - # Unreachable — clamp branches above cover all cases - raise RuntimeError(f"TempCurve: failed to bracket T={temp_K}") # pragma: no cover + return _interp_clamped(self.temps_K, self.values, temp_K, "TempCurve", "T", "K") @classmethod def from_toml(cls, raw: Any) -> "TempCurve": """Build from `{temps_K = [...], values = [...]}` TOML inline-table.""" - if not isinstance(raw, dict): - raise ValueError(f"TempCurve TOML must be a table, got {type(raw).__name__}") + raw = _require_table(raw, "TempCurve") if "temps_K" not in raw or "values" not in raw: raise ValueError(f"TempCurve TOML missing 'temps_K' or 'values': {raw}") return cls(temps_K=list(raw["temps_K"]), values=list(raw["values"])) + + +# Value-column spellings accepted by `WavelengthCurve.from_toml` when no +# explicit `value_key` is given, in priority order. The non-canonical ones +# exist because the structured optical slots shipped in #153 / #164 before +# this primitive did, and their on-disk shape is load-bearing: +# refractive_index_dispersion = {wavelengths_nm = [...], n = [...]} +# emission_spectrum = {wavelengths_nm = [...], intensities = [...]} +_WL_VALUE_KEYS: Tuple[str, ...] = ("values", "n", "intensities") + + +@dataclass(frozen=True) +class WavelengthCurve: + """Piecewise-linear wavelength-dependent optical property curve (#243). + + The wavelength twin of `TempCurve`. Same clamping contract, same + load-time validation. Wavelengths are always nanometres — the schema + spells the abscissa `wavelengths_nm` everywhere, so there is no unit + ambiguity to resolve at load. + + Attributes: + wavelengths_nm: Strictly-sorted ascending wavelengths in nm. + values: Property values at each knot, same length as `wavelengths_nm`. + """ + + wavelengths_nm: List[float] + values: List[float] + + def __post_init__(self) -> None: + _validate_knots(self.wavelengths_nm, self.values, "WavelengthCurve", "wavelengths_nm") + + def interpolate(self, wavelength_nm: float) -> float: + """Evaluate at `wavelength_nm`. Out-of-range clamps to nearest knot.""" + return _interp_clamped( + self.wavelengths_nm, self.values, wavelength_nm, "WavelengthCurve", "lambda", "nm" + ) + + @property + def range_nm(self) -> Tuple[float, float]: + """`(min, max)` of the measured range — the span outside which + `interpolate` clamps. Downstream resamplers (e.g. strata's parquet + emitter) use this to record where the data actually stops.""" + return (self.wavelengths_nm[0], self.wavelengths_nm[-1]) + + @classmethod + def from_toml(cls, raw: Any, value_key: Optional[str] = None) -> "WavelengthCurve": + """Build from a `{wavelengths_nm = [...], = [...]}` table. + + Args: + raw: The TOML inline-table. + value_key: Explicit name of the ordinate column. When omitted, + the first of `values` / `n` / `intensities` present in the + table wins. Two or more present at once is a hard error — + silently picking one would make the file's meaning depend + on this function's internals. + """ + raw = _require_table(raw, "WavelengthCurve") + if "wavelengths_nm" not in raw: + raise ValueError(f"WavelengthCurve TOML missing 'wavelengths_nm': {raw}") + + if value_key is not None: + if value_key not in raw: + raise ValueError(f"WavelengthCurve TOML missing {value_key!r}: {raw}") + key = value_key + else: + present = [k for k in _WL_VALUE_KEYS if k in raw] + if not present: + accepted = ", ".join(repr(k) for k in _WL_VALUE_KEYS) + raise ValueError( + f"WavelengthCurve TOML has no value column (accepted: {accepted}): {raw}" + ) + if len(present) > 1: + raise ValueError( + f"WavelengthCurve TOML is ambiguous — multiple value columns " + f"{present}; pass value_key explicitly: {raw}" + ) + key = present[0] + + return cls(wavelengths_nm=list(raw["wavelengths_nm"]), values=list(raw[key])) diff --git a/src/pymat/data/ceramics.toml b/src/pymat/data/ceramics.toml index 2cd0305..d8ae499 100644 --- a/src/pymat/data/ceramics.toml +++ b/src/pymat/data/ceramics.toml @@ -1239,3 +1239,134 @@ kind = "handbook" ref = "pdg.lbl.gov:atomic-nuclear/shielding-concrete" license = "CC-BY-4.0" note = "Z_eff ≈ 11 reported as the working effective-Z value for ordinary/shielding concrete in the PDG-derived simulation community (Geant4, MCNP). Computed via Mayneord-style weighting of the NIST composition; documented but not directly tabulated by NIST/PDG, so cited as a PDG-community-derived scalar." + + +# ============================================================================ +# Barium sulfate (BaSO4) — the classic diffuse reflectance standard (#243) +# ============================================================================ +# Pressed BaSO4 powder is the reference white Lambertian diffuse reflector; it +# was the working reflectance standard before sintered PTFE (Spectralon) +# replaced it for NIST calibrations. In detector construction it is the usual +# inter-crystal septum material in a PET block. +# +# --------------------------------------------------------------------------- +# READ THIS BEFORE USING THE REFLECTANCE. It is not the number you will see. +# --------------------------------------------------------------------------- +# `reflectivity` / `reflectivity_spectrum` below are SEMI-INFINITE reflectance: +# what a thick pile of this powder returns. That is a genuine material property +# and it is why the field exists — but NO REAL LAYER IS SEMI-INFINITE, and the +# reflectance a finite layer delivers is substantially lower because the +# balance goes straight through. +# +# DO NOT adopt reflectivity_at(nm) unless your layer is optically thick. +# DO use the Kubelka-Munk coefficients with YOUR thickness: +# +# R, T, A = opt.km_split_at(420, thickness_cm) # percent, sums to 100 +# R = opt.km_reflectance_at(420, 0.02) # 0.2 mm -> 91.9% +# +# At 420 nm this material gives, per thickness (R / T / A, %): +# +# 0.1 mm 85.4 / 14.4 / 0.2 +# 0.2 mm 91.9 / 7.6 / 0.5 <- a typical PET inter-crystal septum +# 0.3 mm 94.2 / 5.1 / 0.7 +# 0.5 mm 96.0 / 2.9 / 1.1 <- vendor-recommended coating thickness +# 0.6 mm 96.4 / 2.3 / 1.3 +# infinite 97.2 / 0 / 2.8 (the R_inf below) +# +# That T column is not a rounding error. In a segmented detector it IS the +# inter-crystal crosstalk channel, and it is also why vendors specify 0.5-0.6 mm +# — that is where transmission falls under ~3% and the coating starts behaving +# like the reflectance standard it is made of. +# +# ON ANGLE OF INCIDENCE — and on a trap this material creates for itself. +# +# km_split_at() takes an optional `incidence_deg` for COLLIMATED light, since a +# ray at theta crosses d/cos(theta) of material. For a BaSO4 septum, though, +# the honest default is usually 0, for two reasons that pull the same way: +# +# 1. THIS REFLECTOR ERASES THE ANGLE IT IS GIVEN. BaSO4 is Lambertian. After +# one contact, direction is cosine-distributed about the septum normal +# with mean |cos| = 2/3 exactly, i.e. 48.2 deg, NO MATTER how the light +# arrived. So "the crystal is long and thin, therefore light hits the walls +# at grazing incidence" is false the moment the wall is this material. The +# reflector sets the angle, not the geometry. (That reasoning IS valid for +# a specular wall — see the aluminium entry.) +# +# 2. KUBELKA-MUNK IS ALREADY A DIFFUSE MODEL. k and s as published are +# defined for diffuse flux, with the obliquity averaged in — that is where +# the factor 2 in the usual K = 2k convention comes from. Multiplying by +# 1/cos on top of diffuse coefficients double-counts. +# +# Net: for diffusely-illuminated layers use the table above as written. Use +# `incidence_deg` when you genuinely have a collimated beam at a known angle. +# +# A worked consequence, recorded because two independent attempts got it wrong: +# a 0.2 mm septum in a wrapped scintillator array is NOT optically thick. At +# the Lambertian mean angle it transmits ~5%, and at normal incidence ~7.6%. +# Reaching the semi-infinite limit would need ~77 deg, which this reflector's +# own randomisation prevents. +# +# Two further caveats on the semi-infinite values themselves: they are PRESSED +# POWDER at high packing density in an integrating sphere, and Grum & Luckey's +# own BaSO4/PVA *paint* measures 0.992 rather than 0.995-0.999. Packing quality +# and finite thickness are separate effects and both push the same way; the +# thickness term usually dominates for a detector septum. +# +# There is no CC-licensed tabulated (lambda, R) source for BaSO4. The values +# are cited from the Optica-paywalled primary; facts are not copyrightable, so +# the numbers travel, but the table is not ours to redistribute. + +[baso4] +name = "Barium Sulfate (BaSO4)" +formula = "BaSO4" +tags = ["reflector", "diffuse-reflector", "reflectance-standard", "ceramic"] + +[baso4.mechanical] +density_value = 4.49 +density_unit = "g/cm^3" +hardness_vickers = 125 + +[baso4.thermal] +melting_point_value = 1580 +melting_point_unit = "degC" + +[baso4.optical] +# Total hemispherical diffuse reflectance, PERCENT, pressed powder. +# Peaks 0.999 at 420-470 nm — squarely on the LYSO emission band. +reflectivity = 99.9 +reflectivity_spectrum = { wavelengths_nm = [350, 360, 370, 380, 390, 400, 410, 420, 430, 440, 450, 460, 470, 480, 490, 500, 550, 600, 650, 700], values = [98.50, 98.79, 99.07, 99.27, 99.39, 99.50, 99.72, 99.90, 99.92, 99.90, 99.90, 99.90, 99.90, 99.89, 99.85, 99.80, 99.80, 99.80, 99.80, 99.80] } +# Kubelka-Munk two-flux coefficients, k and s in 1/cm. Bundled because they +# are one model's parameters and are meaningless apart. Use the km_* accessors: +# km_reflectance_infinite_at(nm) -> thick-layer R +# km_transmittance_at(nm, cm) -> FINITE-layer T +# +# READ THIS BEFORE ASSUMING A THIN SEPTUM IS OPAQUE. Reflectance converges to +# its thick-layer limit quickly; transmittance does NOT go to zero anywhere +# near as fast, and the two are different questions. At 420 nm a 0.2 mm layer +# at this (pressed-pellet) density still transmits ~7.6%. A "12 scattering +# lengths, therefore optically thick" argument is about reflectance and does +# not license ignoring transmission — in a segmented detector that transmission +# IS the inter-crystal crosstalk channel. Vendors specify 0.5-0.6 mm for +# coatings, and even 0.6 mm transmits ~2%. +kubelka_munk = { wavelengths_nm = [300, 500, 700], k = [0.455, 0.100, 0.062], s = [619.0, 572.0, 517.0] } +scattering_length = 0.00175 +scattering_length_unit = "cm" +absorption_coefficient = 0.100 + +[baso4.vis] +base_color = [1.0, 1.0, 1.0, 1.0] +metallic = 0.0 +roughness = 0.95 +transmission = 0.0 + +[baso4._sources] +"mechanical.density" = { citation = "crc_handbook_baso4", kind = "handbook", ref = "CRC Handbook of Chemistry and Physics, barium sulfate (barite)", license = "proprietary-reference-only", note = "4.49 g/cm^3 for the barite mineral form. A pressed powder septum is far less dense — this is the crystal density, not the packed-layer density." } +"optical.reflectivity" = { citation = "grum_luckey_1968", kind = "doi", ref = "10.1364/AO.7.002289", license = "proprietary-reference-only", note = "F. Grum & G. W. Luckey, 'Optical Sphere Paint and a Working Standard of Reflectance', Appl. Opt. 7(11) 2289-2294 (1968). THE primary reference for pressed BaSO4 as a reflectance standard. Absolute luminous reflectance 0.995 +/- 0.001 for pressed powder; the paper's own BaSO4/PVA paint formulation measures 0.992. Values cited, table not redistributed." } +"optical.reflectivity_spectrum" = { citation = "grum_luckey_1968", kind = "doi", ref = "10.1364/AO.7.002289", license = "proprietary-reference-only", note = "Table I, pressed BaSO4 (Eastman white reflectance standard #6091). Transcribed via the Purdue LARS FRData reflectance calibration tables, which reproduce the paper's grid; the 10 nm rows are on Grum & Luckey's own measurement grid. PRESSED POWDER, integrating sphere, total hemispherical — see the header comment before using this for a septum." } +"optical.scattering_length" = { citation = "patterson_1977", kind = "doi", ref = "10.1364/AO.16.000729", license = "proprietary-reference-only", note = "E. M. Patterson, C. E. Shelden, B. H. Stockton, 'Kubelka-Munk optical properties of a barium sulfate white reflectance standard', Appl. Opt. 16(3) 729-732 (1977). Table I gives s = 619 /cm at 300 nm, 572 /cm at 500 nm, 517 /cm at 700 nm. 1/s at 500 nm = 17.5 um, recorded here as 0.00175 cm. NOTE this is the Kubelka-Munk two-flux scattering coefficient for a compressed pellet, not a single-scatter transport mean free path, and not a photon-transport length in a looser coating." } +"optical.absorption_coefficient" = { citation = "patterson_1977", kind = "doi", ref = "10.1364/AO.16.000729", license = "proprietary-reference-only", note = "Kubelka-Munk k = 0.100 /cm at 500 nm (0.455 at 300 nm, 0.062 at 700 nm). Same two-flux caveat as the scattering coefficient." } + +"optical.kubelka_munk" = { citation = "patterson_1977", kind = "doi", ref = "10.1364/AO.16.000729", license = "proprietary-reference-only", note = "Table I, k and s in 1/cm at 300/500/700 nm. Verified self-consistent: the K-M thick-layer relation R_inf = 1 + k/s - sqrt((k/s)^2 + 2k/s) reproduces Patterson's own published R_inf (0.9624 / 0.9815 / 0.9846) to five decimal places from these coefficients. TWO-SOURCE DISAGREEMENT, READ THIS: these coefficients imply R_inf = 97.18% at 420 nm, while the Grum & Luckey reflectivity_spectrum on this same material gives 99.90% at 420 nm - a 2.7 point gap, and 1.3-2.3 points across 300-700 nm. Both are cited primaries measuring pressed BaSO4; they are different samples at different packing densities from different eras, and neither is wrong. The gap IS the packing-density sensitivity of this material, and it is the physical reason a reflectance value for BaSO4 has to be a bracket rather than a point. Grum & Luckey is the higher-packing, best-case artifact; Patterson sits near the bottom of the 0.98-0.999 bracket. Pick per what you physically have, and say which." } + +[baso4._absent] +"optical.refractive_index" = { reason = "not-applicable", note = "A pressed powder has no single bulk refractive index that is useful for transport — its reflectance comes from multiple scattering between grains and voids, not from a Fresnel step at a smooth surface. The barite crystal is biaxial (n ~ 1.63-1.65), but that is the grain index, not the layer's optical behaviour. Use reflectivity_spectrum, not a Fresnel calculation." } diff --git a/src/pymat/data/electronics.toml b/src/pymat/data/electronics.toml index a927d0e..e03f847 100644 --- a/src/pymat/data/electronics.toml +++ b/src/pymat/data/electronics.toml @@ -291,9 +291,14 @@ density_value = 5.0 density_unit = "g/cm^3" [ferrite.electrical] -permeability = 100 dielectric_constant = 10 +# Relative permeability is a magnetic property; it lived under [electrical] as +# `permeability`, where no field existed to receive it and the loader dropped +# it on every load. Moved to its real home (#243). +[ferrite.magnetic] +permeability_relative = 100 + [ferrite.vis] base_color = [0.1, 0.1, 0.1, 1.0] metallic = 0.0 diff --git a/src/pymat/data/metals.toml b/src/pymat/data/metals.toml index cac9218..666c46b 100644 --- a/src/pymat/data/metals.toml +++ b/src/pymat/data/metals.toml @@ -155,6 +155,9 @@ default = "smooth" smooth = { source = "ambientcg", id = "Metal049A" } machined = { source = "ambientcg", id = "Metal055A" } +[aluminum.optical] +refractive_index_dispersion = {wavelengths_nm = [61.992, 62.509, 63.03, 63.556, 64.085, 64.62, 65.158, 65.702, 66.249, 66.802, 67.359, 67.92, 68.486, 69.057, 69.633, 70.214, 70.799, 71.389, 71.984, 72.585, 73.19, 73.8, 74.415, 75.035, 75.661, 76.292, 76.928, 77.569, 78.216, 78.868, 79.525, 80.188, 80.857, 81.531, 82.211, 82.896, 83.587, 84.284, 84.987, 85.695, 86.41, 87.13, 87.857, 88.589, 89.328, 90.072, 90.823, 91.58, 92.344, 93.114, 93.89, 94.673, 95.462, 96.258, 97.06, 97.87, 98.686, 99.508, 100.34, 101.17, 102.02, 102.87, 103.73, 104.59, 105.46, 106.34, 107.23, 108.12, 109.02, 109.93, 110.85, 111.77, 112.71, 113.64, 114.59, 115.55, 116.51, 117.48, 118.46, 119.45, 120.45, 121.45, 122.46, 123.48, 124.51, 125.55, 126.6, 127.65, 128.72, 129.79, 130.87, 131.96, 133.06, 134.17, 135.29, 136.42, 137.56, 138.7, 139.86, 141.03, 142.2, 143.39, 144.58, 145.79, 147.0, 148.23, 149.46, 150.71, 151.97, 153.23, 154.51, 155.8, 157.1, 158.41, 159.73, 161.06, 162.4, 163.76, 165.12, 166.5, 167.89, 169.29, 170.7, 172.12, 173.56, 175.0, 176.46, 177.93, 179.42, 180.91, 182.42, 183.94, 185.47, 187.02, 188.58, 190.15, 191.74, 193.34, 194.95, 196.57, 198.21, 199.86, 201.53, 203.21, 204.9, 206.61, 208.34, 210.07, 211.82, 213.59, 215.37, 217.17, 218.98, 220.8, 222.64, 224.5, 226.37, 228.26, 230.16, 232.08, 234.01, 235.97, 237.93, 239.92, 241.92, 243.93, 245.97, 248.02, 250.09, 252.17, 254.27, 256.39, 258.53, 260.69, 262.86, 265.05, 267.26, 269.49, 271.73, 274.0, 276.28, 278.59, 280.91, 283.25, 285.61, 287.99, 290.4, 292.82, 295.26, 297.72, 300.2, 302.7, 305.23, 307.77, 310.34, 312.93, 315.53, 318.17, 320.82, 323.49, 326.19, 328.91, 331.65, 334.42, 337.2, 340.01, 342.85, 345.71, 348.59, 351.5, 354.43, 357.38, 360.36, 363.36, 366.39, 369.45, 372.53, 375.63, 378.77, 381.92, 385.11, 388.32, 391.56, 394.82, 398.11, 401.43, 404.78, 408.15, 411.56, 414.99, 418.45, 421.93, 425.45, 429.0, 432.58, 436.18, 439.82, 443.49, 447.18, 450.91, 454.67, 458.46, 462.28, 466.14, 470.02, 473.94, 477.89, 481.88, 485.89, 489.94, 494.03, 498.15, 502.3, 506.49, 510.71, 514.97, 519.26, 523.59, 527.96, 532.36, 536.8, 541.27, 545.78, 550.33, 554.92, 559.55, 564.21, 568.92, 573.66, 578.44, 583.27, 588.13, 593.03, 597.98, 602.96, 607.99, 613.06, 618.17, 623.32, 628.52, 633.76, 639.04, 644.37, 649.74, 655.16, 660.62, 666.13, 671.68, 677.28, 682.93, 688.62, 694.36, 700.15, 705.99, 711.87, 717.81, 723.79, 729.83, 735.91, 742.05, 748.23, 754.47, 760.76, 767.1, 773.5, 779.95, 786.45, 793.0, 799.62, 806.28, 813.0, 819.78, 826.62, 833.51, 840.46, 847.46, 854.53, 861.65, 868.84, 876.08, 883.38, 890.75, 898.17, 905.66, 913.21, 920.83, 928.5, 936.24, 944.05, 951.92, 959.86, 967.86, 975.93, 984.06, 992.27, 1000.5, 1008.9, 1017.3, 1025.8, 1034.3, 1042.9, 1051.6, 1060.4, 1069.3, 1078.2, 1087.2, 1096.2, 1105.4, 1114.6, 1123.9, 1133.2, 1142.7, 1152.2, 1161.8, 1171.5, 1181.3, 1191.1, 1201.0, 1211.1, 1221.2, 1231.3, 1241.6, 1252.0, 1262.4, 1272.9, 1283.5, 1294.2, 1305.0, 1315.9, 1326.9, 1337.9, 1349.1, 1360.3, 1371.7, 1383.1, 1394.6, 1406.3, 1418.0, 1429.8, 1441.7, 1453.7, 1465.9, 1478.1, 1490.4, 1502.8, 1515.4, 1528.0, 1540.7, 1553.6, 1566.5, 1579.6, 1592.8, 1606.0, 1619.4, 1632.9, 1646.5, 1660.3, 1674.1, 1688.1, 1702.1, 1716.3, 1730.6, 1745.1, 1759.6, 1774.3, 1789.1, 1804.0, 1819.0, 1834.2, 1849.5, 1864.9, 1880.5, 1896.1, 1911.9, 1927.9, 1944.0, 1960.2, 1976.5, 1993.0, 2009.6, 2026.4, 2043.3, 2060.3, 2077.5, 2094.8, 2112.2, 2129.9, 2147.6, 2165.5, 2183.6, 2201.8, 2220.1, 2238.6, 2257.3, 2276.1, 2295.1, 2314.2, 2333.5, 2353.0, 2372.6, 2392.4, 2412.3, 2432.4, 2452.7, 2473.2, 2493.8, 2514.6, 2535.5, 2556.7, 2578.0, 2599.5, 2621.1, 2643.0, 2665.0, 2687.3, 2709.7, 2732.2, 2755.0, 2778.0, 2801.2, 2824.5, 2848.1, 2871.8, 2895.7, 2919.9, 2944.2, 2968.8, 2993.5, 3018.5, 3043.6, 3069.0, 3094.6, 3120.4, 3146.4, 3172.6, 3199.1, 3225.8, 3252.7, 3279.8, 3307.1, 3334.7, 3362.5, 3390.5, 3418.8, 3447.3, 3476.0, 3505.0, 3534.2, 3563.7, 3593.4, 3623.4, 3653.6, 3684.0, 3714.7, 3745.7, 3776.9, 3808.4, 3840.2, 3872.2, 3904.5, 3937.0, 3969.9, 4003.0, 4036.3, 4070.0, 4103.9, 4138.1, 4172.6, 4207.4, 4242.5, 4277.9, 4313.5, 4349.5, 4385.7, 4422.3, 4459.2, 4496.3, 4533.8, 4571.6, 4609.7, 4648.2, 4686.9, 4726.0, 4765.4, 4805.1, 4845.2, 4885.6, 4926.3, 4967.4, 5008.8, 5050.6, 5092.7, 5135.1, 5177.9, 5221.1, 5264.6, 5308.5, 5352.8, 5397.4, 5442.4, 5487.8, 5533.5, 5579.7, 5626.2, 5673.1, 5720.4, 5768.1, 5816.1, 5864.6, 5913.5, 5962.8, 6012.5, 6062.7, 6113.2, 6164.2, 6215.6, 6267.4, 6319.6, 6372.3, 6425.4, 6479.0, 6533.0, 6587.5, 6642.4, 6697.8, 6753.6, 6809.9, 6866.7, 6924.0, 6981.7, 7039.9, 7098.6, 7157.8, 7217.4, 7277.6, 7338.3, 7399.5, 7461.1, 7523.3, 7586.1, 7649.3, 7713.1, 7777.4, 7842.2, 7907.6, 7973.5, 8040.0, 8107.0, 8174.6, 8242.8, 8311.5, 8380.8, 8450.7, 8521.1, 8592.1, 8663.8, 8736.0, 8808.8, 8882.3, 8956.3, 9031.0, 9106.3, 9182.2, 9258.8, 9335.9, 9413.8, 9492.3, 9571.4, 9651.2, 9731.7, 9812.8, 9894.6, 9977.1, 10060.0, 10144.0, 10229.0, 10314.0, 10400.0, 10487.0, 10574.0, 10662.0, 10751.0, 10841.0, 10931.0, 11022.0, 11114.0, 11207.0, 11300.0, 11394.0, 11489.0, 11585.0, 11682.0, 11779.0, 11877.0, 11976.0, 12076.0, 12177.0, 12279.0, 12381.0, 12484.0, 12588.0, 12693.0, 12799.0, 12906.0, 13013.0, 13122.0, 13231.0, 13341.0, 13453.0, 13565.0, 13678.0, 13792.0, 13907.0, 14023.0, 14140.0, 14258.0, 14376.0, 14496.0, 14617.0, 14739.0, 14862.0, 14986.0, 15111.0, 15237.0, 15364.0, 15492.0, 15621.0, 15751.0, 15883.0, 16015.0, 16149.0, 16283.0, 16419.0, 16556.0, 16694.0, 16833.0, 16973.0, 17115.0, 17257.0, 17401.0, 17546.0, 17693.0, 17840.0, 17989.0, 18139.0, 18290.0, 18443.0, 18596.0, 18751.0, 18908.0, 19065.0, 19224.0, 19385.0, 19546.0, 19709.0, 19873.0, 20039.0, 20206.0, 20375.0, 20545.0, 20716.0, 20889.0, 21063.0, 21238.0, 21415.0, 21594.0, 21774.0, 21955.0, 22138.0, 22323.0, 22509.0, 22697.0, 22886.0, 23077.0, 23269.0, 23463.0, 23659.0, 23856.0, 24055.0, 24255.0, 24458.0, 24662.0, 24867.0, 25075.0, 25284.0, 25494.0, 25707.0, 25921.0, 26137.0, 26355.0, 26575.0, 26796.0, 27020.0, 27245.0, 27472.0, 27701.0, 27932.0, 28165.0, 28400.0, 28637.0, 28875.0, 29116.0, 29359.0, 29604.0, 29850.0, 30099.0, 30350.0, 30603.0, 30858.0, 31116.0, 31375.0, 31637.0, 31900.0, 32166.0, 32435.0, 32705.0, 32978.0, 33253.0, 33530.0, 33809.0, 34091.0, 34375.0, 34662.0, 34951.0, 35242.0, 35536.0, 35832.0, 36131.0, 36432.0, 36736.0, 37042.0, 37351.0, 37663.0, 37977.0, 38293.0, 38612.0, 38934.0, 39259.0, 39586.0, 39916.0, 40249.0, 40585.0, 40923.0, 41264.0, 41608.0, 41955.0, 42305.0, 42657.0, 43013.0, 43372.0, 43733.0, 44098.0, 44466.0, 44836.0, 45210.0, 45587.0, 45967.0, 46350.0, 46737.0, 47126.0, 47519.0, 47915.0, 48315.0, 48718.0, 49124.0, 49533.0, 49946.0, 50363.0, 50783.0, 51206.0, 51633.0, 52063.0, 52497.0, 52935.0, 53376.0, 53821.0, 54270.0, 54722.0, 55179.0, 55639.0, 56102.0, 56570.0, 57042.0, 57517.0, 57997.0, 58480.0, 58968.0, 59460.0, 59955.0, 60455.0, 60959.0, 61467.0, 61980.0, 62497.0, 63018.0, 63543.0, 64073.0, 64607.0, 65145.0, 65689.0, 66236.0, 66788.0, 67345.0, 67907.0, 68473.0, 69044.0, 69619.0, 70200.0, 70785.0, 71375.0, 71970.0, 72570.0, 73175.0, 73785.0, 74400.0, 75021.0, 75646.0, 76277.0, 76913.0, 77554.0, 78200.0, 78852.0, 79510.0, 80173.0, 80841.0, 81515.0, 82195.0, 82880.0, 83571.0, 84267.0, 84970.0, 85678.0, 86393.0, 87113.0, 87839.0, 88571.0, 89310.0, 90054.0, 90805.0, 91562.0, 92326.0, 93095.0, 93871.0, 94654.0, 95443.0, 96239.0, 97041.0, 97850.0, 98666.0, 99489.0, 100320.0, 101150.0, 102000.0, 102850.0, 103710.0, 104570.0, 105440.0, 106320.0, 107210.0, 108100.0, 109000.0, 109910.0, 110830.0, 111750.0, 112680.0, 113620.0, 114570.0, 115520.0, 116490.0, 117460.0, 118440.0, 119430.0, 120420.0, 121430.0, 122440.0, 123460.0, 124490.0, 125530.0, 126570.0, 127630.0, 128690.0, 129760.0, 130850.0, 131940.0, 133040.0, 134150.0, 135260.0, 136390.0, 137530.0, 138680.0, 139830.0, 141000.0, 142170.0, 143360.0, 144550.0, 145760.0, 146970.0, 148200.0, 149430.0, 150680.0, 151940.0, 153200.0, 154480.0, 155770.0, 157070.0, 158380.0, 159700.0, 161030.0, 162370.0, 163720.0, 165090.0, 166470.0, 167850.0, 169250.0, 170660.0, 172090.0, 173520.0, 174970.0, 176430.0, 177900.0, 179380.0, 180880.0, 182380.0, 183900.0, 185440.0, 186980.0, 188540.0, 190110.0, 191700.0, 193300.0, 194910.0, 196530.0, 198170.0, 199820.0, 201490.0, 203170.0, 204860.0, 206570.0, 208290.0, 210030.0, 211780.0, 213550.0, 215330.0, 217120.0, 218930.0, 220760.0, 222600.0, 224450.0, 226330.0, 228210.0, 230120.0, 232030.0, 233970.0, 235920.0, 237890.0, 239870.0, 241870.0, 243880.0, 245920.0, 247970.0], n = [0.66397, 0.65688, 0.64959, 0.6421, 0.63439, 0.62645, 0.61828, 0.60985, 0.60117, 0.59221, 0.58296, 0.57341, 0.56353, 0.5533, 0.54271, 0.53172, 0.52032, 0.50846, 0.49612, 0.48326, 0.46982, 0.45576, 0.44102, 0.42551, 0.40917, 0.39187, 0.3735, 0.35389, 0.33284, 0.3101, 0.28532, 0.25807, 0.22779, 0.19402, 0.15728, 0.12194, 0.095549, 0.079098, 0.068839, 0.062015, 0.057192, 0.05362, 0.050882, 0.048729, 0.047003, 0.0456, 0.044448, 0.043496, 0.042707, 0.042051, 0.041508, 0.041061, 0.040695, 0.040401, 0.04017, 0.039994, 0.039868, 0.039786, 0.039745, 0.039741, 0.039771, 0.039832, 0.039923, 0.04004, 0.040184, 0.040351, 0.040542, 0.040754, 0.040988, 0.041241, 0.041514, 0.041805, 0.042115, 0.042443, 0.042787, 0.043149, 0.043527, 0.043921, 0.044332, 0.044759, 0.045201, 0.045659, 0.046132, 0.046621, 0.047126, 0.047646, 0.048181, 0.048732, 0.049298, 0.04988, 0.050477, 0.051091, 0.05172, 0.052365, 0.053026, 0.053704, 0.054398, 0.055108, 0.055835, 0.05658, 0.057341, 0.05812, 0.058917, 0.059731, 0.060564, 0.061415, 0.062284, 0.063173, 0.064081, 0.065008, 0.065956, 0.066924, 0.067912, 0.068921, 0.069951, 0.071003, 0.072077, 0.073174, 0.074293, 0.075436, 0.076602, 0.077792, 0.079007, 0.080246, 0.081511, 0.082802, 0.08412, 0.085464, 0.086835, 0.088235, 0.089663, 0.091119, 0.092606, 0.094122, 0.095669, 0.097247, 0.098857, 0.1005, 0.10217, 0.10388, 0.10563, 0.1074, 0.10922, 0.11107, 0.11295, 0.11488, 0.11684, 0.11884, 0.12088, 0.12296, 0.12508, 0.12724, 0.12945, 0.1317, 0.13399, 0.13633, 0.13871, 0.14114, 0.14361, 0.14613, 0.1487, 0.15132, 0.15399, 0.1567, 0.15947, 0.16229, 0.16516, 0.16809, 0.17106, 0.1741, 0.17718, 0.18033, 0.18352, 0.18678, 0.19009, 0.19346, 0.19689, 0.20037, 0.20392, 0.20753, 0.21119, 0.21492, 0.21871, 0.22256, 0.22647, 0.23044, 0.23448, 0.23859, 0.24275, 0.24699, 0.25129, 0.25565, 0.26008, 0.26459, 0.26916, 0.2738, 0.27851, 0.28329, 0.28815, 0.29309, 0.2981, 0.30319, 0.30836, 0.31361, 0.31894, 0.32437, 0.32988, 0.33549, 0.34119, 0.34699, 0.35289, 0.3589, 0.36502, 0.37126, 0.37761, 0.38409, 0.39069, 0.39743, 0.4043, 0.41133, 0.4185, 0.42583, 0.43332, 0.44098, 0.44881, 0.45683, 0.46504, 0.47345, 0.48206, 0.49088, 0.49993, 0.5092, 0.51871, 0.52845, 0.53845, 0.54871, 0.55924, 0.57004, 0.58112, 0.5925, 0.60417, 0.61615, 0.62844, 0.64104, 0.65398, 0.66725, 0.68086, 0.69481, 0.70911, 0.72377, 0.73878, 0.75416, 0.7699, 0.78601, 0.80249, 0.81933, 0.83655, 0.85413, 0.87207, 0.89038, 0.90904, 0.92804, 0.9474, 0.96708, 0.9871, 1.0074, 1.0281, 1.049, 1.0702, 1.0917, 1.1134, 1.1354, 1.1576, 1.1801, 1.2028, 1.2257, 1.2489, 1.2723, 1.2959, 1.3198, 1.3441, 1.3687, 1.3937, 1.4192, 1.4452, 1.472, 1.4996, 1.5283, 1.5581, 1.5894, 1.6223, 1.6573, 1.6945, 1.7345, 1.7775, 1.8241, 1.8745, 1.9291, 1.9883, 2.0521, 2.1205, 2.193, 2.2687, 2.3463, 2.4237, 2.4982, 2.5667, 2.6257, 2.6719, 2.7026, 2.7157, 2.7107, 2.6882, 2.6498, 2.5982, 2.5362, 2.4671, 2.3935, 2.3181, 2.2427, 2.1689, 2.0977, 2.03, 1.966, 1.906, 1.85, 1.798, 1.7498, 1.7052, 1.6641, 1.6261, 1.591, 1.5587, 1.529, 1.5016, 1.4765, 1.4534, 1.4321, 1.4127, 1.3949, 1.3786, 1.3638, 1.3503, 1.3381, 1.327, 1.3172, 1.3083, 1.3006, 1.2937, 1.2878, 1.2828, 1.2786, 1.2752, 1.2726, 1.2708, 1.2697, 1.2692, 1.2695, 1.2703, 1.2718, 1.274, 1.2767, 1.28, 1.2839, 1.2883, 1.2932, 1.2987, 1.3048, 1.3113, 1.3183, 1.3258, 1.3338, 1.3423, 1.3513, 1.3607, 1.3706, 1.381, 1.3918, 1.4031, 1.4148, 1.4269, 1.4395, 1.4526, 1.4661, 1.48, 1.4943, 1.5091, 1.5243, 1.5399, 1.556, 1.5725, 1.5895, 1.6068, 1.6246, 1.6429, 1.6615, 1.6806, 1.7002, 1.7201, 1.7405, 1.7614, 1.7827, 1.8044, 1.8266, 1.8492, 1.8723, 1.8958, 1.9198, 1.9443, 1.9692, 1.9945, 2.0204, 2.0467, 2.0735, 2.1007, 2.1285, 2.1567, 2.1854, 2.2146, 2.2443, 2.2745, 2.3051, 2.3363, 2.368, 2.4002, 2.433, 2.4662, 2.5, 2.5343, 2.5691, 2.6045, 2.6404, 2.6769, 2.7139, 2.7515, 2.7896, 2.8283, 2.8676, 2.9074, 2.9478, 2.9888, 3.0304, 3.0726, 3.1153, 3.1587, 3.2027, 3.2473, 3.2925, 3.3383, 3.3847, 3.4318, 3.4795, 3.5279, 3.5768, 3.6265, 3.6768, 3.7277, 3.7793, 3.8316, 3.8845, 3.9381, 3.9924, 4.0474, 4.103, 4.1594, 4.2164, 4.2741, 4.3326, 4.3917, 4.4516, 4.5122, 4.5735, 4.6355, 4.6982, 4.7617, 4.8259, 4.8909, 4.9566, 5.023, 5.0902, 5.1582, 5.2269, 5.2963, 5.3665, 5.4375, 5.5093, 5.5818, 5.6551, 5.7292, 5.804, 5.8797, 5.9561, 6.0333, 6.1113, 6.1901, 6.2697, 6.3501, 6.4313, 6.5132, 6.596, 6.6796, 6.764, 6.8492, 6.9352, 7.0221, 7.1097, 7.1981, 7.2874, 7.3775, 7.4684, 7.5601, 7.6527, 7.7461, 7.8402, 7.9353, 8.0311, 8.1278, 8.2253, 8.3236, 8.4228, 8.5228, 8.6237, 8.7253, 8.8278, 8.9312, 9.0354, 9.1404, 9.2463, 9.353, 9.4606, 9.569, 9.6783, 9.7884, 9.8994, 10.011, 10.124, 10.238, 10.352, 10.467, 10.584, 10.701, 10.819, 10.937, 11.057, 11.178, 11.299, 11.422, 11.545, 11.669, 11.795, 11.921, 12.048, 12.176, 12.305, 12.435, 12.566, 12.698, 12.83, 12.964, 13.099, 13.235, 13.372, 13.51, 13.649, 13.789, 13.931, 14.073, 14.216, 14.361, 14.507, 14.654, 14.802, 14.951, 15.101, 15.253, 15.406, 15.56, 15.716, 15.873, 16.031, 16.191, 16.352, 16.514, 16.678, 16.844, 17.01, 17.179, 17.349, 17.52, 17.693, 17.868, 18.045, 18.223, 18.403, 18.585, 18.768, 18.953, 19.141, 19.33, 19.521, 19.714, 19.909, 20.106, 20.306, 20.507, 20.711, 20.917, 21.125, 21.335, 21.548, 21.763, 21.981, 22.201, 22.424, 22.649, 22.877, 23.108, 23.341, 23.578, 23.817, 24.058, 24.303, 24.551, 24.802, 25.056, 25.312, 25.573, 25.836, 26.103, 26.373, 26.646, 26.923, 27.203, 27.487, 27.774, 28.065, 28.36, 28.659, 28.961, 29.267, 29.577, 29.891, 30.209, 30.531, 30.857, 31.187, 31.522, 31.861, 32.204, 32.551, 32.903, 33.26, 33.62, 33.986, 34.356, 34.731, 35.11, 35.494, 35.883, 36.277, 36.676, 37.08, 37.488, 37.902, 38.321, 38.745, 39.174, 39.608, 40.048, 40.493, 40.943, 41.398, 41.859, 42.326, 42.798, 43.275, 43.758, 44.247, 44.741, 45.241, 45.746, 46.258, 46.775, 47.298, 47.826, 48.361, 48.901, 49.447, 50.0, 50.558, 51.122, 51.692, 52.268, 52.85, 53.439, 54.033, 54.633, 55.24, 55.852, 56.471, 57.096, 57.727, 58.364, 59.008, 59.657, 60.313, 60.975, 61.644, 62.318, 62.999, 63.686, 64.379, 65.078, 65.784, 66.496, 67.214, 67.939, 68.669, 69.406, 70.149, 70.898, 71.654, 72.415, 73.183, 73.957, 74.737, 75.524, 76.316, 77.115, 77.92, 78.731, 79.547, 80.37, 81.2, 82.035, 82.876, 83.723, 84.576, 85.435, 86.3, 87.171, 88.048, 88.931, 89.819, 90.713, 91.614, 92.52, 93.431, 94.349, 95.272, 96.201, 97.135, 98.075, 99.021, 99.972, 100.93, 101.89, 102.86, 103.83, 104.81, 105.79, 106.78, 107.78, 108.78, 109.78, 110.79, 111.81, 112.83, 113.86, 114.89, 115.92, 116.97, 118.01, 119.06, 120.12, 121.18, 122.25, 123.32, 124.39, 125.47, 126.56, 127.65, 128.74, 129.84, 130.95, 132.06, 133.17, 134.29, 135.41, 136.54, 137.67, 138.81, 139.95, 141.1, 142.25, 143.4, 144.56, 145.72, 146.89, 148.06, 149.24, 150.42, 151.6, 152.79, 153.98, 155.18, 156.38, 157.59, 158.8, 160.01, 161.23, 162.45, 163.67, 164.9, 166.14, 167.38, 168.62, 169.87, 171.12, 172.37, 173.63, 174.89, 176.16, 177.43, 178.7, 179.98, 181.26, 182.55, 183.83, 185.13, 186.43, 187.73, 189.03, 190.34, 191.65, 192.97, 194.29, 195.62, 196.94, 198.28, 199.61, 200.95, 202.3, 203.65, 205.0, 206.35, 207.71, 209.08, 210.44, 211.81, 213.19, 214.57, 215.95, 217.34, 218.73, 220.12, 221.52, 222.92, 224.33, 225.74, 227.16, 228.58, 230.0, 231.42, 232.86, 234.29, 235.73, 237.17, 238.62, 240.07, 241.52, 242.98, 244.45, 245.91, 247.39, 248.86, 250.34, 251.83, 253.31, 254.81, 256.3, 257.81, 259.31, 260.82, 262.34, 263.85, 265.38, 266.91, 268.44, 269.97, 271.51, 273.06, 274.61, 276.16, 277.72, 279.29, 280.85, 282.43, 284.0, 285.59, 287.17, 288.76, 290.36, 291.96, 293.57, 295.18, 296.79, 298.41, 300.04, 301.67, 303.3, 304.94, 306.59, 308.23, 309.89, 311.55, 313.21, 314.88, 316.56, 318.24, 319.93, 321.62, 323.31, 325.01, 326.72, 328.43, 330.15, 331.87, 333.6, 335.33, 337.07, 338.82, 340.57, 342.32, 344.09, 345.85, 347.63, 349.4, 351.19, 352.98, 354.77, 356.58, 358.38, 360.2, 362.02, 363.84, 365.67, 367.51, 369.35, 371.2, 373.06, 374.92, 376.79, 378.66, 380.54, 382.43, 384.32, 386.22, 388.13, 390.04, 391.96, 393.88, 395.81, 397.75, 399.7, 401.65, 403.61, 405.57, 407.54, 409.52, 411.51, 413.5, 415.5, 417.5, 419.52, 421.54, 423.56, 425.6, 427.64, 429.68, 431.74, 433.8, 435.87, 437.95, 440.03, 442.12, 444.22, 446.33, 448.44, 450.57, 452.69, 454.83, 456.97, 459.13, 461.29, 463.45, 465.63, 467.81, 470.0, 472.2], k = [0.009454, 0.009799, 0.010161, 0.010541, 0.010941, 0.011362, 0.011806, 0.012273, 0.012768, 0.013292, 0.013847, 0.014436, 0.015064, 0.015734, 0.01645, 0.017218, 0.018045, 0.018937, 0.019903, 0.020955, 0.022104, 0.023368, 0.024766, 0.026325, 0.028076, 0.030065, 0.03235, 0.035016, 0.038183, 0.042032, 0.046852, 0.053126, 0.061727, 0.074328, 0.094043, 0.12441, 0.16283, 0.20174, 0.23776, 0.27069, 0.30105, 0.32934, 0.35598, 0.38126, 0.40541, 0.42862, 0.45103, 0.47275, 0.49388, 0.51448, 0.53463, 0.55437, 0.57376, 0.59282, 0.6116, 0.63013, 0.64843, 0.66653, 0.68444, 0.70218, 0.71978, 0.73725, 0.75459, 0.77183, 0.78897, 0.80603, 0.82301, 0.83993, 0.85679, 0.8736, 0.89037, 0.9071, 0.92381, 0.94049, 0.95715, 0.9738, 0.99045, 1.0071, 1.0237, 1.0404, 1.0571, 1.0737, 1.0904, 1.1072, 1.1239, 1.1407, 1.1575, 1.1744, 1.1912, 1.2082, 1.2252, 1.2422, 1.2593, 1.2764, 1.2936, 1.3108, 1.3281, 1.3455, 1.3629, 1.3804, 1.398, 1.4156, 1.4333, 1.4511, 1.469, 1.4869, 1.505, 1.5231, 1.5413, 1.5596, 1.578, 1.5964, 1.615, 1.6337, 1.6524, 1.6713, 1.6902, 1.7093, 1.7285, 1.7478, 1.7671, 1.7866, 1.8062, 1.826, 1.8458, 1.8657, 1.8858, 1.906, 1.9263, 1.9467, 1.9673, 1.988, 2.0088, 2.0297, 2.0508, 2.072, 2.0933, 2.1148, 2.1364, 2.1582, 2.1801, 2.2021, 2.2243, 2.2466, 2.2691, 2.2917, 2.3144, 2.3373, 2.3604, 2.3836, 2.407, 2.4305, 2.4542, 2.4781, 2.5021, 2.5262, 2.5506, 2.5751, 2.5997, 2.6246, 2.6496, 2.6747, 2.7001, 2.7256, 2.7513, 2.7771, 2.8032, 2.8294, 2.8558, 2.8824, 2.9092, 2.9361, 2.9632, 2.9906, 3.0181, 3.0458, 3.0737, 3.1018, 3.13, 3.1585, 3.1872, 3.2161, 3.2452, 3.2744, 3.3039, 3.3336, 3.3635, 3.3937, 3.424, 3.4545, 3.4853, 3.5163, 3.5475, 3.579, 3.6106, 3.6425, 3.6747, 3.707, 3.7397, 3.7725, 3.8056, 3.839, 3.8726, 3.9065, 3.9406, 3.975, 4.0097, 4.0446, 4.0798, 4.1153, 4.1511, 4.1871, 4.2235, 4.2601, 4.2971, 4.3343, 4.3718, 4.4097, 4.4478, 4.4863, 4.525, 4.5641, 4.6035, 4.6432, 4.6832, 4.7236, 4.7643, 4.8052, 4.8465, 4.8882, 4.9301, 4.9724, 5.015, 5.0578, 5.1011, 5.1446, 5.1884, 5.2325, 5.2769, 5.3216, 5.3666, 5.4119, 5.4574, 5.5033, 5.5493, 5.5957, 5.6423, 5.6891, 5.7361, 5.7834, 5.8309, 5.8785, 5.9264, 5.9744, 6.0226, 6.0709, 6.1194, 6.168, 6.2167, 6.2655, 6.3144, 6.3634, 6.4125, 6.4617, 6.5109, 6.5601, 6.6094, 6.6587, 6.7081, 6.7576, 6.8071, 6.8566, 6.9063, 6.956, 7.0058, 7.0558, 7.106, 7.1563, 7.2069, 7.2578, 7.3089, 7.3605, 7.4124, 7.4649, 7.5178, 7.5713, 7.6254, 7.6801, 7.7354, 7.7913, 7.8477, 7.9046, 7.9618, 8.0191, 8.0761, 8.1324, 8.1875, 8.2405, 8.2905, 8.3365, 8.3769, 8.4104, 8.4352, 8.4497, 8.4524, 8.4424, 8.4194, 8.3841, 8.3382, 8.2847, 8.2271, 8.1697, 8.1165, 8.071, 8.0362, 8.0137, 8.0045, 8.0086, 8.0253, 8.0538, 8.0929, 8.1412, 8.1974, 8.2605, 8.3294, 8.4032, 8.4811, 8.5624, 8.6466, 8.7333, 8.8221, 8.9127, 9.0048, 9.0983, 9.193, 9.2887, 9.3855, 9.4831, 9.5815, 9.6807, 9.7806, 9.8813, 9.9826, 10.085, 10.187, 10.29, 10.394, 10.499, 10.604, 10.709, 10.816, 10.923, 11.03, 11.138, 11.247, 11.356, 11.467, 11.577, 11.689, 11.801, 11.913, 12.027, 12.141, 12.255, 12.371, 12.487, 12.604, 12.721, 12.839, 12.958, 13.078, 13.199, 13.32, 13.442, 13.565, 13.688, 13.812, 13.937, 14.063, 14.19, 14.318, 14.446, 14.575, 14.705, 14.836, 14.968, 15.1, 15.234, 15.368, 15.503, 15.639, 15.776, 15.914, 16.053, 16.192, 16.333, 16.475, 16.617, 16.76, 16.905, 17.05, 17.196, 17.344, 17.492, 17.641, 17.792, 17.943, 18.095, 18.248, 18.403, 18.558, 18.714, 18.872, 19.03, 19.19, 19.351, 19.512, 19.675, 19.839, 20.004, 20.17, 20.337, 20.505, 20.675, 20.845, 21.017, 21.19, 21.364, 21.539, 21.715, 21.892, 22.071, 22.251, 22.432, 22.614, 22.797, 22.982, 23.167, 23.354, 23.542, 23.732, 23.922, 24.114, 24.308, 24.502, 24.698, 24.894, 25.093, 25.292, 25.493, 25.695, 25.898, 26.103, 26.309, 26.516, 26.725, 26.934, 27.146, 27.358, 27.572, 27.787, 28.004, 28.222, 28.441, 28.662, 28.884, 29.108, 29.332, 29.559, 29.786, 30.015, 30.246, 30.478, 30.711, 30.946, 31.182, 31.42, 31.659, 31.899, 32.142, 32.385, 32.63, 32.876, 33.124, 33.374, 33.625, 33.877, 34.131, 34.387, 34.644, 34.903, 35.163, 35.425, 35.688, 35.953, 36.219, 36.487, 36.757, 37.028, 37.301, 37.575, 37.852, 38.129, 38.409, 38.69, 38.973, 39.257, 39.543, 39.831, 40.121, 40.412, 40.705, 41.0, 41.296, 41.595, 41.895, 42.196, 42.5, 42.806, 43.113, 43.422, 43.733, 44.046, 44.361, 44.678, 44.996, 45.317, 45.64, 45.964, 46.291, 46.619, 46.95, 47.282, 47.617, 47.954, 48.292, 48.633, 48.976, 49.321, 49.669, 50.018, 50.37, 50.724, 51.08, 51.439, 51.8, 52.163, 52.528, 52.896, 53.266, 53.639, 54.014, 54.392, 54.772, 55.154, 55.54, 55.927, 56.317, 56.71, 57.106, 57.504, 57.905, 58.309, 58.715, 59.124, 59.536, 59.951, 60.369, 60.789, 61.213, 61.639, 62.069, 62.501, 62.937, 63.375, 63.817, 64.262, 64.71, 65.161, 65.615, 66.073, 66.534, 66.998, 67.465, 67.936, 68.411, 68.889, 69.37, 69.854, 70.343, 70.834, 71.33, 71.829, 72.331, 72.837, 73.347, 73.861, 74.378, 74.899, 75.424, 75.953, 76.485, 77.022, 77.562, 78.106, 78.654, 79.206, 79.762, 80.322, 80.886, 81.454, 82.026, 82.602, 83.182, 83.766, 84.355, 84.947, 85.544, 86.145, 86.75, 87.359, 87.973, 88.59, 89.212, 89.838, 90.468, 91.103, 91.741, 92.384, 93.032, 93.683, 94.339, 94.999, 95.663, 96.332, 97.005, 97.682, 98.363, 99.049, 99.739, 100.43, 101.13, 101.83, 102.54, 103.25, 103.97, 104.69, 105.41, 106.14, 106.87, 107.61, 108.35, 109.09, 109.84, 110.59, 111.35, 112.11, 112.87, 113.64, 114.41, 115.19, 115.97, 116.76, 117.54, 118.34, 119.13, 119.93, 120.73, 121.54, 122.35, 123.17, 123.99, 124.81, 125.63, 126.46, 127.29, 128.13, 128.97, 129.81, 130.66, 131.51, 132.36, 133.22, 134.07, 134.94, 135.8, 136.67, 137.54, 138.42, 139.29, 140.18, 141.06, 141.95, 142.83, 143.73, 144.62, 145.52, 146.42, 147.32, 148.23, 149.13, 150.04, 150.96, 151.87, 152.79, 153.71, 154.63, 155.55, 156.48, 157.41, 158.34, 159.27, 160.21, 161.14, 162.08, 163.02, 163.97, 164.91, 165.86, 166.8, 167.75, 168.7, 169.66, 170.61, 171.57, 172.52, 173.48, 174.44, 175.41, 176.37, 177.33, 178.3, 179.27, 180.23, 181.2, 182.18, 183.15, 184.12, 185.09, 186.07, 187.05, 188.02, 189.0, 189.98, 190.96, 191.94, 192.93, 193.91, 194.89, 195.88, 196.86, 197.85, 198.84, 199.83, 200.82, 201.81, 202.8, 203.79, 204.78, 205.77, 206.77, 207.76, 208.76, 209.75, 210.75, 211.75, 212.74, 213.74, 214.74, 215.74, 216.74, 217.74, 218.75, 219.75, 220.75, 221.76, 222.76, 223.77, 224.78, 225.78, 226.79, 227.8, 228.81, 229.82, 230.83, 231.85, 232.86, 233.87, 234.89, 235.9, 236.92, 237.94, 238.96, 239.98, 241.0, 242.02, 243.04, 244.07, 245.09, 246.12, 247.14, 248.17, 249.2, 250.23, 251.27, 252.3, 253.33, 254.37, 255.41, 256.45, 257.49, 258.53, 259.57, 260.62, 261.66, 262.71, 263.76, 264.81, 265.86, 266.92, 267.97, 269.03, 270.09, 271.15, 272.22, 273.28, 274.35, 275.42, 276.49, 277.56, 278.64, 279.72, 280.8, 281.88, 282.97, 284.05, 285.14, 286.23, 287.33, 288.42, 289.52, 290.62, 291.73, 292.84, 293.95, 295.06, 296.17, 297.29, 298.41, 299.53, 300.66, 301.79, 302.92, 304.06, 305.2, 306.34, 307.48, 308.63, 309.78, 310.93, 312.09, 313.25, 314.42, 315.58, 316.76, 317.93, 319.11, 320.29, 321.48, 322.66, 323.86, 325.05, 326.25, 327.46, 328.67, 329.88, 331.09, 332.31, 333.54, 334.77, 336.0, 337.23, 338.47, 339.72, 340.97, 342.22, 343.48, 344.74, 346.01, 347.28, 348.55, 349.83, 351.12, 352.41, 353.7, 355.0, 356.3, 357.61, 358.92, 360.24, 361.56, 362.89, 364.22, 365.56, 366.9, 368.25, 369.61, 370.96, 372.33, 373.69, 375.07, 376.45, 377.83, 379.22, 380.62, 382.02, 383.42, 384.84, 386.25, 387.68, 389.1, 390.54, 391.98, 393.42, 394.88, 396.33, 397.8, 399.27, 400.74, 402.22, 403.71, 405.2, 406.7, 408.21, 409.72, 411.24, 412.76, 414.29, 415.83, 417.37, 418.92, 420.48, 422.04, 423.61, 425.19, 426.77, 428.36, 429.95, 431.56, 433.16, 434.78, 436.4, 438.03, 439.67, 441.31, 442.96, 444.62, 446.28, 447.95, 449.63, 451.32, 453.01, 454.71, 456.42, 458.13, 459.85, 461.58, 463.32, 465.06, 466.82, 468.57, 470.34, 472.12, 473.9, 475.69, 477.48, 479.29, 481.1, 482.92, 484.75, 486.59, 488.43, 490.28, 492.14, 494.01, 495.89, 497.77, 499.66, 501.56, 503.47, 505.39, 507.32, 509.25, 511.19, 513.14, 515.1, 517.07, 519.05, 521.03, 523.02]} + # Aluminum 6061 (parent alloy node — tempers hang below) [aluminum.a6061] name = "Aluminum 6061" @@ -333,6 +336,9 @@ resistivity_unit = "ohm*m" conductivity_value = 5.85e7 conductivity_unit = "S/m" +[copper.optical] +refractive_index_dispersion = {wavelengths_nm = [187.9, 191.6, 195.3, 199.3, 203.3, 207.3, 211.9, 216.4, 221.4, 226.2, 231.3, 237.1, 242.6, 249.0, 255.1, 261.6, 268.9, 276.1, 284.4, 292.4, 300.9, 310.7, 320.4, 331.5, 342.5, 354.2, 367.9, 381.5, 397.4, 413.3, 430.5, 450.9, 471.4, 495.9, 520.9, 548.6, 582.1, 616.8, 659.5, 704.5, 756.0, 821.1, 892.0, 984.0, 1088.0, 1216.0, 1393.0, 1610.0, 1937.0], n = [0.94, 0.95, 0.97, 0.98, 0.99, 1.01, 1.04, 1.08, 1.13, 1.18, 1.23, 1.28, 1.34, 1.37, 1.41, 1.41, 1.45, 1.46, 1.45, 1.42, 1.4, 1.38, 1.38, 1.34, 1.36, 1.37, 1.36, 1.33, 1.32, 1.28, 1.25, 1.24, 1.25, 1.22, 1.18, 1.02, 0.7, 0.3, 0.22, 0.21, 0.24, 0.26, 0.3, 0.32, 0.36, 0.48, 0.6, 0.76, 1.09], k = [1.337, 1.388, 1.44, 1.493, 1.55, 1.599, 1.651, 1.699, 1.737, 1.768, 1.792, 1.802, 1.799, 1.783, 1.741, 1.691, 1.668, 1.646, 1.633, 1.633, 1.679, 1.729, 1.783, 1.821, 1.864, 1.916, 1.975, 2.045, 2.116, 2.207, 2.305, 2.397, 2.483, 2.564, 2.608, 2.577, 2.704, 3.205, 3.747, 4.205, 4.665, 5.18, 5.768, 6.421, 7.217, 8.245, 9.439, 11.12, 13.43]} + # ============================================================================ # TUNGSTEN @@ -598,6 +604,9 @@ thermal_conductivity_unit = "W/(m*K)" specific_heat_value = 129 specific_heat_unit = "J/(kg*K)" +[gold.optical] +refractive_index_dispersion = {wavelengths_nm = [187.9, 191.6, 195.3, 199.3, 203.3, 207.3, 211.9, 216.4, 221.4, 226.2, 231.3, 237.1, 242.6, 249.0, 255.1, 261.6, 268.9, 276.1, 284.4, 292.4, 300.9, 310.7, 320.4, 331.5, 342.5, 354.2, 367.9, 381.5, 397.4, 413.3, 430.5, 450.9, 471.4, 495.9, 520.9, 548.6, 582.1, 616.8, 659.5, 704.5, 756.0, 821.1, 892.0, 984.0, 1088.0, 1216.0, 1393.0, 1610.0, 1937.0], n = [1.28, 1.32, 1.34, 1.33, 1.33, 1.3, 1.3, 1.3, 1.3, 1.31, 1.3, 1.32, 1.32, 1.33, 1.33, 1.35, 1.38, 1.43, 1.47, 1.49, 1.53, 1.53, 1.54, 1.48, 1.48, 1.5, 1.48, 1.46, 1.47, 1.46, 1.45, 1.38, 1.31, 1.04, 0.62, 0.43, 0.29, 0.21, 0.14, 0.13, 0.14, 0.16, 0.17, 0.22, 0.27, 0.35, 0.43, 0.56, 0.92], k = [1.188, 1.203, 1.226, 1.251, 1.277, 1.304, 1.35, 1.387, 1.427, 1.46, 1.497, 1.536, 1.577, 1.631, 1.688, 1.749, 1.803, 1.847, 1.869, 1.878, 1.889, 1.893, 1.898, 1.883, 1.871, 1.866, 1.895, 1.933, 1.952, 1.958, 1.948, 1.914, 1.849, 1.833, 2.081, 2.455, 2.863, 3.272, 3.697, 4.103, 4.542, 5.083, 5.663, 6.35, 7.15, 8.145, 9.519, 11.21, 13.78]} + [gold.vis] base_color = [1.0, 0.84, 0.0, 1.0] metallic = 1.0 @@ -1443,6 +1452,9 @@ ref = "mil-hdbk-5j:p2-197" license = "PD-USGov" note = "17-4PH AMS 5604 sheet/strip/plate, H900 condition, S-basis design allowable, long transverse, room temperature; Table 2.6.9.0(b) Fty(LT) = 170 ksi = 1172 MPa (TOML rounds to 1170); verified 2026-05-07." +[aluminum._sources] +"optical.refractive_index_dispersion" = {citation = "Rakic 1995 (Al, Lorentz-Drude) via refractiveindex.info", kind = "doi", ref = "refractiveindex.info:main/Al/nk/Rakic-LD", license = "CC0", note = "n,k tabulated 1000 rows, 62-247970 nm, fetched 2026-08-14"} + [aluminum._sources._default] citation = "py-mat-curation-3.x" kind = "handbook" @@ -1527,6 +1539,9 @@ ref = "mil-hdbk-5j:p3-69" license = "PD-USGov" note = "2024 AMS 4037/AMS-QQ-A-250/4 sheet/plate; physical property (typical) from Table 3.2.3.0(b1); 0.100 lb/in^3 = 2.768 g/cm^3 (TOML 2.78 rounds matching); room temperature; verified 2026-05-07." +[copper._sources] +"optical.refractive_index_dispersion" = {citation = "Johnson & Christy 1972 (Cu) via refractiveindex.info", kind = "doi", ref = "refractiveindex.info:main/Cu/nk/Johnson", license = "CC0", note = "n,k tabulated 49 rows, 188-1937 nm, fetched 2026-08-14"} + [copper._sources._default] citation = "py-mat-curation-3.x" kind = "handbook" @@ -1715,6 +1730,9 @@ kind = "handbook" ref = "py-mat curation history; values from handbook/vendor/Wikidata aggregate (pre-#175 audit)" license = "proprietary-reference-only" +[gold._sources] +"optical.refractive_index_dispersion" = {citation = "Johnson & Christy 1972 (Au) via refractiveindex.info", kind = "doi", ref = "refractiveindex.info:main/Au/nk/Johnson", license = "CC0", note = "n,k tabulated 49 rows, 188-1937 nm, fetched 2026-08-14"} + [gold._sources._default] citation = "py-mat-curation-3.x" kind = "handbook" diff --git a/src/pymat/data/plastics.toml b/src/pymat/data/plastics.toml index 2693fb5..988080f 100644 --- a/src/pymat/data/plastics.toml +++ b/src/pymat/data/plastics.toml @@ -2213,3 +2213,105 @@ kind = "vendor" ref = "emcoplastics.com:borated-hdpe" license = "proprietary-reference-only" note = "Heat-deflection temperature at 66 psi = 171 °F = 77.2 °C (Emco datasheet). Adopted as the conservative continuous max-service temperature; HDPE host's matrix softens above this. Shieldwerx SWX-201 lists `up to ~85 °C` for the same grade — the Emco/ASTM HDT value is the more defensible primary number." + + +# ============================================================================ +# SiPM entrance windows (#243) +# ============================================================================ +# A photodetector is NOT a material and is deliberately absent from this +# database (ADR-0004 §11): its PDE, dark count rate, crosstalk and afterpulse +# are the response of a manufactured device at an operating point, and they +# move with overvoltage and temperature, which are run-time choices. +# +# The WINDOW is a different matter. It is a substance, and it is the medium an +# optical photon actually crosses when it leaves the coupling grease — the +# boundary is grease->window, not grease->"SiPM". Both variants are here so the +# Fresnel step at the readout face is computed from cited indices on both +# sides rather than one lumped number. +# +# WHICH ONE YOU HAVE MATTERS. Going from BC-630 grease (n = 1.465): +# -> silicone window (1.41): index steps DOWN. Real Fresnel step, and a +# critical angle near 74 deg at that boundary. +# -> epoxy window (1.55): index steps UP. No total internal reflection there. +# Those are qualitatively different at the face that does the readout. + +[sipm_window_silicone] +name = "SiPM entrance window, silicone resin" +vendor = "hamamatsu" +tags = ["optical-window", "photodetector-window", "silicone"] + +[sipm_window_silicone.optical] +refractive_index = 1.41 + +[sipm_window_silicone.vis] +base_color = [0.95, 0.95, 0.92, 0.4] +metallic = 0.0 +roughness = 0.05 +transmission = 0.95 +ior = 1.41 + +[sipm_window_silicone._sources] +"optical.refractive_index" = { citation = "hamamatsu_s13360_kapd1052e", kind = "vendor", ref = "https://www.hamamatsu.com/content/dam/hamamatsu-photonics/sites/documents/99_SALES_LIBRARY/ssd/s13360_series_kapd1052e.pdf", license = "proprietary-reference-only", note = "Hamamatsu MPPC S13360 series datasheet, Cat. No. KAPD1052E07. The CS (ceramic) packages — e.g. S13360-3050CS — use a SILICONE resin window with n = 1.41. The n = 1.55 figure widely quoted for 'the Hamamatsu window' is the PE (glass-epoxy) variant and does not apply to CS parts." } + +[sipm_window_silicone._absent] +"optical.refractive_index_dispersion" = { reason = "not-measured", note = "The datasheet gives a single index, no dispersion. The material is optically similar to PDMS, whose n(lambda) is well characterised, so a consumer needing dispersion could substitute PDMS and SAY it substituted — but that is a modelling assumption and does not belong in this database as if measured." } + +[sipm_window_epoxy] +name = "SiPM entrance window, epoxy resin" +vendor = "hamamatsu" +tags = ["optical-window", "photodetector-window", "epoxy"] + +[sipm_window_epoxy.optical] +refractive_index = 1.55 + +[sipm_window_epoxy.vis] +base_color = [0.96, 0.94, 0.88, 0.4] +metallic = 0.0 +roughness = 0.05 +transmission = 0.95 +ior = 1.55 + +[sipm_window_epoxy._sources] +"optical.refractive_index" = { citation = "hamamatsu_s13360_kapd1052e", kind = "vendor", ref = "https://www.hamamatsu.com/content/dam/hamamatsu-photonics/sites/documents/99_SALES_LIBRARY/ssd/s13360_series_kapd1052e.pdf", license = "proprietary-reference-only", note = "Hamamatsu MPPC S13360 series datasheet, Cat. No. KAPD1052E07. The PE (glass-epoxy) packages use an EPOXY resin window with n = 1.55." } + + +# ============================================================================ +# DOWSIL Q2-3067 optical couplant (#243) +# ============================================================================ + +[q2_3067] +name = "DOWSIL Q2-3067 Optical Couplant" +vendor = "dow" +tags = ["optical-coupling", "grease", "silicone"] + +[q2_3067.mechanical] +density_value = 0.976 +density_unit = "g/cm^3" + +[q2_3067.optical] +refractive_index = 1.4658 +# Transmission at 10 mm path, PERCENT. Note the blue end: 70% at 400 nm rising +# to 87% at 500 nm. For a 420 nm emitter this is materially worse than BC-630, +# and the 1200 nm collapse to 31% is a silicone C-H overtone absorption. +transparency = 87 +transparency_spectrum = { wavelengths_nm = [400, 500, 600, 700, 800, 900, 1000, 1100, 1200], values = [70.0, 87.0, 92.0, 92.0, 94.0, 92.0, 87.0, 92.0, 31.0] } + +[q2_3067.thermal] +max_service_temp_value = 150 +max_service_temp_unit = "degC" + +[q2_3067.vis] +base_color = [0.98, 0.94, 0.82, 0.6] +metallic = 0.0 +roughness = 0.10 +transmission = 0.9 +ior = 1.4658 + +[q2_3067._sources] +_default = { citation = "dowsil_q2_3067_tds", kind = "vendor", ref = "DOWSIL Q2-3067 Optical Couplant Technical Data Sheet, Dow Form No. 11-4218-01-0521 S2D (2021)", license = "proprietary-reference-only", note = "Values cited from the Dow technical data sheet; the document itself is proprietary and is not redistributed." } +"optical.refractive_index" = { citation = "dowsil_q2_3067_tds", kind = "vendor", ref = "DOWSIL Q2-3067 TDS, Dow Form No. 11-4218-01-0521 S2D (2021)", license = "proprietary-reference-only", note = "n = 1.4658 at the sodium D line (589 nm). No n(lambda) dispersion is published — a consumer needing dispersion is inventing it and should say so." } +"optical.transparency" = { citation = "dowsil_q2_3067_tds", kind = "vendor", ref = "DOWSIL Q2-3067 TDS, Dow Form No. 11-4218-01-0521 S2D (2021)", license = "proprietary-reference-only", note = "Light transmission at 10 mm thickness. The scalar records the 500 nm value; the full table is in the spectrum field. The datasheet tabulates nothing below 400 nm." } +"optical.transparency_spectrum" = { citation = "dowsil_q2_3067_tds", kind = "vendor", ref = "DOWSIL Q2-3067 TDS, Dow Form No. 11-4218-01-0521 S2D (2021)", license = "proprietary-reference-only", note = "Transmission percentages at a 10 mm path length. The datasheet tabulates nothing below 400 nm, so the 70% at 400 nm is the bluest measured point and the clamp below it is a floor, not a measurement." } + +[q2_3067._absent] +"optical.refractive_index_dispersion" = { reason = "not-measured", note = "Only n_D at 589 nm is published, as for BC-630. No optical-coupling compound in this database has a measured dispersion curve." } diff --git a/src/pymat/data/scintillators.toml b/src/pymat/data/scintillators.toml index 3ab0912..7e63ea5 100644 --- a/src/pymat/data/scintillators.toml +++ b/src/pymat/data/scintillators.toml @@ -1,5 +1,28 @@ # Scintillator crystals and plastic scintillators database # Units: All values use explicit *_value and *_unit format for v2.0.0 compatibility +# +# --------------------------------------------------------------------------- +# Who owns `refractive_index_dispersion` here (ADR-0004 §10) +# --------------------------------------------------------------------------- +# `scripts/enrich_from_refractiveindex.py` (#164) is an ADD-ONLY enricher that +# pulls CC0 dispersion from refractiveindex.info and writes it with a `_sources` +# row. It SKIPS any material that already has dispersion, so hand-authored data +# inside its scope would permanently mask the automated pull and the two would +# diverge invisibly. +# +# In scope, DO NOT hand-author: nai, nai.Tl, csi, csi.Tl, csi.Na, bgo +# Out of scope, hand-author with a citation: lyso, lso, gagg, labr3, pwo, +# plastic_scint, cebr3, sri2 +# +# All six in-scope materials are currently EMPTY on disk — the enricher has not +# been run to `--write`. That is the action, not typing the numbers in: +# +# python scripts/enrich_from_refractiveindex.py --key bgo --dry-run +# python scripts/enrich_from_refractiveindex.py --write +# +# Note that refractiveindex.info carries neither LSO nor LYSO, so `lyso` is out +# of scope for a reason that no amount of running the enricher will fix — see +# its `_absent` block below. [lyso] name = "LYSO" @@ -10,14 +33,60 @@ density_value = 7.1 density_unit = "g/cm^3" [lyso.optical] +# LYSO is optically BIAXIAL positive — n_x = 1.8077, n_y = 1.8110, n_z = 1.8307 +# at 524 nm (Chen/Mao/Zhu 2011). The scalar below is the isotropic value the +# vendor datasheets quote near the emission peak, which is what a transport +# code using a single index wants; the anisotropy is recorded in the source +# note rather than faked with a tensor the schema cannot hold. refractive_index = 1.82 light_yield = 32000 decay_time = 41 emission_peak = 420 +# Emission band shape. Peak 420 nm (vendor), but the band is centred nearer +# 430 nm with ~60 nm FWHM (Bosca & Lopez 2023) because it is the sum of two +# inequivalent Ce sites (Ce1 ~393/427 nm, Ce2 ~460 nm). +emission_range = [380, 600] +# Lumped bulk attenuation. See the _sources note — this is a Monte-Carlo +# CONVENTION with a traceable provenance chain, not a measured bulk constant. +absorption_length = 200.0 +# Self-absorption channel. Bosca & Lopez 2023 measure a loss coefficient of +# alpha_L = 1.7e-2 /cm in the emission band over 105 mm of propagation; +# 1/alpha_L = 58.8 cm = 588 mm. This is the Ce re-absorption channel +# specifically, not total attenuation. +absorption_length_reabs = 588.0 +temperature_coefficient_light_yield = -0.15 +hygroscopic = false [lyso.nuclear] radiation_length = 1.14 interaction_length = 25 +# 176-Lu is 2.6% of natural Lu and beta-decays with T_1/2 = 3.76e10 yr, giving +# LYSO a permanent internal count rate. Load-bearing for low-activity PET. +intrinsic_activity_Bq_per_g = 40 + +[lyso._sources] +"optical.refractive_index" = { citation = "chen_mao_zhu_2011", kind = "doi", ref = "10.1016/j.optmat.2011.10.006", license = "proprietary-reference-only", note = "Chen, Mao, Zhu, 'Refractive index measurement of cerium-doped Lu_xY_(2-x)SiO5 single crystal', Opt. Mater. 34, 351-353 (2011). Minimum-deviation method, 7 wavelengths over 400-700 nm. Principal indices at 524 nm: n_x=1.8077, n_y=1.8110, n_z=1.8307. The 1.82 scalar here is the vendor near-peak figure (Luxium quotes 1.81 at 420 nm, Berkeley Nucleonics 1.82); it sits within the measured principal-index spread." } +"optical.emission_range" = { citation = "bosca_lopez_2023", kind = "doi", ref = "10.1038/s41598-023-32689-z", license = "CC-BY-4.0", note = "Bosca & Lopez, Sci. Rep. 13, 7199 (2023). Band centred 430 nm with 60 nm FWHM from a two-Ce-site Gaussian decomposition (Ce1 at 3.17 eV / 2.93 eV = 393 / 427 nm; Ce2 near 460 nm). The [380, 600] range here is the practical support, not a measured cutoff." } +"optical.absorption_length" = { citation = "usubov_2013", kind = "doi", ref = "10.48550/arXiv.1305.3010", license = "proprietary-reference-only", note = "CAUTION: this is a Monte-Carlo CONVENTION, not a measured bulk constant. Usubov 2013 adopts 20 cm flat across the emission band, inferred from Vilardi et al. 2006 (NIM A 564, 506-514, doi:10.1016/j.nima.2006.04.079), who measured ~10 cm EFFECTIVE attenuation in 3.2x3.2x100 mm polished bars — a figure that includes surface and wrapping losses, so it is a lower bound on the bulk value. Other simulation papers use 15 cm and 40 cm. A consumer that needs the physical bulk number should prefer the self-absorption channel below, which is directly measured. SENSITIVITY (measured downstream, 2026-08-14): this value is NOT second-order. In a wrapped 3x3x25 mm crystal it moves mean collection efficiency by +17% at a reflector R=0.97 but by +53% at R=0.999, because the two interact - at low reflectivity photons die at the wrap before path length matters, at high reflectivity they survive to accumulate path and absorption takes over. Sweep this value; do not adopt it. Any claim that it is second-order is only true at whatever reflectance it was computed against." } +"optical.absorption_length_reabs" = { citation = "bosca_lopez_2023", kind = "doi", ref = "10.1038/s41598-023-32689-z", license = "CC-BY-4.0", note = "Self-absorption loss coefficient alpha_L = 1.7e-2 /cm measured over 105 mm of propagation in the emission band; inverted to 588 mm. This is the Ce re-absorption channel only. Corroborated qualitatively by Mao/Zhang/Zhu 2008 (IEEE TNS 55, 1229, doi:10.1109/TNS.2008.922804), who observe the emission spectrum red-shifting with path length in a 200 mm crystal — the signature of the Ce excitation tail eating the emission blue edge." } +"optical.temperature_coefficient_light_yield" = { citation = "tully_2022", kind = "doi", ref = "10.48550/arXiv.2205.14890", license = "proprietary-reference-only", note = "-0.15 %/degC mean across producers; producer range -0.28 to -0.08 %/degC." } +"optical.hygroscopic" = { citation = "luxium_prelude420", kind = "vendor", ref = "https://luxiumsolutions.com/sites/default/files/2021-08/LYSO-Material-Data-Sheet.pdf", license = "proprietary-reference-only", note = "Non-hygroscopic — one of LYSO's main practical advantages over NaI:Tl." } +"nuclear.intrinsic_activity_Bq_per_g" = { citation = "enriquez_mier_y_teran_2020", kind = "doi", ref = "10.1186/s40658-020-00291-1", license = "CC-BY-4.0", note = "~40 Bq/g (~300 Bq/cm^3 at 7.1 g/cm^3). The 39 Bq/g figure widely quoted elsewhere is the same number derived from 2.59% 176-Lu abundance and T_1/2 = 3.76e10 yr; the ~5% spread tracks the half-life uncertainty (Kossert et al. 2023 remeasure 3.72e10 yr). Decay: beta- endpoint 593 keV, prompt gammas at 88 / 202 / 307 keV (Alva-Sanchez et al. 2018, doi:10.1038/s41598-018-35684-x, CC-BY-4.0)." } + +# --------------------------------------------------------------------------- +# Declared absences (#243, ADR-0004 §6). These are NOT gaps nobody looked at. +# Each one was searched for and is recorded here so a downstream engine can +# tell "unmeasured" from "unvisited" — including the field the strata brief +# calls its single highest-value ask. +# --------------------------------------------------------------------------- +[lyso._absent] +"optical.emission_spectrum" = { reason = "proprietary", note = "No tabulated (wavelength, intensity) LYSO:Ce emission spectrum exists in a redistributable source. The two papers that plot one — Mao/Zhang/Zhu 2008 (IEEE TNS 55, 1229, doi:10.1109/TNS.2008.922804) and Melcher & Schweitzer's original LSO characterisation — are paywalled figures, and digitising them would produce a derivative of a proprietary figure. What IS available and cited above: peak 420 nm, band centre 430 nm, FWHM 60 nm, and the two-Ce-site Gaussian decomposition from Bosca & Lopez 2023 (CC-BY). A consumer needing a curve should synthesise it from those parameters ON ITS OWN SIDE and label it as synthesised — this database will not ship a fabricated Gaussian as if it were measured." } +"optical.refractive_index_dispersion" = { reason = "proprietary", note = "Sellmeier coefficients for LYSO exist — Chen/Mao/Zhu 2011 (doi:10.1016/j.optmat.2011.10.006) fit all three principal indices, and Petrosyan et al. 2015 (doi:10.1016/j.optmat.2015.06.023) publish another — but both sit behind Elsevier paywalls and the coefficients could not be extracted. refractiveindex.info, which is CC0 and is the automated enricher's source (#164), carries neither LSO nor LYSO; verified twice. This is a genuine absence, not a licensing artifact of our tooling. Populating it requires paid access to Chen 2011." } +"optical.decay_components" = { reason = "not-measured", note = "No clean multi-exponential (tau_i, A_i) table for standard uncodoped LYSO:Ce at room temperature exists in an openly readable primary. The review literature repeats 'fast 20-30 ns at 10-40%, slow ~43 ns at 60-90%' but it could not be tied to a single measurement paper. Related primaries that are NOT the same material or condition: ter Weele/Schaart/Dorenbos 2014 (doi:10.1186/2197-7364-1-S1-A10) for Ca-codoped LSO:Ce, and Jary et al. 2015 (doi:10.1002/pssb.201451234) for a microsecond-scale thermally-stimulated component. The single-exponential decay_time above is the honest representation of what is published." } +"optical.absorption_length_matrix" = { reason = "not-measured", note = "The host-matrix loss channel with cerium subtracted has no clean measurement. Undoped LSO/LYSO transmits down to a ~200 nm cutoff, so the matrix contribution at 420-500 nm is expected to be small relative to the Ce self-absorption recorded above — but 'expected to be small' is not a number. Declared absent rather than back-computed from absorption_length minus absorption_length_reabs, which would silently promote a Monte-Carlo convention into a measurement (ADR-0004 §5). PRIORITY NOTE: a downstream 2D sweep shows absorption length matters far more at realistic reflector reflectivity than a standalone sensitivity suggested (+53% at R=0.999 against +17% at R=0.97), so closing this gap is worth more than it first appeared. It still needs a measurement that does not exist; this records the raised value of finding one." } +"optical.intrinsic_resolution_pct_at_662keV" = { reason = "proprietary", note = "LYSO:Ce-specific extracted intrinsic resolution exists but only behind paywalls: Wanarak, Chewpraditkul & Phunpueok, Procedia Engineering 32, 765-771 (2012), doi:10.1016/j.proeng.2012.02.010, and Sreebunpeng et al., Radiat. Meas. 125, 73-77 (2019), doi:10.1016/j.radmeas.2019.02.002. The nearest citable figure in this database is 7.7 +/- 1.0% on `lso.Ce`, and LYSO is expected to be BETTER, not equal - Wanarak measures total resolution 8.2% for LYSO:Ce against 10.6% for LSO:Ce at 662 keV, so the LYSO residual should sit below the LSO one. USING THE LSO VALUE FOR LYSO WOULD BE BIASED HIGH. Note also that R_int is a derived quantity, not a measured one: it is the residual after subtracting an ASSUMED photostatistical term, so a literature R_int and one extracted from your own detector are only comparable if the light-collection assumptions match. Chewpraditkul's LSO figure comes from a 10x10x5 mm crystal on a PMT with N_pe = 6610; a small PET crystal on a SiPM is a different regime entirely." } +"optical.intrinsic_resolution_pct_at_511keV" = { reason = "not-measured", note = "No primary source at 511 keV for LYSO:Ce or LSO:Ce - see the same declaration on `lso.Ce` for the full reasoning. R_int is energy-dependent, so the 662 keV figure is not a substitute." } +"optical.reemit_qe" = { reason = "not-measured", note = "No published scalar for the probability that a REABSORBED scintillation photon is re-emitted. The number frequently pressed into this role is Bosca & Lopez 2023's absolute photoluminescence quantum yield, PLQY = 0.51 at 365 nm excitation (CC-BY, doi:10.1038/s41598-023-32689-z) — but PLQY under external UV pumping is not the same physical quantity as re-emission efficiency following self-absorption within the emission band, and the substitution is an assumption, not a measurement. A consumer that needs a value should adopt 0.51 explicitly as an assumption anchored to that paper, on its own side, where the assumption is visible in the run config." } [lyso.vis] base_color = [0.0, 1.0, 1.0, 0.85] metallic = 0.0 @@ -37,7 +106,29 @@ formula = "Lu1.8Y0.2SiO5:Ce" [lyso.Ce.optical] light_yield = 33000 decay_time = 41 +dopant = "Ce" dopant_pct = 0.1 +# 72 ps rise, measured under 511 keV excitation with a solid-state photon +# counter. Short relative to the 41 ns decay, but it is the term that sets the +# leading edge of the timing distribution, so a CTR calculation needs it. +rise_time = 0.072 + +[lyso.Ce._sources] +"optical.rise_time" = { citation = "seifert_2012", kind = "doi", ref = "10.1088/1748-0221/7/09/P09004", license = "CC-BY-3.0", note = "Seifert et al., JINST 7 P09004 (2012). Same measurement reports a 43 ns single-exponential decay under 511 keV excitation, consistent with the 41 ns photoluminescence lifetime above." } +"optical.dopant_pct" = { citation = "luxium_prelude420", kind = "vendor", ref = "https://luxiumsolutions.com/sites/default/files/2021-08/LYSO-Material-Data-Sheet.pdf", license = "proprietary-reference-only", note = "Nominal Ce doping; producers vary and rarely publish exact concentrations. Tully 2022 (arXiv:2205.14890) finds ~8% relative spread in light output across 8 producers, of which doping variation is one contributor." } + +# LYSO:Ce with a polished crystal face. A treatment is a substance fact — the +# face was prepared this way — and everything else inherits (#243). +# +# Deliberately NOT carrying a `default_surface` key. Which finish a polished +# crystal is then WRAPPED in (ESR with grease? Teflon with an air gap?) is a +# fact about a detector somebody built, not about the crystal, and it would +# inherit down to every vendor variant below as a wrapping choice nobody made. +# The surface catalogue in surfaces.toml names the finishes; pairing a material +# with a finish is the consuming engine's job. See ADR-0004 §9. +[lyso.Ce.polished] +name = "LYSO:Ce, polished" +treatment = "polished" # Saint-Gobain [lyso.Ce.saint_gobain] @@ -49,8 +140,15 @@ name = "Saint-Gobain PreLude 420 LYSO:Ce" vendor = "saint_gobain" [lyso.Ce.saint_gobain.prelude420.optical] -light_yield = 34000 +# Corrected 34000 -> 33200 in #243: the Luxium (formerly Saint-Gobain Crystals) +# PreLude 420 data sheet states 33200 ph/MeV. 34000 was an uncited round-up. +light_yield = 33200 decay_time = 41 +refractive_index = 1.81 + +[lyso.Ce.saint_gobain.prelude420._sources] +"optical.light_yield" = { citation = "luxium_prelude420", kind = "vendor", ref = "https://luxiumsolutions.com/sites/default/files/2021-08/LYSO-Material-Data-Sheet.pdf", license = "proprietary-reference-only", note = "33200 ph/MeV. Saint-Gobain Crystals was rebranded Luxium Solutions in 2022; PreLude 420 is the same product line." } +"optical.refractive_index" = { citation = "luxium_prelude420", kind = "vendor", ref = "https://luxiumsolutions.com/sites/default/files/2021-08/LYSO-Material-Data-Sheet.pdf", license = "proprietary-reference-only", note = "1.81 at the 420 nm emission peak — the vendor's own figure, slightly below the 1.82 generic scalar on the parent." } # Epic Crystal [lyso.Ce.epic] @@ -76,10 +174,26 @@ density_value = 7.13 density_unit = "g/cm^3" [bgo.optical] +refractive_index_dispersion = {wavelengths_nm = [305.0, 312.4815, 320.1465, 327.9996, 336.0453, 344.2883, 352.7336, 361.386, 370.2506, 379.3327, 388.6376, 398.1707, 407.9376, 417.9441, 428.1961, 438.6996, 449.4607, 460.4858, 471.7813, 483.3539, 495.2103, 507.3576, 519.8029, 532.5534, 545.6167, 559.0005, 572.7125, 586.7609, 601.1539, 615.8999, 631.0077, 646.486, 662.344, 678.591, 695.2366, 712.2904, 729.7626, 747.6633, 766.0032, 784.7929, 804.0435, 823.7664, 843.973, 864.6753, 885.8854, 907.6158, 929.8792, 952.6887, 976.0577, 1000.0], n = [2.410454, 2.385429, 2.362493, 2.341416, 2.322001, 2.304077, 2.287495, 2.272127, 2.257859, 2.244592, 2.232237, 2.220716, 2.209958, 2.199903, 2.190492, 2.181677, 2.173412, 2.165655, 2.158369, 2.15152, 2.145077, 2.139012, 2.133299, 2.127915, 2.122837, 2.118046, 2.113523, 2.109251, 2.105214, 2.101398, 2.097789, 2.094375, 2.091144, 2.088085, 2.085188, 2.082444, 2.079844, 2.077379, 2.075043, 2.072827, 2.070725, 2.068731, 2.066839, 2.065044, 2.063339, 2.06172, 2.060183, 2.058723, 2.057337, 2.056019]} refractive_index = 2.15 light_yield = 8500 decay_time = 300 emission_peak = 480 +emission_range = [375, 650] +hygroscopic = false +# DO NOT hand-author `refractive_index_dispersion` here — `bgo` is inside the +# enricher's scope; see the header of this file and ADR-0004 §10. +# +# The data exists and is CC0: Williams et al., Appl. Opt. 35, 3562-3569 (1996), +# doi:10.1364/AO.35.003562, Sellmeier n^2 - 1 = 3.1218393*L^2/(L^2 - 0.1807^2) +# for L in um over 0.305-1.0 um, republished by refractiveindex.info under CC0. +# It gives n = 2.177 at 420 nm and 2.080 at the 480 nm emission peak — note +# that the 2.15 scalar above sits between them, so a monochromatic-at-peak +# simulation using it is off by ~3% in n at the peak. +# +# THE ACTION IS TO RUN THE ENRICHER, not to type the numbers: +# python scripts/enrich_from_refractiveindex.py --key bgo --dry-run +# python scripts/enrich_from_refractiveindex.py --key bgo --write [bgo.nuclear] radiation_length = 1.12 @@ -113,6 +227,7 @@ refractive_index = 1.85 light_yield = 38000 decay_time = 230 emission_peak = 410 +refractive_index_dispersion = {wavelengths_nm = [250.0, 277.2822, 307.5418, 341.1035, 378.3277, 419.6142, 465.4063, 516.1956, 572.5275, 635.0068, 704.3044, 781.1644, 866.4121, 960.9627, 1065.8316, 1182.1446, 1311.1508, 1454.2353, 1612.9345, 1788.9524, 1984.1789, 2200.7102, 2440.8714, 2707.2411, 3002.6795, 3330.3588, 3693.7973, 4096.8975, 4543.9876, 5039.8682, 5589.8638, 6199.8797, 6876.4661, 7626.8876, 8459.2018, 9382.3456, 10406.2311, 11541.8521, 12801.4023, 14198.4059, 15747.863, 17466.4107, 19372.5017, 21486.6025, 23831.4128, 26432.1098, 29316.6181, 32515.9098, 36064.3369, 40000.0], n = [2.080304, 1.983085, 1.919381, 1.875156, 1.843232, 1.81954, 1.801597, 1.787798, 1.77706, 1.768628, 1.761957, 1.756648, 1.752401, 1.748989, 1.746236, 1.744003, 1.742185, 1.740693, 1.739458, 1.738425, 1.737546, 1.736782, 1.7361, 1.735468, 1.73486, 1.734249, 1.733609, 1.73291, 1.732123, 1.731214, 1.730142, 1.728859, 1.727309, 1.725421, 1.72311, 1.720268, 1.716762, 1.712425, 1.707043, 1.700344, 1.691975, 1.681478, 1.668243, 1.651449, 1.629965, 1.602191, 1.565789, 1.517187, 1.45061, 1.355947]} [nai.nuclear] radiation_length = 2.59 @@ -134,6 +249,7 @@ light_yield = 38000 decay_time = 230 dopant = "Tl" dopant_pct = 0.05 +refractive_index_dispersion = {wavelengths_nm = [250.0, 277.2822, 307.5418, 341.1035, 378.3277, 419.6142, 465.4063, 516.1956, 572.5275, 635.0068, 704.3044, 781.1644, 866.4121, 960.9627, 1065.8316, 1182.1446, 1311.1508, 1454.2353, 1612.9345, 1788.9524, 1984.1789, 2200.7102, 2440.8714, 2707.2411, 3002.6795, 3330.3588, 3693.7973, 4096.8975, 4543.9876, 5039.8682, 5589.8638, 6199.8797, 6876.4661, 7626.8876, 8459.2018, 9382.3456, 10406.2311, 11541.8521, 12801.4023, 14198.4059, 15747.863, 17466.4107, 19372.5017, 21486.6025, 23831.4128, 26432.1098, 29316.6181, 32515.9098, 36064.3369, 40000.0], n = [2.080304, 1.983085, 1.919381, 1.875156, 1.843232, 1.81954, 1.801597, 1.787798, 1.77706, 1.768628, 1.761957, 1.756648, 1.752401, 1.748989, 1.746236, 1.744003, 1.742185, 1.740693, 1.739458, 1.738425, 1.737546, 1.736782, 1.7361, 1.735468, 1.73486, 1.734249, 1.733609, 1.73291, 1.732123, 1.731214, 1.730142, 1.728859, 1.727309, 1.725421, 1.72311, 1.720268, 1.716762, 1.712425, 1.707043, 1.700344, 1.691975, 1.681478, 1.668243, 1.651449, 1.629965, 1.602191, 1.565789, 1.517187, 1.45061, 1.355947]} [nai.Tl.saint_gobain] name = "Saint-Gobain NaI(Tl)" @@ -159,6 +275,7 @@ density_unit = "g/cm^3" [csi.optical] refractive_index = 1.95 emission_peak = 420 +refractive_index_dispersion = {wavelengths_nm = [250.0, 280.2165, 314.0853, 352.0475, 394.5982, 442.2918, 495.7499, 555.6693, 622.8309, 698.1101, 782.4881, 877.0644, 983.0719, 1101.892, 1235.0735, 1384.3521, 1551.6735, 1739.2184, 1949.4311, 2185.0514, 2449.1503, 2745.1698, 3076.968, 3448.8694, 3865.7211, 4332.9561, 4856.664, 5443.6706, 6101.6263, 6839.1067, 7665.7235, 8592.2503, 9630.7629, 10794.7966, 12099.5225, 13561.9458, 15201.1266, 17038.4289, 19097.799, 21406.0773, 23993.3484, 26893.3332, 30143.828, 33787.1978, 37870.9279, 42448.2429, 47578.8005, 53329.4691, 59775.1991, 67000.0], n = [2.209377, 2.038658, 1.948334, 1.892185, 1.854323, 1.827527, 1.807959, 1.793356, 1.782289, 1.773805, 1.767246, 1.76214, 1.758146, 1.755007, 1.752532, 1.750574, 1.749019, 1.74778, 1.746787, 1.745986, 1.745334, 1.744795, 1.744339, 1.743944, 1.743589, 1.743253, 1.74292, 1.742572, 1.742191, 1.741757, 1.741246, 1.740631, 1.739881, 1.738954, 1.737801, 1.736359, 1.734549, 1.73227, 1.729393, 1.725753, 1.721134, 1.715256, 1.707747, 1.698107, 1.685658, 1.669456, 1.648153, 1.61976, 1.581219, 1.527552]} [csi.nuclear] radiation_length = 1.86 @@ -176,6 +293,7 @@ name = "CsI(Tl)" formula = "CsI:Tl" [csi.Tl.optical] +refractive_index_dispersion = {wavelengths_nm = [250.0, 280.2165, 314.0853, 352.0475, 394.5982, 442.2918, 495.7499, 555.6693, 622.8309, 698.1101, 782.4881, 877.0644, 983.0719, 1101.892, 1235.0735, 1384.3521, 1551.6735, 1739.2184, 1949.4311, 2185.0514, 2449.1503, 2745.1698, 3076.968, 3448.8694, 3865.7211, 4332.9561, 4856.664, 5443.6706, 6101.6263, 6839.1067, 7665.7235, 8592.2503, 9630.7629, 10794.7966, 12099.5225, 13561.9458, 15201.1266, 17038.4289, 19097.799, 21406.0773, 23993.3484, 26893.3332, 30143.828, 33787.1978, 37870.9279, 42448.2429, 47578.8005, 53329.4691, 59775.1991, 67000.0], n = [2.209377, 2.038658, 1.948334, 1.892185, 1.854323, 1.827527, 1.807959, 1.793356, 1.782289, 1.773805, 1.767246, 1.76214, 1.758146, 1.755007, 1.752532, 1.750574, 1.749019, 1.74778, 1.746787, 1.745986, 1.745334, 1.744795, 1.744339, 1.743944, 1.743589, 1.743253, 1.74292, 1.742572, 1.742191, 1.741757, 1.741246, 1.740631, 1.739881, 1.738954, 1.737801, 1.736359, 1.734549, 1.73227, 1.729393, 1.725753, 1.721134, 1.715256, 1.707747, 1.698107, 1.685658, 1.669456, 1.648153, 1.61976, 1.581219, 1.527552]} light_yield = 54000 decay_time = 1000 dopant = "Tl" @@ -187,6 +305,7 @@ name = "CsI(Na)" formula = "CsI:Na" [csi.Na.optical] +refractive_index_dispersion = {wavelengths_nm = [250.0, 280.2165, 314.0853, 352.0475, 394.5982, 442.2918, 495.7499, 555.6693, 622.8309, 698.1101, 782.4881, 877.0644, 983.0719, 1101.892, 1235.0735, 1384.3521, 1551.6735, 1739.2184, 1949.4311, 2185.0514, 2449.1503, 2745.1698, 3076.968, 3448.8694, 3865.7211, 4332.9561, 4856.664, 5443.6706, 6101.6263, 6839.1067, 7665.7235, 8592.2503, 9630.7629, 10794.7966, 12099.5225, 13561.9458, 15201.1266, 17038.4289, 19097.799, 21406.0773, 23993.3484, 26893.3332, 30143.828, 33787.1978, 37870.9279, 42448.2429, 47578.8005, 53329.4691, 59775.1991, 67000.0], n = [2.209377, 2.038658, 1.948334, 1.892185, 1.854323, 1.827527, 1.807959, 1.793356, 1.782289, 1.773805, 1.767246, 1.76214, 1.758146, 1.755007, 1.752532, 1.750574, 1.749019, 1.74778, 1.746787, 1.745986, 1.745334, 1.744795, 1.744339, 1.743944, 1.743589, 1.743253, 1.74292, 1.742572, 1.742191, 1.741757, 1.741246, 1.740631, 1.739881, 1.738954, 1.737801, 1.736359, 1.734549, 1.73227, 1.729393, 1.725753, 1.721134, 1.715256, 1.707747, 1.698107, 1.685658, 1.669456, 1.648153, 1.61976, 1.581219, 1.527552]} light_yield = 41000 decay_time = 630 dopant = "Na" @@ -384,11 +503,28 @@ density_unit = "g/cm^3" [lso.Ce.optical] decay_time = 42 hygroscopic = false +# Intrinsic (non-statistical) energy resolution, FWHM %, at the 662 keV 137Cs +# photopeak. READ THE SOURCE NOTE BEFORE COMPARING THIS TO YOUR OWN NUMBER. +# R_int is not measured — it is what REMAINS after an assumed photostatistical +# term is subtracted in quadrature, so it inherits whatever the authors assumed +# for light collection. Two labs can report different R_int for the same +# crystal purely by using different photodetectors. +intrinsic_resolution_pct_at_662keV = { nominal = 7.7, stddev = 1.0 } +# Photon non-proportionality: LSO:Ce retains ~57% of its 662 keV light yield at +# 10 keV, i.e. ~43% down. This is the physical cause of R_int. +non_proportionality = 43.0 decay_components = [ { tau_ns = 12.0, fraction = 0.30 }, { tau_ns = 42.0, fraction = 0.70 }, ] +[lso.Ce._sources] +"optical.intrinsic_resolution_pct_at_662keV" = { citation = "chewpraditkul_moszynski_2011", kind = "doi", ref = "10.1016/j.phpro.2011.11.035", license = "proprietary-reference-only", note = "Chewpraditkul & Moszynski, Phys. Procedia 22, 218-226 (2011). LSO:Ce 10x10x5 mm, Crytur, XP5500B PMT. EXTRACTION METHOD, from which the value is inseparable: measured total dE/E = 8.3 +/- 0.3% at 662 keV with N_pe = 6610 photoelectrons; statistical term 2.355*sqrt(1+eps)/sqrt(N_pe) with PMT variance eps = 0.1 gives 3.0%; transfer term assumed zero; the 7.7% residual is attributed to the scintillator. Reproduced from those inputs as 7.72%. THE STDDEV OF 1.0 IS NOT THE PAPER ERROR BAR - the paper quotes +/-0.3 on the TOTAL, not the residual. It is an honest widening for two effects the paper cannot capture: extraction-assumption sensitivity (a different photodetector changes N_pe, hence the statistical term, hence the residual), and sample-to-sample spread, for which Khodyuk & Dorenbos 2012 Table I gives published LSO:Ce total resolution spanning 7.9-11.9% across five references. Paper is open access under CC BY-NC-ND; recorded as proprietary-reference-only because NC-ND is not in this repo redistributable set, so the number is cited rather than the table reproduced." } +"optical.non_proportionality" = { citation = "khodyuk_dorenbos_2012", kind = "doi", ref = "10.1109/TNS.2012.2221094", license = "proprietary-reference-only", note = "Khodyuk & Dorenbos, IEEE TNS 59 (2012), Table I: LSO:Ce photon non-proportional response at 10 keV is 56.7% of the 662 keV yield, i.e. 43% down; sigma_photon-nPR = 8.40%. Open-access preprint arXiv:1204.4350. The highest-precision underlying electron-response measurement is Khodyuk, de Haas & Dorenbos, doi:10.1109/TNS.2011.2175214 (preprint arXiv:1101.4485), resolving 9-100 keV in 25 eV steps plus K-dip spectroscopy to ~100 eV - but that exists only as figures, so no table is reproduced here." } + +[lso.Ce._absent] +"optical.intrinsic_resolution_pct_at_511keV" = { reason = "not-measured", note = "NO PRIMARY SOURCE REPORTS AN EXTRACTED INTRINSIC RESOLUTION AT 511 keV for LSO:Ce or LYSO:Ce. Extraction is conventionally done at the 662 keV 137Cs photopeak, where the statistical term is smallest and the subtraction most stable. R_int IS energy-dependent - it falls from roughly 15-20% near 16.6 keV to ~7.7% at 662 keV, driven by non-proportionality - so a 511 keV value is not simply the 662 keV one. It can only be read off published curves (Chewpraditkul 2011 Fig. 3; Wanarak et al. 2012 Fig. 3, doi:10.1016/j.proeng.2012.02.010), which are figures behind a paywall, not tables. Reading a value off such a figure would produce a number with the appearance of a citation and none of the substance. Interpolation suggests roughly 7.5-8.5% for LSO:Ce; that is an inference, and it is recorded in this note rather than in the field." } + # ============================================================================ # LaBr3:Ce (5%) — production high-resolution scintillator (#122) @@ -564,6 +700,15 @@ license = "proprietary-reference-only" # --- BGO --------------------------------------------------------------------- +[bgo._sources] +"optical.emission_range" = { citation = "luxium_bgo", kind = "vendor", ref = "https://luxiumsolutions.com/radiation-detection-scintillators/crystal-scintillators/bgo-bismuth-germanate", license = "proprietary-reference-only", note = "Practical band support around the 480 nm peak. Neither the Luxium nor the Berkeley Nucleonics BGO datasheet states an emission FWHM — see _absent." } +"optical.hygroscopic" = { citation = "luxium_bgo", kind = "vendor", ref = "https://luxiumsolutions.com/radiation-detection-scintillators/crystal-scintillators/bgo-bismuth-germanate", license = "proprietary-reference-only", note = "Non-hygroscopic." } +"optical.refractive_index_dispersion" = {citation = "Williams 1996 (BGO ord. ray) via refractiveindex.info", kind = "doi", ref = "refractiveindex.info:main/Bi4Ge3O12/nk/Williams", license = "CC0", note = "n,k tabulated 50 rows, 305-1000 nm, fetched 2026-08-14"} + +[bgo._absent] +"optical.emission_spectrum" = { reason = "not-measured", note = "Same situation as LYSO: peak (480 nm) is universally published, a tabulated (wavelength, intensity) curve is not, and neither vendor datasheet states an FWHM. Unlike LYSO, BGO's refractive-index dispersion IS available under CC0 (Williams et al. 1996, doi:10.1364/AO.35.003562) — but it is the automated enricher's to write, not this file's. See the comment on [bgo.optical]." } +"optical.decay_components" = { reason = "not-measured", note = "BGO is dominated by a ~300 ns component with a small fast contribution; one secondary source quotes 45.8 ns (8%) / 365 ns (92%) but it could not be traced to a readable primary. Left absent rather than recorded on a broken citation chain." } + [bgo._sources."optical.light_yield"] citation = "weber_monchamp_1973" kind = "doi" @@ -585,6 +730,9 @@ license = "proprietary-reference-only" # --- NaI / NaI:Tl ------------------------------------------------------------ +[nai._sources] +"optical.refractive_index_dispersion" = {citation = "Li 1976 (NaI, 297 K) via refractiveindex.info", kind = "doi", ref = "refractiveindex.info:main/NaI/nk/Li", license = "CC0", note = "n,k tabulated 50 rows, 250-40000 nm, fetched 2026-08-14"} + [nai._sources."optical.light_yield"] citation = "hofstadter_1948" kind = "doi" @@ -604,6 +752,9 @@ kind = "doi" ref = "10.1103/PhysRev.74.100" license = "proprietary-reference-only" +[nai.Tl._sources] +"optical.refractive_index_dispersion" = {citation = "Li 1976 (NaI, 297 K) via refractiveindex.info", kind = "doi", ref = "refractiveindex.info:main/NaI/nk/Li", license = "CC0", note = "n,k tabulated 50 rows, 250-40000 nm, fetched 2026-08-14"} + [nai.Tl._sources."optical.light_yield"] citation = "hofstadter_1948" kind = "doi" @@ -630,6 +781,9 @@ license = "proprietary-reference-only" # --- CsI / CsI:Tl / CsI:Na --------------------------------------------------- +[csi._sources] +"optical.refractive_index_dispersion" = {citation = "Li 1976 (CsI) via refractiveindex.info", kind = "doi", ref = "refractiveindex.info:main/CsI/nk/Li", license = "CC0", note = "n,k tabulated 50 rows, 250-67000 nm, fetched 2026-08-14"} + [csi._sources."optical.light_yield"] citation = "holl_lorenz_mageras_1988" kind = "doi" @@ -643,6 +797,9 @@ kind = "doi" ref = "10.1109/23.12684" license = "proprietary-reference-only" +[csi.Tl._sources] +"optical.refractive_index_dispersion" = {citation = "Li 1976 (CsI) via refractiveindex.info", kind = "doi", ref = "refractiveindex.info:main/CsI/nk/Li", license = "CC0", note = "n,k tabulated 50 rows, 250-67000 nm, fetched 2026-08-14"} + [csi.Tl._sources."optical.light_yield"] citation = "holl_lorenz_mageras_1988" kind = "doi" @@ -658,6 +815,9 @@ license = "proprietary-reference-only" # CsI:Na — Holl 1988 covered CsI(Tl), not CsI(Na). No verified primary cite at # this time; keep the audit placeholder. TODO(#170): locate primary CsI:Na # scintillation paper (Hofstadter et al. 1950s era? Brinckmann?) and replace. +[csi.Na._sources] +"optical.refractive_index_dispersion" = {citation = "Li 1976 (CsI) via refractiveindex.info", kind = "doi", ref = "refractiveindex.info:main/CsI/nk/Li", license = "CC0", note = "n,k tabulated 50 rows, 250-67000 nm, fetched 2026-08-14"} + [csi.Na._sources._default] citation = "py-mat-curation-3.x" kind = "handbook" diff --git a/src/pymat/data/surfaces.toml b/src/pymat/data/surfaces.toml new file mode 100644 index 0000000..b53e14b --- /dev/null +++ b/src/pymat/data/surfaces.toml @@ -0,0 +1,391 @@ +# ============================================================================ +# surfaces.toml — measured optical surface finishes (#243, ADR-0004) +# ============================================================================ +# +# A surface here is an INTERFACE, not a bulk. Each entry describes a measured +# crystal-face / reflector / coupling triple: what treatment the face had, what +# reflector sat against it, and what filled the gap between them. +# +# Scope is deliberately narrow — MEASURED interfaces only. Every entry below +# comes from the Geant4 `G4RealSurface` 2.2 data set: 21 LBNL look-up tables +# (Janecek & Moses 2010) and 9 DAVIS look-up tables (Roncali & Cherry 2013). +# +# NOT here, on purpose: +# +# * Geant4's six analytic UNIFIED/GLISUR finishes — `polished`, `ground`, +# `polishedfrontpainted`, `polishedbackpainted`, `groundfrontpainted`, +# `groundbackpainted`. These ship no data file. They are model selections +# parameterised by a fitted `sigma_alpha`, so they are run-time policy for +# the consuming engine, not measurements (ADR-0004 §3). +# * The three bare-surface LBNL enum members `polishedair`, `etchedair` and +# `groundair`. They ARE `dielectric_LUT` enum values, but `ReadLUTFile()` +# finds no `.dat` for them — there is no measurement behind the name. They +# are named here in this comment so the omission is greppable rather than +# silent. (24 LBNL LUT enum members - 3 unmeasured = the 21 below.) +# * Assignments. Which face of which crystal carries which finish is a fact +# about a built detector, not about matter. +# +# `lut_surface` is the exact `G4OpticalSurfaceFinish` enum spelling, transcribed +# verbatim from `source/materials/include/G4OpticalSurface.hh`. The casing is +# inconsistent between families (`polishedvm2000glue` vs `PolishedESRGrease_LUT`) +# because Geant4's is. Do not normalise it — downstream consumers match on it. +# +# `coupling` vocabulary: +# air_gap — a reflector is present, with air between it and the face. +# The photon meets a crystal->air Fresnel step first: large +# index contrast, small critical angle, strong TIR +# light-piping. This is the mechanism DOI designs exploit. +# optical_contact — the gap is index-filled (grease, glue, meltmount). The +# photon meets the polymer directly. `coupling_index` is +# then required: the index of the filler is the entire +# physical difference between the two cases. +# none — no reflector at all; a bare face against the ambient. +# +# Note (Kang et al., NIM A 2017, doi:10.1016/j.nima.2017.02.032): 3M ESR is a +# multilayer interference stack designed for an air interface, so wet-coupling +# it measurably lowers its effective reflectivity. That is a second, independent +# reason the `*_air` and `*_glue`/`*Grease` variants are separate measurements +# and must not be treated as one surface with a different gap material. + +# ============================================================================ +# LBNL / Janecek look-up tables — 21 measured surfaces +# ============================================================================ +# 3 face treatments x 7 wrappings. Measured at LBNL on a 2-pi silicon +# photodiode array; the family citation and data-set identity live on this +# abstract parent and flow down to all 21 children. + +[surface.lbnl] +abstract = true +model = "lut" +lut_family = "lbnl" +g4_surface_type = "dielectric_LUT" +lut_dataset = "G4RealSurface-2.2" + +[surface.lbnl._sources] +_default = { citation = "geant4_realsurface_2_2", kind = "handbook", ref = "Geant4 G4RealSurface 2.2 data set; https://cern.ch/geant4-data/datasets/G4RealSurface.2.2.tar.gz", license = "Geant4-SL", note = "Enum spellings transcribed from source/materials/include/G4OpticalSurface.hh. Data set located at runtime via $G4REALSURFACEDATA." } +lut_surface = { citation = "geant4_optical_surface_hh", kind = "handbook", ref = "Geant4 source/materials/include/G4OpticalSurface.hh, G4OpticalSurfaceFinish enum", license = "Geant4-SL", note = "Verbatim enum spelling; stable across Geant4 10.3-11.1.3." } +coupling_index = { citation = "cargille_meltmount_1582", kind = "vendor", ref = "https://www.cargille.com/ — Cargille Meltmount, n_D = 1.582 (code 5870)", license = "proprietary-reference-only", note = "Meltmount is the removable thermoplastic mountant used for the LBNL glue-coupled reflectance measurements (see Janecek's reflectance-apparatus report, OSTI 952861); it lets the same crystal be re-wrapped with different reflectors. CAVEAT: 1.582 is the standard grade, but the LBNL papers do not tabulate the grade per measured surface, and Cargille also sells 1.539 and 1.605. Treat as the family value, not a per-surface measurement." } +lut_family = { citation = "janecek_moses_2010", kind = "doi", ref = "10.1109/TNS.2010.2042731", license = "proprietary-reference-only", note = "M. Janecek, W.W. Moses, 'Simulating Scintillator Light Collection Using Measured Optical Reflectance', IEEE TNS 57(3) 964-970 (2010). Cited, not redistributed." } + +[surface.lbnl._absent] +reflectivity = { reason = "not-applicable", note = "A LUT entry IS the angular reflectance distribution. Collapsing it to a scalar reflectivity would discard the angular dependence that is the entire reason the table was measured." } + +# --- polished face ---------------------------------------------------------- + +[surface.lbnl.polished] +abstract = true +treatment = "polished" + +[surface.lbnl.polished.lumirror_air] +name = "Polished / Lumirror, air gap (LBNL)" +lut_surface = "polishedlumirrorair" +reflector = "Lumirror (Toray biaxially-oriented white PET)" +coupling = "air_gap" +note = "Lumirror is a Toray voided/pigmented polyester film and reflects diffusely. It is NOT 3M ESR — see the vm2000 entries for the specular multilayer." + +[surface.lbnl.polished.lumirror_glue] +name = "Polished / Lumirror, meltmount-coupled (LBNL)" +lut_surface = "polishedlumirrorglue" +reflector = "Lumirror (Toray biaxially-oriented white PET)" +coupling = "optical_contact" +coupling_index = 1.582 + +[surface.lbnl.polished.teflon_air] +name = "Polished / Teflon, air gap (LBNL)" +lut_surface = "polishedteflonair" +reflector = "PTFE (Teflon) tape" +reflector_material = "ptfe" +coupling = "air_gap" + +[surface.lbnl.polished.tio_air] +name = "Polished / TiO2 paint, air gap (LBNL)" +lut_surface = "polishedtioair" +reflector = "TiO2 reflective paint" +coupling = "air_gap" + +[surface.lbnl.polished.tyvek_air] +name = "Polished / Tyvek, air gap (LBNL)" +lut_surface = "polishedtyvekair" +reflector = "Tyvek (DuPont flash-spun HDPE)" +coupling = "air_gap" + +[surface.lbnl.polished.vm2000_air] +name = "Polished / 3M ESR (VM2000), air gap (LBNL)" +lut_surface = "polishedvm2000air" +reflector = "3M ESR / Vikuiti (formerly VM2000)" +reflector_material = "esr" +coupling = "air_gap" + +[surface.lbnl.polished.vm2000_glue] +name = "Polished / 3M ESR (VM2000), meltmount-coupled (LBNL)" +lut_surface = "polishedvm2000glue" +reflector = "3M ESR / Vikuiti (formerly VM2000)" +reflector_material = "esr" +coupling = "optical_contact" +coupling_index = 1.582 + +# --- etched face ------------------------------------------------------------ + +[surface.lbnl.etched] +abstract = true +treatment = "etched" + +[surface.lbnl.etched.lumirror_air] +name = "Etched / Lumirror, air gap (LBNL)" +lut_surface = "etchedlumirrorair" +reflector = "Lumirror (Toray biaxially-oriented white PET)" +coupling = "air_gap" + +[surface.lbnl.etched.lumirror_glue] +name = "Etched / Lumirror, meltmount-coupled (LBNL)" +lut_surface = "etchedlumirrorglue" +reflector = "Lumirror (Toray biaxially-oriented white PET)" +coupling = "optical_contact" +coupling_index = 1.582 + +[surface.lbnl.etched.teflon_air] +name = "Etched / Teflon, air gap (LBNL)" +lut_surface = "etchedteflonair" +reflector = "PTFE (Teflon) tape" +reflector_material = "ptfe" +coupling = "air_gap" + +[surface.lbnl.etched.tio_air] +name = "Etched / TiO2 paint, air gap (LBNL)" +lut_surface = "etchedtioair" +reflector = "TiO2 reflective paint" +coupling = "air_gap" + +[surface.lbnl.etched.tyvek_air] +name = "Etched / Tyvek, air gap (LBNL)" +lut_surface = "etchedtyvekair" +reflector = "Tyvek (DuPont flash-spun HDPE)" +coupling = "air_gap" + +[surface.lbnl.etched.vm2000_air] +name = "Etched / 3M ESR (VM2000), air gap (LBNL)" +lut_surface = "etchedvm2000air" +reflector = "3M ESR / Vikuiti (formerly VM2000)" +reflector_material = "esr" +coupling = "air_gap" + +[surface.lbnl.etched.vm2000_glue] +name = "Etched / 3M ESR (VM2000), meltmount-coupled (LBNL)" +lut_surface = "etchedvm2000glue" +reflector = "3M ESR / Vikuiti (formerly VM2000)" +reflector_material = "esr" +coupling = "optical_contact" +coupling_index = 1.582 + +# --- ground (rough-cut) face ------------------------------------------------ + +[surface.lbnl.ground] +abstract = true +treatment = "ground" + +[surface.lbnl.ground.lumirror_air] +name = "Ground / Lumirror, air gap (LBNL)" +lut_surface = "groundlumirrorair" +reflector = "Lumirror (Toray biaxially-oriented white PET)" +coupling = "air_gap" + +[surface.lbnl.ground.lumirror_glue] +name = "Ground / Lumirror, meltmount-coupled (LBNL)" +lut_surface = "groundlumirrorglue" +reflector = "Lumirror (Toray biaxially-oriented white PET)" +coupling = "optical_contact" +coupling_index = 1.582 + +[surface.lbnl.ground.teflon_air] +name = "Ground / Teflon, air gap (LBNL)" +lut_surface = "groundteflonair" +reflector = "PTFE (Teflon) tape" +reflector_material = "ptfe" +coupling = "air_gap" + +[surface.lbnl.ground.tio_air] +name = "Ground / TiO2 paint, air gap (LBNL)" +lut_surface = "groundtioair" +reflector = "TiO2 reflective paint" +coupling = "air_gap" + +[surface.lbnl.ground.tyvek_air] +name = "Ground / Tyvek, air gap (LBNL)" +lut_surface = "groundtyvekair" +reflector = "Tyvek (DuPont flash-spun HDPE)" +coupling = "air_gap" + +[surface.lbnl.ground.vm2000_air] +name = "Ground / 3M ESR (VM2000), air gap (LBNL)" +lut_surface = "groundvm2000air" +reflector = "3M ESR / Vikuiti (formerly VM2000)" +reflector_material = "esr" +coupling = "air_gap" + +[surface.lbnl.ground.vm2000_glue] +name = "Ground / 3M ESR (VM2000), meltmount-coupled (LBNL)" +lut_surface = "groundvm2000glue" +reflector = "3M ESR / Vikuiti (formerly VM2000)" +reflector_material = "esr" +coupling = "optical_contact" +coupling_index = 1.582 + + +# ============================================================================ +# DAVIS look-up tables — 9 measured surfaces +# ============================================================================ +# Built from AFM-scanned crystal topography convolved with Fresnel physics on +# each micro-facet, rather than from a goniometer sweep. Different method, +# different family, same catalogue shape. + +[surface.davis] +abstract = true +model = "lut" +lut_family = "davis" +g4_surface_type = "dielectric_LUTDAVIS" +lut_dataset = "G4RealSurface-2.2" + +[surface.davis._sources] +_default = { citation = "geant4_realsurface_2_2", kind = "handbook", ref = "Geant4 G4RealSurface 2.2 data set; https://cern.ch/geant4-data/datasets/G4RealSurface.2.2.tar.gz", license = "Geant4-SL", note = "Enum spellings transcribed from source/materials/include/G4OpticalSurface.hh; files read by ReadLUTDAVISFile()." } +lut_surface = { citation = "geant4_optical_surface_hh", kind = "handbook", ref = "Geant4 source/materials/include/G4OpticalSurface.hh, G4OpticalSurfaceFinish enum", license = "Geant4-SL", note = "Verbatim enum spelling; each maps to .dat in the data set." } +coupling_index = { citation = "saint_gobain_bc630", kind = "vendor", ref = "Saint-Gobain / Luxium BC-630 silicone optical grease datasheet, n = 1.465", license = "proprietary-reference-only", note = "BC-630 is the grease used for the DAVIS ESR-grease measurements. Transmittance >= 95% down to 280 nm. Also present in this database as the material `bc630`." } +lut_family = { citation = "roncali_cherry_2013", kind = "doi", ref = "10.1088/0031-9155/58/7/2185", license = "proprietary-reference-only", note = "E. Roncali, S.R. Cherry, 'Simulation of light transport in scintillators based on 3D characterization of crystal surfaces', Phys. Med. Biol. 58(7) 2185-2198 (2013). The DAVIS reflectance model itself." } +treatment = { citation = "roncali_stockhoff_cherry_2017", kind = "doi", ref = "10.1088/1361-6560/aa6ca5", license = "proprietary-reference-only", note = "'An integrated model of scintillator-reflector properties for advanced simulations of optical transport' — the work that produced the 9 DAVIS LUTs." } +g4_surface_type = { citation = "stockhoff_2017", kind = "doi", ref = "10.1088/1361-6560/aa7007", license = "proprietary-reference-only", note = "Stockhoff, Jan, Dubois, Cherry, Roncali, 'Advanced optical simulation of scintillation detectors in GATE V8.0', Phys. Med. Biol. 62(12) L1-L8 (2017). The Geant4/GATE integration that added dielectric_LUTDAVIS." } + +[surface.davis._absent] +reflectivity = { reason = "not-applicable", note = "The LUT is the reflectance distribution; a scalar would discard its angular dependence." } + +# --- rough face ------------------------------------------------------------- + +[surface.davis.rough] +name = "Rough, bare (DAVIS)" +treatment = "rough" +lut_surface = "Rough_LUT" +coupling = "none" +note = "Bare rough face against the ambient — no reflector present." + +[surface.davis.rough_teflon] +name = "Rough / Teflon, air gap (DAVIS)" +treatment = "rough" +lut_surface = "RoughTeflon_LUT" +reflector = "PTFE (Teflon) tape" +reflector_material = "ptfe" +coupling = "air_gap" + +[surface.davis.rough_esr] +name = "Rough / 3M ESR, air gap (DAVIS)" +treatment = "rough" +lut_surface = "RoughESR_LUT" +reflector = "3M ESR / Vikuiti" +reflector_material = "esr" +coupling = "air_gap" + +[surface.davis.rough_esr_grease] +name = "Rough / 3M ESR, grease-coupled (DAVIS)" +treatment = "rough" +lut_surface = "RoughESRGrease_LUT" +reflector = "3M ESR / Vikuiti" +reflector_material = "esr" +coupling = "optical_contact" +coupling_material = "bc630" +coupling_index = 1.465 + +# --- polished face ---------------------------------------------------------- + +[surface.davis.polished] +name = "Polished, bare (DAVIS)" +treatment = "polished" +lut_surface = "Polished_LUT" +coupling = "none" +note = "Bare polished face against the ambient — no reflector present." + +[surface.davis.polished_teflon] +name = "Polished / Teflon, air gap (DAVIS)" +treatment = "polished" +lut_surface = "PolishedTeflon_LUT" +reflector = "PTFE (Teflon) tape" +reflector_material = "ptfe" +coupling = "air_gap" + +[surface.davis.polished_esr] +name = "Polished / 3M ESR, air gap (DAVIS)" +treatment = "polished" +lut_surface = "PolishedESR_LUT" +reflector = "3M ESR / Vikuiti" +reflector_material = "esr" +coupling = "air_gap" + +[surface.davis.polished_esr_grease] +name = "Polished / 3M ESR, grease-coupled (DAVIS)" +treatment = "polished" +lut_surface = "PolishedESRGrease_LUT" +reflector = "3M ESR / Vikuiti" +reflector_material = "esr" +coupling = "optical_contact" +coupling_material = "bc630" +coupling_index = 1.465 + +# --- photodetector coupling ------------------------------------------------- + +[surface.davis.detector] +name = "Polished crystal / photodetector (DAVIS)" +treatment = "polished" +lut_surface = "Detector_LUT" +note = "The crystal-to-photodetector boundary rather than a crystal-to-reflector one. Which photodetector, and what window/coupling index it was measured with, is not stated in a form we can cite — see _absent below. The photodetector's own PDE and geometry are the consuming engine's business, not this catalogue's." + +[surface.davis.detector._absent] +coupling = { reason = "not-measured", note = "The DAVIS papers do not state the coupling medium for Detector_LUT in a citable form. Left unset rather than guessed: assuming optical contact would silently import a Fresnel step that may not be in the measurement." } +coupling_index = { reason = "not-measured", note = "See `coupling`." } + + +# ============================================================================ +# Non-LUT measured interfaces (#243) +# ============================================================================ +# These carry no angular look-up table, but they do carry measured, cited +# reflectance — which is the test (ADR-0004 §3), not LUT-backing. +# +# They deliberately do NOT copy R(lambda). The reflectance is a property of the +# reflector substance and lives on the material named by `reflector_material`; +# these entries add the INTERFACE facts a bulk entry cannot express: whether +# the gap is air or index-filled, and whether the reflector is diffuse or +# specular. Copying the spectrum here would create a second copy to drift. +# +# R = pymat.materials[s.reflector_material].properties.optical.reflectivity_at(420) +# +# NAMING: named for the reflector and the coupling, never for the crystal. A +# BaSO4 septum is not a LYSO fact — the Fresnel term comes from whatever +# crystal you pair it with, and pairing is the consumer's job. Naming these +# `lyso_*` would force `bgo_*`, `gagg_*`, ... each duplicating one measured +# curve. + +[surface.diffuse] +abstract = true +model = "diffuse" +coupling = "air_gap" + +[surface.diffuse.baso4_air] +name = "Crystal / pressed BaSO4, air gap" +reflector = "Pressed barium sulfate powder" +reflector_material = "baso4" +note = "The classic Lambertian white standard, and the usual inter-crystal septum in a PET block. Reflectance peaks at 0.999 over 420-470 nm — see the `baso4` material for the spectrum and, importantly, for the caveat that the cited values are pressed powder at high packing density and are an upper bound on what a real septum achieves. Kubelka-Munk scattering gives a ~17 um scattering mean free path, so a 0.2 mm layer is ~12 scattering lengths: effectively optically thick, but inside the margin. A looser-packed or binder-loaded layer at that thickness may transmit, which shows up as inter-crystal crosstalk." + +[surface.diffuse.baso4_air._sources] +reflector_material = { citation = "grum_luckey_1968", kind = "doi", ref = "10.1364/AO.7.002289", license = "proprietary-reference-only", note = "Reflectance data lives on the `baso4` material; this row records which measurement the interface is built on." } + +[surface.specular] +abstract = true +model = "specular" +coupling = "air_gap" + +[surface.specular.aluminium_air] +name = "Crystal / aluminium, air gap" +reflector = "Aluminium (evaporated or foil wrap)" +reflector_material = "aluminum" +note = "Reflectance is DERIVED from the material's CC0 n,k rather than stored, via OpticalProperties.normal_reflectance_at(lambda): 92.5% at 420 nm, 92.3% mean over 400-500 nm, with the characteristic interband dip near 800 nm. That figure is the ceiling — bulk, normal-incidence, optically thick, perfectly smooth, freshly evaporated. Air-exposed aluminium grows a native oxide and drops to ~88-89% (Hass 1955, doi:10.1364/JOSA.45.000945), and rolled foil in a real wrap is commonly quoted at 85-88% with no clean primary. The gap between 92.5% and what a given wrap achieves is a modelling assumption, and belongs in the consumer's config where it is visible." + +[surface.specular.aluminium_air._sources] +reflector_material = { citation = "rakic_1995", kind = "doi", ref = "10.1364/AO.34.004755", license = "CC0", note = "A. D. Rakic, 'Algorithm for the determination of intrinsic optical constants of metal films: application to aluminum', Appl. Opt. 34, 4755-4767 (1995). The n,k tables are redistributed CC0 by refractiveindex.info and are on the `aluminum` material; reflectance is derived from them by Fresnel, not stored separately, so the two cannot drift apart." } + +[surface.specular.aluminium_air._absent] +reflectivity = { reason = "not-measured", note = "Deliberately not a stored number. Derive it from the material's n,k with normal_reflectance_at(lambda). No measurement exists for aluminium AS WRAPPED — oxidised, rolled, struck at all angles — that we could cite; closing that gap needs an integrating-sphere measurement of the actual foil batch." } diff --git a/src/pymat/loader.py b/src/pymat/loader.py index 6730bda..d151040 100644 --- a/src/pymat/loader.py +++ b/src/pymat/loader.py @@ -26,11 +26,11 @@ from . import registry from .core import Material -from .curves import TempCurve +from .curves import TempCurve, WavelengthCurve from .properties import ( AllProperties, ) -from .sources import Source, merge_sources, parse_sources_table +from .sources import Absent, Source, merge_sources, parse_absent_table, parse_sources_table from .units import STANDARD_UNITS logger = logging.getLogger(__name__) @@ -87,6 +87,45 @@ def _parse_composition(comp: Any) -> dict[str, Any] | None: return {el: _parse_value(val) for el, val in comp.items()} +# Structured wavelength-indexed optical slots and the name of their value +# column (#243). Stored on the dataclasses as plain dicts — the on-disk shape +# predates `WavelengthCurve` and downstream JSON round-trips depend on it — +# but validated at load by building a throwaway curve, so a mismatched-length +# or unsorted spectrum raises here rather than at first query. +_WAVELENGTH_SLOTS: Dict[str, str] = { + "refractive_index_dispersion": "n", + "emission_spectrum": "intensities", + "absorption_length_spectrum": "values", + "absorption_length_matrix_spectrum": "values", + "absorption_length_reabs_spectrum": "values", + "reflectivity_spectrum": "values", + "transparency_spectrum": "values", + # Multi-column; see _MULTI_COLUMN_SLOTS for the rest. + "kubelka_munk": "k", +} + + +# Slots whose table carries MORE than one value column, and every column that +# must validate. `kubelka_munk` bundles k and s because they are parameters of +# one model and are meaningless apart. +_MULTI_COLUMN_SLOTS: Dict[str, tuple] = {"kubelka_munk": ("k", "s")} + + +def _validate_wavelength_slot(prop_name: str, key: str, value: Any) -> None: + """Raise if a structured wavelength slot is malformed. See `_WAVELENGTH_SLOTS`.""" + if not isinstance(value, dict): + raise ValueError( + f"{prop_name}.{key} must be a table of " + f"{{wavelengths_nm = [...], {_WAVELENGTH_SLOTS[key]} = [...]}}, " + f"got {type(value).__name__}: {value!r}" + ) + for column in _MULTI_COLUMN_SLOTS.get(key, (_WAVELENGTH_SLOTS[key],)): + try: + WavelengthCurve.from_toml(value, value_key=column) + except ValueError as e: + raise ValueError(f"{prop_name}.{key}: {e}") from e + + def _build_properties_from_dict( data: Dict[str, Any], parent_props: Optional[AllProperties] = None ) -> AllProperties: @@ -146,6 +185,13 @@ def assign(base_key: str, raw_value): if isinstance(raw_value, dict) and (set(raw_value) & _ufloat_keys): parsed = _parse_value(raw_value) else: + if base_key in _WAVELENGTH_SLOTS: + # A non-dict here is a scalar written where a spectrum + # belongs. Guarding on `isinstance(dict)` would skip + # validation and stuff the scalar into a dict-typed field, + # deferring the failure to the first `_at(lambda)` call — + # far from the file that caused it. + _validate_wavelength_slot(prop_name, base_key, raw_value) parsed = raw_value sibling = stddev_map.get(base_key) if sibling is not None: @@ -329,6 +375,21 @@ def _resolve_material_node( else: sources = dict(parent_sources) + # Declared absences (#243). Same parent-overlay shape as `_sources` — + # a child that measures what its parent could not simply omits the + # entry and sets the value; a child re-declaring the path wins. + parent_absent: Dict[str, Absent] = ( + parent_material._absent if parent_material is not None else {} + ) + raw_absent = data.get("_absent") + if raw_absent is not None: + if not isinstance(raw_absent, dict): + kind = type(raw_absent).__name__ + raise ValueError(f"{key}._absent must be a TOML table, got {kind}") + absent = {**parent_absent, **parse_absent_table(raw_absent)} + else: + absent = dict(parent_absent) + # Tags (#132). Multi-axial filterable labels orthogonal to the # TOML hierarchy. Children INHERIT parent tags and EXTEND at # child level — declare only what's new. Order-preserving union @@ -354,14 +415,21 @@ def _resolve_material_node( name=name, formula=formula, composition=composition, - grade=grade or parent_material.grade if parent_material else None, - temper=temper or parent_material.temper if parent_material else None, - treatment=treatment or parent_material.treatment if parent_material else None, - vendor=vendor or parent_material.vendor if parent_material else None, + # Own value first, then the parent's. The parentheses are load-bearing: + # `a or b.x if b else None` parses as `(a or b.x) if b else None`, so + # every ROOT material silently discarded its own grade/temper/treatment/ + # vendor — `pymat.beryllium.grade` was None despite the TOML declaring + # "S-200F", and `pymat.esr.vendor` was None despite "3M". Child + # materials were unaffected, which is why it went unnoticed (#243). + grade=grade or (parent_material.grade if parent_material else None), + temper=temper or (parent_material.temper if parent_material else None), + treatment=treatment or (parent_material.treatment if parent_material else None), + vendor=vendor or (parent_material.vendor if parent_material else None), properties=properties, parent=parent_material, _key=key, _sources=sources, + _absent=absent, tags=tags, ) diff --git a/src/pymat/properties.py b/src/pymat/properties.py index 950c25f..9dd218c 100644 --- a/src/pymat/properties.py +++ b/src/pymat/properties.py @@ -14,15 +14,76 @@ from __future__ import annotations +import logging +import math from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Dict, List, Optional if TYPE_CHECKING: from pint import Quantity -from .curves import TempCurve +from .curves import TempCurve, WavelengthCurve from .units import ureg +logger = logging.getLogger(__name__) + + +def _to_nm(wavelength: Any) -> float: + """Coerce a wavelength to bare nanometres. + + Accepts a Pint `Quantity` (converted, so `500 * ureg.nm` and + `0.5 * ureg.micrometer` both work) or a bare number, which is + interpreted as nm — the schema spells every wavelength column + `wavelengths_nm`, so nm is the one unambiguous default (ADR-0004 §4). + """ + if hasattr(wavelength, "to"): + try: + return float(wavelength.to(ureg.nanometer).magnitude) + except Exception as e: + raise ValueError(f"Wavelength must be a length. Got {wavelength}: {e}") + return float(wavelength) + + +def _as_wl_curve( + raw: Optional[Dict[str, List[float]]], value_key: str +) -> Optional[WavelengthCurve]: + """Lift a structured `{wavelengths_nm, }` dict to a curve. + + The structured optical slots (#153, #164) are stored as plain dicts so + the on-disk shape and the JSON round-trip stay unchanged; the curve is + built on demand by the `_at(lambda)` accessors. + """ + if raw is None: + return None + return WavelengthCurve.from_toml(raw, value_key=value_key) + + +def _eval_wl_or_scalar( + raw: Optional[Dict[str, List[float]]], + value_key: str, + scalar: Optional[float], + unit_str: Optional[str], + wavelength: Any, +) -> Optional["Quantity"]: + """Shared wavelength evaluator: prefer spectrum, fall back to scalar. + + The wavelength twin of `_eval_curve_or_scalar`. Same precedence rule + (structured data beats the scalar), same clamp-never-extrapolate + contract inherited from `WavelengthCurve`. + """ + curve = _as_wl_curve(raw, value_key) + if curve is None and scalar is None: + return None + # Validate the argument even on the scalar path. Otherwise a caller who + # passes a temperature to a wavelength accessor gets a plausible number + # back from whichever materials happen to have only a scalar, and a + # hard error from the ones that have a spectrum — the worst possible mix. + nm = _to_nm(wavelength) + value = curve.interpolate(nm) if curve is not None else scalar + if unit_str: + return value * ureg(unit_str) + return value + def _eval_curve_or_scalar( curve: Optional[TempCurve], scalar: Optional[float], unit_str: Optional[str], temp: "Quantity" @@ -458,8 +519,23 @@ class OpticalProperties: # Basic optical properties refractive_index: Optional[float] = None # n at 550nm (default) transparency: Optional[float] = None # % transmission (0-100) - MEASURED VALUE + # Wavelength-resolved transmission, % — {wavelengths_nm: [...], values: [...]}. + # Couplants and windows are quoted at a stated path length; record it in the + # `_sources` note, since the number is meaningless without it. + transparency_spectrum: Optional[Dict[str, List[float]]] = None + # Bulk specular/total reflectivity, % (0-100) — same percent convention as + # `transparency` above. Reflector films (ESR, Teflon, Tyvek) carry this. + # NOTE (#243): `[esr.optical] reflectivity = 98.5` has been on disk since + # #147 but there was no field to receive it, so the loader silently + # dropped it on every load. Adding the field is the fix. + reflectivity: Optional[float] = None # % + # Wavelength-resolved reflectivity, % — {wavelengths_nm: [...], values: [...]}. + # Reflector films and diffuse standards are strongly wavelength-dependent at + # the blue end, which is exactly where scintillators emit. + reflectivity_spectrum: Optional[Dict[str, List[float]]] = None absorption_coefficient: Optional[float] = None # 1/cm absorption_length: Optional[float] = None # mm (inverse of coefficient) + absorption_length_unit: str = "mm" # Scintillator properties (detector physics) light_yield: Optional[float] = None # photons/MeV @@ -489,6 +565,53 @@ class OpticalProperties: # Shape: {wavelengths_nm: [...], n: [...]} refractive_index_dispersion: Optional[Dict[str, List[float]]] = None + # ------------------------------------------------------------------ + # Wavelength-resolved attenuation and the self-absorption split (#243) + # ------------------------------------------------------------------ + # Shape for every `_spectrum` slot below: {wavelengths_nm: [...], values: [...]}. + # Each is the wavelength-resolved sibling of the scalar above it, and the + # `_at(lambda)` accessors prefer the spectrum when both are present. + absorption_length_spectrum: Optional[Dict[str, List[float]]] = None + + # A photon absorbed in a doped scintillator has TWO physically distinct + # fates, and one lumped attenuation length cannot express the difference + # (ADR-0004 §5): + # matrix — absorbed by the host lattice/impurities. Photon is gone. + # reabs — absorbed by the activator (e.g. Ce3+) on the overlap between + # its excitation tail and its own emission band. The photon may + # be re-emitted after a fresh decay draw, which puts a slow tail + # on the timing distribution rather than simply losing light. + # Populate BOTH or NEITHER: a lone `absorption_length_matrix` reads as "the + # rest is self-absorption", which is a claim the data usually cannot make. + # When the split is not measurable for a material, declare it absent via + # the `[._absent]` table rather than folding it into one number. + absorption_length_matrix: Optional[float] = None # mm + absorption_length_matrix_unit: str = "mm" + absorption_length_matrix_spectrum: Optional[Dict[str, List[float]]] = None + absorption_length_reabs: Optional[float] = None # mm + absorption_length_reabs_unit: str = "mm" + absorption_length_reabs_spectrum: Optional[Dict[str, List[float]]] = None + # Probability in [0, 1] that an activator-reabsorbed photon is re-emitted + # rather than lost non-radiatively. Only meaningful alongside `_reabs`. + reemit_qe: Optional[float] = None + + # ------------------------------------------------------------------ + # Kubelka-Munk two-flux coefficients for diffusing media (#243) + # ------------------------------------------------------------------ + # Shape: {wavelengths_nm: [...], k: [...], s: [...]}, both in 1/cm. + # + # Bundled rather than split across `absorption_coefficient` and + # `scattering_length` on purpose. K-M `k` and `s` are the parameters of a + # SPECIFIC two-flux model of a diffusing layer; they are not general + # optical constants, `s` is not a transport mean free path, and `k` is not + # a Beer-Lambert coefficient. Only their RATIO is physically meaningful for + # the thick-layer limit, and they are useless individually — so they travel + # together, under the model's own name. + # + # This is what lets a powder reflector be modelled as a medium rather than + # a surface, which matters as soon as a layer is thin enough to transmit. + kubelka_munk: Optional[Dict[str, List[float]]] = None + # Detector-physics scalars (#153) afterglow_pct_at_3ms: Optional[float] = None # count-rate ceiling afterglow_pct_at_100ms: Optional[float] = None @@ -497,6 +620,14 @@ class OpticalProperties: temperature_coefficient_light_yield: Optional[float] = None # %/K hygroscopic: Optional[bool] = None # NaI:Tl yes, BGO/LYSO no + # Activator identity and concentration (#243). Both have been written in + # the scintillator TOMLs since before there were fields to receive them + # (`[lyso.Ce.optical] dopant_pct = 0.1`, `[nai.Tl.optical] dopant = "Tl"`), + # so the loader silently dropped them on every load. The activator is what + # makes a scintillator scintillate; it is not an incidental label. + dopant: Optional[str] = None # e.g. "Ce", "Tl", "Na" + dopant_pct: Optional[float] = None # mol % + # T-dependent curves (#148). Refractive index, light yield, decay time # are the dimensionless / unit-implicit ones — no `_unit` field exists. refractive_index_curve: Optional[TempCurve] = None @@ -515,6 +646,12 @@ def rayleigh_length_qty(self) -> Optional["Quantity"]: return None return self.rayleigh_length * ureg(self.rayleigh_length_unit) + @property + def absorption_length_qty(self) -> Optional["Quantity"]: + if self.absorption_length is None: + return None + return self.absorption_length * ureg(self.absorption_length_unit) + def refractive_index_at(self, temp: "Quantity") -> Optional[float]: """Refractive index at T. Curve > scalar fallback (#148).""" return _eval_curve_or_scalar(self.refractive_index_curve, self.refractive_index, None, temp) @@ -527,6 +664,373 @@ def decay_time_at(self, temp: "Quantity") -> Optional[float]: """Scintillator decay time at T. Curve > scalar fallback (#148).""" return _eval_curve_or_scalar(self.decay_time_curve, self.decay_time, None, temp) + # ===================================================================== + # Wavelength accessors (#243, ADR-0004 §4) + # + # The lambda twins of the `_at(T)` methods above. Deliberately named + # differently — `refractive_index_at(T)` is temperature and has shipped + # since #148, so overloading it on argument type would be a silent + # behaviour change for existing callers. `n_at(lambda)` is unambiguous. + # + # Every one accepts a Pint Quantity or a bare number in nm, prefers the + # structured spectrum over the scalar, and CLAMPS outside the measured + # range rather than extrapolating. + # ===================================================================== + + @property + def refractive_index_dispersion_curve(self) -> Optional[WavelengthCurve]: + """`refractive_index_dispersion` as a `WavelengthCurve`, or None.""" + return _as_wl_curve(self.refractive_index_dispersion, "n") + + @property + def extinction_curve(self) -> Optional[WavelengthCurve]: + """The `k` column of `refractive_index_dispersion`, or None. + + Absorbing media (metals) carry `k` alongside `n` in the same table; + transparent ones omit it. The #164 enricher writes both when the + upstream CC0 entry has them. + """ + if self.refractive_index_dispersion is None: + return None + if "k" not in self.refractive_index_dispersion: + return None + return _as_wl_curve(self.refractive_index_dispersion, "k") + + @property + def emission_spectrum_curve(self) -> Optional[WavelengthCurve]: + """`emission_spectrum` as a `WavelengthCurve`, or None.""" + return _as_wl_curve(self.emission_spectrum, "intensities") + + @property + def absorption_length_curve(self) -> Optional[WavelengthCurve]: + """`absorption_length_spectrum` as a `WavelengthCurve`, or None.""" + return _as_wl_curve(self.absorption_length_spectrum, "values") + + @property + def transparency_curve(self) -> Optional[WavelengthCurve]: + """`transparency_spectrum` as a `WavelengthCurve`, or None.""" + return _as_wl_curve(self.transparency_spectrum, "values") + + def transparency_at(self, wavelength: Any) -> Optional[float]: + """Transmission (%) at a wavelength. Spectrum > scalar fallback. + + The path length the figure was measured over lives in the `_sources` + note, because a transmission percentage without one is not a number. + """ + return _eval_wl_or_scalar( + self.transparency_spectrum, "values", self.transparency, None, wavelength + ) + + @property + def reflectivity_curve(self) -> Optional[WavelengthCurve]: + """`reflectivity_spectrum` as a `WavelengthCurve`, or None.""" + return _as_wl_curve(self.reflectivity_spectrum, "values") + + def reflectivity_at(self, wavelength: Any) -> Optional[float]: + """Reflectivity (%) at a wavelength. Spectrum > scalar fallback. + + Distinct from `normal_reflectance_at`, which *derives* reflectance from + n,k. This one returns a *measured* reflectivity where the data carries + one — for a diffuse reflector like pressed BaSO4 there is no meaningful + n,k to derive from, because the reflectance comes from multiple + scattering in a powder rather than a Fresnel step at a smooth surface. + """ + return _eval_wl_or_scalar( + self.reflectivity_spectrum, "values", self.reflectivity, None, wavelength + ) + + def n_at(self, wavelength: Any) -> Optional[float]: + """Refractive index at a wavelength. Dispersion > scalar fallback. + + Args: + wavelength: Pint Quantity (e.g. `420 * ureg.nm`) or bare nm. + + Returns: + Dimensionless n, or None when neither dispersion nor scalar is set. + """ + return _eval_wl_or_scalar( + self.refractive_index_dispersion, "n", self.refractive_index, None, wavelength + ) + + def k_at(self, wavelength: Any) -> Optional[float]: + """Extinction coefficient at a wavelength, or None for a transparent medium.""" + curve = self.extinction_curve + if curve is None: + return None + return curve.interpolate(_to_nm(wavelength)) + + def normal_reflectance_at(self, wavelength: Any) -> Optional[float]: + """Normal-incidence reflectance from vacuum, in PERCENT (0-100). + + Derived, not stored — the Fresnel result for a semi-infinite medium + with complex index `n - ik` against vacuum:: + + R = ((n - 1)^2 + k^2) / ((n + 1)^2 + k^2) + + Returned as a percent to match the `reflectivity` / `transparency` + convention on this dataclass. This is the number a wrap or mirror + contributes per bounce, so it is worth deriving from cited n,k rather + than carrying a separate hand-entered scalar that can drift from them. + + A dielectric with no `k` uses k = 0, which is the correct limit. + Returns None when there is no refractive index at all. + + NOTE: this is a *bulk, normal-incidence, optically-thick, perfectly + smooth* reflectance. A real wrap is rough, oxidised, and struck at all + angles, so it will measure lower — treat this as the ceiling, and + prefer a measured surface entry where one exists (ADR-0004 §3). + """ + n = self.n_at(wavelength) + if n is None: + return None + k = self.k_at(wavelength) or 0.0 + r = ((n - 1.0) ** 2 + k**2) / ((n + 1.0) ** 2 + k**2) + return 100.0 * r + + def absorption_length_at(self, wavelength: Any) -> Optional["Quantity"]: + """Bulk attenuation length at a wavelength. Spectrum > scalar fallback. + + This is the LUMPED length — everything that removes a photon from the + beam. When a material declares the `_matrix` / `_reabs` split, prefer + those: they separate true loss from re-emittable self-absorption. + """ + return _eval_wl_or_scalar( + self.absorption_length_spectrum, + "values", + self.absorption_length, + self.absorption_length_unit, + wavelength, + ) + + def absorption_length_matrix_at(self, wavelength: Any) -> Optional["Quantity"]: + """Host-matrix (true-loss) attenuation length at a wavelength.""" + return _eval_wl_or_scalar( + self.absorption_length_matrix_spectrum, + "values", + self.absorption_length_matrix, + self.absorption_length_matrix_unit, + wavelength, + ) + + def absorption_length_reabs_at(self, wavelength: Any) -> Optional["Quantity"]: + """Activator self-absorption length at a wavelength. + + A photon absorbed on this channel may be re-emitted with probability + `reemit_qe` after a fresh decay draw. + """ + return _eval_wl_or_scalar( + self.absorption_length_reabs_spectrum, + "values", + self.absorption_length_reabs, + self.absorption_length_reabs_unit, + wavelength, + ) + + # --- Kubelka-Munk (#243) ------------------------------------------ + + def km_k_at(self, wavelength: Any) -> Optional[float]: + """Kubelka-Munk absorption coefficient (1/cm) at a wavelength.""" + curve = _as_wl_curve(self.kubelka_munk, "k") + return None if curve is None else curve.interpolate(_to_nm(wavelength)) + + def km_s_at(self, wavelength: Any) -> Optional[float]: + """Kubelka-Munk scattering coefficient (1/cm) at a wavelength.""" + curve = _as_wl_curve(self.kubelka_munk, "s") + return None if curve is None else curve.interpolate(_to_nm(wavelength)) + + def km_reflectance_infinite_at(self, wavelength: Any) -> Optional[float]: + """Reflectance (%) of an infinitely thick layer, from the K-M ratio. + + ``R_inf = 1 + k/s - sqrt((k/s)^2 + 2k/s)`` + + Derived rather than stored. Its value is that it is *independently* + derived: if a material also carries a measured `reflectivity_spectrum` + and the two disagree, that is a real discrepancy between two sources + and the material should say so rather than quietly carry both. + """ + k = self.km_k_at(wavelength) + s = self.km_s_at(wavelength) + if k is None or s is None or s <= 0: + return None + x = k / s + return 100.0 * (1.0 + x - math.sqrt(x * x + 2.0 * x)) + + @staticmethod + def obliquity_factor(incidence_deg: float) -> float: + """Path-length multiplier `1/cos(theta)` for a slab at incidence `theta`. + + A ray crossing a slab of thickness `d` at `theta` from the normal + travels `d/cos(theta)`. At grazing incidence this diverges, so it is + capped at 40 (≈88.6°) — beyond that a plane-parallel slab model has + stopped describing anything real, and returning a finite large number + is less misleading than returning infinity. + """ + theta = math.radians(abs(float(incidence_deg))) + factor = float("inf") if theta >= math.pi / 2 else 1.0 / math.cos(theta) + if factor > 40.0: + logger.debug( + "obliquity_factor: theta=%s deg gives 1/cos = %s; capping at 40. " + "A plane-parallel slab model has stopped describing anything real " + "by this angle.", + incidence_deg, + factor, + ) + return 40.0 + return factor + + def km_split_at( + self, wavelength: Any, thickness_cm: float, incidence_deg: float = 0.0 + ) -> Optional[tuple]: + """`(R, T, A)` in PERCENT for a finite layer — every photon's fate. + + Returns reflected, transmitted and absorbed fractions, which sum to + 100% by construction. This is the accessor a segmented-detector model + wants: `T` is the inter-crystal crosstalk channel, `A` is the only true + loss, and `R` is the reflectance that layer *actually* delivers — which + is not `km_reflectance_infinite_at` unless the layer is thick. + + **The layer is against a non-reflecting (black) backing**, i.e. a + transmitted photon is gone from this interface's point of view. That is + the correct model for an inter-crystal septum, where a photon crossing + the septum has entered the neighbour. + + There is deliberately no `backing_reflectance` parameter. With a + reflective backing, Kubelka-Munk's `R` is the reflectance of the + *composite* (layer plus backing, including light that crossed the layer, + bounced, and came back), while `T` remains the layer's own + transmittance. Those are not two parts of one photon budget, so + `A := 100 - R - T` stops meaning "absorbed" and can go negative. An + earlier revision of this method exposed such a parameter and documented + a conservation property it did not have; rather than guess at the right + decomposition it was removed, and will return only with a physical + definition and a consumer that needs it. + + Note the limiting behaviour, because it is easy to get backwards: + with `k = 0` the thick-layer reflectance is exactly 1, not 0.999. + **Absorption is the only thing that makes R_inf differ from unity** — + it is not a small correction to a non-absorbing model, it is the entire + reason the asymptote is below 1. Finite thickness is what drives R + below R_inf; `k` is what sets R_inf itself. + """ + k = self.km_k_at(wavelength) + s = self.km_s_at(wavelength) + if k is None or s is None or s <= 0 or thickness_cm <= 0: + return None + # Obliquity: a ray at theta crosses d/cos(theta) of material. + thickness_cm = thickness_cm * self.obliquity_factor(incidence_deg) + if k == 0: + sd = s * thickness_cm + r = sd / (1.0 + sd) + t = 1.0 / (1.0 + sd) + return (100.0 * r, 100.0 * t, 0.0) + a = 1.0 + k / s + b = math.sqrt(a * a - 1.0) + bsd = b * s * thickness_cm + # Optically thick limit. `cosh` overflows a float64 above ~710, and + # `coth` is already 1.0 to machine precision by ~20, so branch before + # the arithmetic can raise. This branch is EXACT, not an approximation: + # coth -> 1 gives R = 1/(a+b), and (1+x+sqrt(x^2+2x))(1+x-sqrt(x^2+2x)) + # = 1, so 1/(a+b) is identically R_inf. T falls as e^-bsd, i.e. below + # 1e-9 here. + if bsd > 20.0: + r = 1.0 / (a + b) + return (100.0 * r, 0.0, 100.0 * (1.0 - r)) + coth = math.cosh(bsd) / math.sinh(bsd) + r = 1.0 / (a + b * coth) + t = b / (a * math.sinh(bsd) + b * math.cosh(bsd)) + return (100.0 * r, 100.0 * t, 100.0 * (1.0 - r - t)) + + def km_reflectance_at( + self, wavelength: Any, thickness_cm: float, incidence_deg: float = 0.0 + ) -> Optional[float]: + """Reflectance (%) of a FINITE layer — the number a real reflector delivers. + + Prefer this over `km_reflectance_infinite_at` for any physical layer. + A 0.2 mm BaSO4 septum reflects ~92%, not the ~97% thick-layer limit and + certainly not the ~99.9% quoted for a pressed-powder standard, because + the balance goes straight through. + """ + split = self.km_split_at(wavelength, thickness_cm, incidence_deg) + return None if split is None else split[0] + + def km_transmittance_at( + self, wavelength: Any, thickness_cm: float, incidence_deg: float = 0.0 + ) -> Optional[float]: + """Diffuse transmittance (%) through a finite layer, K-M hyperbolic form. + + ``T = b / (a*sinh(b*s*d) + b*cosh(b*s*d))``, with ``a = 1 + k/s`` and + ``b = sqrt(a^2 - 1)``. + + This is the accessor that matters for a thin reflector, and it answers + a *different question* from `km_reflectance_infinite_at`. Reflectance + converges to its thick-layer limit quickly; transmittance does not go + to zero anywhere near as fast. A layer can be "optically thick" for the + purpose of reflectance and still transmit several percent — which is a + crosstalk channel, not a rounding error. + + **`incidence_deg` is for COLLIMATED light at a known angle.** For + DIFFUSE illumination pass 0 — see the double-counting warning below. + + A ray crossing at `theta` travels `d/cos(theta)`, so a beam at 60 + degrees sees twice the material. That much is straightforward. + + TWO TRAPS, both of which have bitten real consumers of this accessor: + + 1. **A diffuse reflector erases the incident angular distribution.** + After one contact with a Lambertian surface, direction is + cosine-distributed about that surface's normal, with mean + `|cos theta| = 2/3` exactly, i.e. 48.2 degrees — *regardless* of how + the light arrived. So reasoning like "the crystal is high-aspect, so + light strikes the walls at grazing incidence" is wrong the moment the + wall is a diffuse reflector: the reflector, not the geometry, sets + the angle. It is only right for a specular wall. + + 2. **Kubelka-Munk coefficients are already defined for diffuse flux.** + The K-M two-flux formalism bakes the obliquity of diffuse + illumination into `k` and `s` (this is the origin of the factor 2 in + the usual `K = 2k` convention). So if your light IS diffuse, plain + `d` is already correct and multiplying by `1/cos` double-counts. + Use this parameter for a collimated beam; leave it at 0 for + diffusely-illuminated layers. + + Note also that transmittance is nonlinear in path, so evaluating at a + mean angle is not the same as averaging over the distribution — though + for a Lambertian distribution on a 0.2 mm septum the two agree to about + 0.1 percentage points, so it is a small effect here. + + **`thickness_cm` and `incidence_deg` are degenerate.** They enter only + through the product `d/cos(theta)` — the optical thickness — so + `(0.02 cm, 76.7 deg)` and `(0.087 cm, 0 deg)` return byte-identical + results. Nothing downstream of this call can tell them apart. + + That matters when fitting. A layer fitted against measured R or T + constrains the PRODUCT, never either factor, so "the light arrives at + 77 degrees" and "the layer is 4.3x thicker than nominal" are the same + claim wearing different clothes. Both have been proposed for the same + detector; the arithmetic could not distinguish them, and only measuring + the angle directly did. If you fit here, fit optical thickness and say + so — then go measure a factor independently. + """ + split = self.km_split_at(wavelength, thickness_cm, incidence_deg) + return None if split is None else split[1] + + def emission_at(self, wavelength: Any) -> Optional[float]: + """Relative emission intensity at a wavelength, or None if no spectrum. + + No scalar fallback exists by construction — `emission_peak` is one + point on a band, not a stand-in for its shape. A caller with only a + peak should sample monochromatically and know that it is doing so. + """ + # Validate the argument before the early return, so a bad wavelength + # fails the same way regardless of whether this particular material + # happens to have a spectrum. Otherwise the same call silently returns + # None on LYSO and raises on the next material along. + nm = _to_nm(wavelength) + curve = self.emission_spectrum_curve + if curve is None: + return None + return curve.interpolate(nm) + @dataclass class MagneticProperties: @@ -724,6 +1228,12 @@ class ComplianceProperties: uv_resistant: Optional[bool] = None radiation_resistant: Optional[bool] = None # gamma, neutron, etc. flame_retardant: Optional[bool] = None + # Distinct from `flame_retardant`, which is a property of a treated + # material; `flammable` is a hazard classification. Both written in the + # TOMLs (hydrogen, methane) with no field to land in until #243. + flammable: Optional[bool] = None + # Handling hazard — beryllia dust is the canonical case. + toxic: Optional[bool] = None # Recyclability recyclable: Optional[bool] = None diff --git a/src/pymat/sources.py b/src/pymat/sources.py index 4df4e77..0e9aad8 100644 --- a/src/pymat/sources.py +++ b/src/pymat/sources.py @@ -33,7 +33,19 @@ "refractive_index": "optical.refractive_index", "light_yield": "optical.light_yield", "decay_time": "optical.decay_time", - "radiation_length": "optical.radiation_length", + # #157 moved radiation_length to NuclearProperties but this alias kept + # pointing at `optical.` — so `mat.cite("radiation_length")` silently + # resolved to a path no TOML writes, and fell through to `_default`. + # Fixed in #243; the qualified form `nuclear.radiation_length` was + # always correct and is unaffected. + "radiation_length": "nuclear.radiation_length", + "interaction_length": "nuclear.interaction_length", + "moliere_radius": "nuclear.moliere_radius", + "reflectivity": "optical.reflectivity", + "absorption_length": "optical.absorption_length", + "emission_spectrum": "optical.emission_spectrum", + "emission_peak": "optical.emission_peak", + "decay_components": "optical.decay_components", } @@ -93,6 +105,71 @@ def to_bibtex(self) -> str: return "@misc{" + self.citation + ",\n" + ",\n".join(fields) + "\n}" +# Why a value is missing. Deliberately a closed set — the whole point of a +# declared absence is that it can be audited, and a free-text reason cannot +# be counted. Extend the set in a PR, not in a data file. +ABSENT_REASONS: frozenset[str] = frozenset( + { + # Nobody has measured it (for this material, to our knowledge). + "not-measured", + # The property is meaningless here (e.g. `hygroscopic` for a gas). + "not-applicable", + # The quantity exists but cannot be decomposed the way the schema + # asks — e.g. a lumped attenuation length that no published work + # splits into matrix-loss and self-absorption channels. + "not-separable", + # Measured, but the only source forbids redistribution. + "proprietary", + # We intend to populate it; tracked elsewhere. Use `note` for the ref. + "pending", + } +) + + +@dataclass(frozen=True) +class Absent: + """A declared absence for a property path (#243, ADR-0004 §6). + + The negative twin of `Source`. `None` on a property means "no value + here" and cannot distinguish *nobody looked* from *we looked and the + number does not exist* — but those two facts make a downstream engine + behave very differently. A declared absence makes the second case + visible and greppable. + + Attributes: + reason: One of `ABSENT_REASONS`. Validated at load. + note: Human-readable detail — what was searched, what was found + instead, which issue tracks it. + """ + + reason: str + note: Optional[str] = None + + @classmethod + def from_toml(cls, path: str, data: dict[str, Any]) -> "Absent": + """Build from a TOML inline-table. Raises on an unknown reason.""" + if "reason" not in data: + raise ValueError(f"_absent entry {path!r} missing required key 'reason'") + reason = data["reason"] + if reason not in ABSENT_REASONS: + allowed = ", ".join(sorted(ABSENT_REASONS)) + raise ValueError( + f"_absent entry {path!r} has unknown reason {reason!r}; allowed: {allowed}" + ) + return cls(reason=reason, note=data.get("note")) + + +def parse_absent_table(raw: dict[str, Any]) -> dict[str, "Absent"]: + """Parse a `[._absent]` TOML table into `{path: Absent}`.""" + out: dict[str, Absent] = {} + for key, val in raw.items(): + if not isinstance(val, dict): + kind = type(val).__name__ + raise ValueError(f"_absent entry {key!r} must be an inline table, got {kind}") + out[key] = Absent.from_toml(key, val) + return out + + def resolve_path(path: str) -> str: """Expand a short alias (`"density"`) to its fully-qualified path (`"mechanical.density"`). Pass-through if already qualified or unknown.""" diff --git a/src/pymat/surfaces.py b/src/pymat/surfaces.py new file mode 100644 index 0000000..127d6f2 --- /dev/null +++ b/src/pymat/surfaces.py @@ -0,0 +1,468 @@ +"""Measured optical surface finishes — the interface catalogue (#243). + +A `Surface` is **not** a `Material`. A material is a substance; a surface is a +*measured interface between substances* — a crystal face with a given +treatment, a reflector, and whatever fills the gap between them. It has no +density, no formula, and no mass, so modelling it as a `Material` would put +objects into `pymat.materials`, `search()`, and `mass_from_volume_mm3()` for +which those operations are meaningless. See ADR-0004 §2. + +What it shares with materials is everything that is about *values* rather than +about substances: `Source` provenance, `Absent` declarations, `WavelengthCurve` +spectra, and parent-overlay inheritance. + +## Scope — measured interfaces only + +The catalogue holds the 30 measured surfaces shipped in the Geant4 +`G4RealSurface` 2.2 data set: 21 LBNL LUTs (Janecek & Moses 2010) and 9 DAVIS +LUTs (Roncali & Cherry 2013). It deliberately does **not** hold Geant4's six +analytic UNIFIED/GLISUR finishes (`polished`, `ground`, +`polishedfrontpainted`, …). Those carry no measured data and no citation — they +are model selections parameterised by `sigma_alpha`, and a model selection is a +run-time policy choice belonging to the consuming engine (ADR-0004 §3). + +Nor does it hold *assignments*. Which face of which crystal carries which +finish is a fact about a detector somebody built, not about matter. + +## Usage + + from pymat import surfaces + + s = surfaces["davis.polished_esr_grease"] + s.lut_surface # 'PolishedESRGrease_LUT' — exact G4 enum spelling + s.coupling # 'optical_contact' + s.coupling_index # 1.465 (BC-630 silicone grease) + s.cite() # BibTeX for every source behind this entry + + surfaces(lut_family="davis") # -> list[Surface] + surfaces(coupling="air_gap") # -> list[Surface] +""" + +from __future__ import annotations + +import logging +import sys +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, Iterator, List, Optional, overload + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover — Python 3.10 path + import tomli as tomllib + +from .curves import WavelengthCurve +from .sources import Absent, Source, merge_sources, parse_absent_table, parse_sources_table + +logger = logging.getLogger(__name__) + +__all__ = ["Surface", "surfaces", "load_surfaces"] + +# --------------------------------------------------------------------------- +# Closed vocabularies. Validated at load — a typo in a data file is a hard +# error, not a value that quietly fails every downstream comparison. +# --------------------------------------------------------------------------- + +#: How the interface reflects. `lut` means a measured angular-reflectance +#: look-up table; `specular` and `diffuse` are for cited scalar/spectral +#: reflectors that have no LUT. +MODELS = frozenset({"lut", "specular", "diffuse"}) + +#: Which measured LUT family the entry comes from. +LUT_FAMILIES = frozenset({"lbnl", "davis"}) + +#: What fills the gap between crystal face and reflector. This is the +#: distinction the brief calls out as physically load-bearing and currently +#: inexpressible: an air gap means the photon meets a crystal→air Fresnel step +#: first (large index contrast, small critical angle, strong TIR light-piping — +#: the mechanism DOI designs exploit), while optical contact means it meets the +#: coupling polymer directly. +COUPLINGS = frozenset({"air_gap", "optical_contact", "none"}) + +#: Crystal-face preparation the surface was measured against. +TREATMENTS = frozenset({"polished", "etched", "ground", "rough"}) + + +@dataclass(frozen=True) +class Surface: + """One measured optical interface. + + Every field except `key`/`name` is optional — the catalogue records what + was measured, and an entry that lacks a number should say so via `_absent` + rather than carry a plausible default. + """ + + key: str + name: str + + # --- behaviour ------------------------------------------------------- + model: Optional[str] = None # one of MODELS + treatment: Optional[str] = None # one of TREATMENTS + + # --- measured-LUT identity ------------------------------------------ + lut_family: Optional[str] = None # one of LUT_FAMILIES + #: The exact `G4OpticalSurfaceFinish` enum spelling, verbatim. This is the + #: string a consumer matches on to find the right `.dat` file, so it is + #: transcribed from the Geant4 header rather than normalised — hence the + #: inconsistent casing across families (`polishedvm2000glue` for LBNL, + #: `PolishedESRGrease_LUT` for DAVIS). Do not "fix" it. + lut_surface: Optional[str] = None + #: The exact `G4SurfaceType` the finish is valid with. + g4_surface_type: Optional[str] = None + #: Which data-set release the LUT ships in. + lut_dataset: Optional[str] = None + + # --- the two substances being joined --------------------------------- + #: Human label for the reflector (e.g. `"3M ESR"`, `"TiO2 paint"`). + reflector: Optional[str] = None + #: py-mat material key for the reflector, when one exists (e.g. `"esr"`). + reflector_material: Optional[str] = None + #: How the gap is filled — see `COUPLINGS`. + coupling: Optional[str] = None + #: py-mat material key for the coupling medium, when one exists. + coupling_material: Optional[str] = None + #: Refractive index of the coupling medium at the measurement wavelength. + coupling_index: Optional[float] = None + + # --- measured optical values ----------------------------------------- + reflectivity: Optional[float] = None # %, 0-100 (same convention as OpticalProperties) + reflectivity_spectrum: Optional[Dict[str, List[float]]] = None + thickness_um: Optional[float] = None + + note: Optional[str] = None + + # --- sidecars (same contract as Material) ----------------------------- + _sources: Dict[str, Source] = field(default_factory=dict, repr=False) + _absent: Dict[str, Absent] = field(default_factory=dict, repr=False) + #: Catalogue key of the parent node this entry inherited from, if any. + _parent: Optional[str] = field(default=None, repr=False) + + def __post_init__(self) -> None: + self._check_enum("model", self.model, MODELS) + self._check_enum("treatment", self.treatment, TREATMENTS) + self._check_enum("lut_family", self.lut_family, LUT_FAMILIES) + self._check_enum("coupling", self.coupling, COUPLINGS) + + # A LUT entry without its enum spelling is unusable downstream, and a + # non-LUT entry carrying one is claiming measured data it does not have. + if self.model == "lut" and not self.lut_surface: + raise ValueError(f"surface {self.key!r}: model='lut' requires 'lut_surface'") + if self.lut_surface and self.model != "lut": + raise ValueError( + f"surface {self.key!r}: 'lut_surface' set but model is {self.model!r}, not 'lut'" + ) + + # An air gap is air. Anything else is not an air gap, and the + # difference is exactly what this field exists to record. + if self.coupling == "air_gap" and self.coupling_index is not None: + if abs(self.coupling_index - 1.0) > 0.01: + raise ValueError( + f"surface {self.key!r}: coupling='air_gap' but coupling_index=" + f"{self.coupling_index}; use coupling='optical_contact' for a filled gap" + ) + if self.coupling == "optical_contact" and self.coupling_index is None: + raise ValueError( + f"surface {self.key!r}: coupling='optical_contact' requires 'coupling_index' " + f"— the index of the filling medium is the whole physical difference" + ) + + if self.reflectivity is not None: + if not 0.0 <= self.reflectivity <= 100.0: + raise ValueError( + f"surface {self.key!r}: reflectivity={self.reflectivity} out of range; " + f"the schema is percent (0-100), matching OpticalProperties.transparency" + ) + # 0 < r < 1 is legal (a very dark surface) but is far more often a + # fraction typed where a percent was meant. Warn rather than raise: + # rejecting a legal value to catch a likely typo trades a certain + # failure for a probable one. The value is never rewritten — a + # silent x100 would be exactly the subtle-wrongness this schema + # avoids elsewhere. + if 0.0 < self.reflectivity < 1.0: + logger.warning( + "surface %r: reflectivity=%s is in (0, 1). This field is PERCENT " + "(0-100) — 0.985 means 0.985%%, not 98.5%%. Value kept as written.", + self.key, + self.reflectivity, + ) + + if self.reflectivity_spectrum is not None: + # Validate at load, like every other structured spectrum. + WavelengthCurve.from_toml(self.reflectivity_spectrum, value_key="values") + + def _check_enum(self, fname: str, value: Optional[str], allowed: frozenset) -> None: + if value is not None and value not in allowed: + opts = ", ".join(sorted(allowed)) + raise ValueError(f"surface {self.key!r}: {fname}={value!r} not in {{{opts}}}") + + # --- accessors -------------------------------------------------------- + + @property + def reflectivity_curve(self) -> Optional[WavelengthCurve]: + """`reflectivity_spectrum` as a `WavelengthCurve`, or None.""" + if self.reflectivity_spectrum is None: + return None + return WavelengthCurve.from_toml(self.reflectivity_spectrum, value_key="values") + + def reflectivity_at(self, wavelength: Any) -> Optional[float]: + """Reflectivity (%) at a wavelength. Spectrum > scalar fallback, clamped.""" + curve = self.reflectivity_curve + if curve is None: + return self.reflectivity + from .properties import _to_nm + + return curve.interpolate(_to_nm(wavelength)) + + @property + def is_optical_contact(self) -> bool: + """True when the gap is index-filled (grease, glue, meltmount). + + The complement, `coupling == 'air_gap'`, is the case that produces + total-internal-reflection light-piping. + """ + return self.coupling == "optical_contact" + + def source_of(self, path: str) -> Optional[Source]: + """Provenance for a field name, falling back to `_default`.""" + if not self._sources: + return None + if path in self._sources: + return self._sources[path] + return self._sources.get("_default") + + def absent(self, path: str) -> Optional[Absent]: + """Declared absence for a field name, or None (#243).""" + return self._absent.get(path) if self._absent else None + + def cite(self, path: Optional[str] = None) -> str: + """BibTeX for one field, or for every source behind this entry.""" + if not self._sources: + return "" + if path is not None: + src = self.source_of(path) + return src.to_bibtex() if src is not None else "" + seen: Dict[str, Source] = {} + for src in self._sources.values(): + seen.setdefault(src.citation, src) + return "\n\n".join(s.to_bibtex() for s in seen.values()) + + def __repr__(self) -> str: + bits = [repr(self.key)] + if self.lut_surface: + bits.append(f"lut={self.lut_surface}") + if self.coupling: + bits.append(f"coupling={self.coupling}") + return f"Surface({', '.join(bits)})" + + +# --------------------------------------------------------------------------- +# Loading +# --------------------------------------------------------------------------- + +# Keys that configure the node itself rather than naming a child surface. +_FIELD_KEYS = frozenset( + { + "name", + "model", + "treatment", + "lut_family", + "lut_surface", + "g4_surface_type", + "lut_dataset", + "reflector", + "reflector_material", + "coupling", + "coupling_material", + "coupling_index", + "reflectivity", + "reflectivity_spectrum", + "thickness_um", + "note", + "abstract", + } +) + + +def _resolve_node( + key: str, + data: Dict[str, Any], + inherited: Dict[str, Any], + parent_sources: Dict[str, Source], + parent_absent: Dict[str, Absent], + parent_key: Optional[str], + out: Dict[str, Surface], +) -> None: + """Recursively resolve one catalogue node and its children. + + Inheritance is plain overlay: a child sees every field its ancestors set + and overrides what it re-declares. This is what makes the LBNL family — 3 + treatments x 7 wrappings sharing one citation — expressible without + repeating the citation 21 times, which is how citations rot. + """ + own = {k: v for k, v in data.items() if k in _FIELD_KEYS} + merged = {**inherited, **own} + + sources = parent_sources + if "_sources" in data: + raw = data["_sources"] + if not isinstance(raw, dict): + raise ValueError(f"surface {key!r}: _sources must be a table, got {type(raw).__name__}") + sources = merge_sources(parent_sources, parse_sources_table(raw)) + + absent = parent_absent + if "_absent" in data: + raw_absent = data["_absent"] + if not isinstance(raw_absent, dict): + raise ValueError( + f"surface {key!r}: _absent must be a table, got {type(raw_absent).__name__}" + ) + absent = {**parent_absent, **parse_absent_table(raw_absent)} + + # Abstract nodes exist only to carry shared fields and citations down to + # their children. They are not catalogue entries and are not registered. + if not merged.pop("abstract", False): + fields = {k: v for k, v in merged.items() if k != "abstract"} + fields.setdefault("name", key) + out[key] = Surface(key=key, _sources=sources, _absent=absent, _parent=parent_key, **fields) + + child_inherited = {k: v for k, v in merged.items() if k != "abstract"} + # A child must not inherit its parent's identity fields — those are what + # makes each entry distinct. Everything else (family, dataset, model, + # citations) is exactly what we want flowing down. + for identity in ("name", "lut_surface", "note"): + child_inherited.pop(identity, None) + + for child_key, child_data in data.items(): + if child_key in _FIELD_KEYS or child_key.startswith("_"): + continue + if not isinstance(child_data, dict): + continue + _resolve_node( + f"{key}.{child_key}", + child_data, + child_inherited, + sources, + absent, + key, + out, + ) + + +def load_surfaces(file_path: Path | str | None = None) -> Dict[str, Surface]: + """Load the surface catalogue from `surfaces.toml`. + + Entries live under a top-level `[surface]` table; registry keys are the + dotted path with that prefix stripped (`surface.davis.polished_esr_grease` + on disk becomes `davis.polished_esr_grease`). + """ + if file_path is None: + file_path = Path(__file__).parent / "data" / "surfaces.toml" + file_path = Path(file_path) + with open(file_path, "rb") as f: + raw = tomllib.load(f) + + out: Dict[str, Surface] = {} + root = raw.get("surface", {}) + for key, node in root.items(): + if isinstance(node, dict): + _resolve_node(key, node, {}, {}, {}, None, out) + return out + + +# --------------------------------------------------------------------------- +# Registry — mirrors the `pymat.materials` contract (#228) +# --------------------------------------------------------------------------- + +_CACHE: Optional[Dict[str, Surface]] = None + + +def _catalogue() -> Dict[str, Surface]: + global _CACHE + if _CACHE is None: + _CACHE = load_surfaces() + return _CACHE + + +def _normalise(key: str) -> str: + """Accept `surface.davis.rough` as well as `davis.rough`. + + The `surface.` prefix is how the key is spelled on disk and how it travels + in downstream files, so both spellings resolve. + """ + return key[len("surface.") :] if key.startswith("surface.") else key + + +class _Surfaces(Mapping[str, Surface]): + """The ``pymat.surfaces`` registry surface — Mapping + callable + filterable.""" + + def __getitem__(self, key: str) -> Surface: + cat = _catalogue() + norm = _normalise(key) + if norm not in cat: + raise KeyError( + f"Unknown surface {key!r}. The catalogue holds measured interfaces only " + f"({len(cat)} entries); analytic UNIFIED finishes such as 'polished' or " + f"'ground' are model selections and live in the consuming engine's config " + f"(ADR-0004 §3)." + ) + return cat[norm] + + def __iter__(self) -> Iterator[str]: + return iter(_catalogue()) + + def __len__(self) -> int: + return len(_catalogue()) + + def __contains__(self, key: object) -> bool: + return isinstance(key, str) and _normalise(key) in _catalogue() + + @overload + def __call__(self, name: str, /) -> Surface: ... + + @overload + def __call__( + self, + *, + lut_family: str | None = None, + coupling: str | None = None, + treatment: str | None = None, + model: str | None = None, + reflector_material: str | None = None, + ) -> list[Surface]: ... + + def __call__( + self, + name: str | None = None, + /, + *, + lut_family: str | None = None, + coupling: str | None = None, + treatment: str | None = None, + model: str | None = None, + reflector_material: str | None = None, + ) -> "Surface | list[Surface]": + """Look up one surface by key, or filter the catalogue. + + surfaces("davis.polished_esr_grease") # -> Surface + surfaces(coupling="air_gap") # -> list[Surface] + """ + if name is not None: + return self[name] + criteria = { + "lut_family": lut_family, + "coupling": coupling, + "treatment": treatment, + "model": model, + "reflector_material": reflector_material, + } + active = {k: v for k, v in criteria.items() if v is not None} + return [ + s for s in _catalogue().values() if all(getattr(s, k) == v for k, v in active.items()) + ] + + def __repr__(self) -> str: + return f"" + + +surfaces = _Surfaces() diff --git a/src/pymat/vis/__init__.py b/src/pymat/vis/__init__.py index 22cfc4c..a9ed896 100644 --- a/src/pymat/vis/__init__.py +++ b/src/pymat/vis/__init__.py @@ -52,18 +52,48 @@ from typing import Any -from mat_vis_client import ( - MatVisClient, - get_manifest, - prefetch, - rowmap_entry, - seed_indexes, -) - -# Shared-singleton accessor: ``get_client`` became public in -# mat-vis-client 0.5.0 (see mat-vis#84). Pinned in pyproject.toml. -from mat_vis_client import get_client as _shared_client -from mat_vis_client import search as _client_search +# ``mat_vis_client`` is an OPTIONAL dependency, guarded the same way PIL is in the +# test suite (#242): importing ``pymat`` must not require it. +# +# ``pymat/__init__.py`` does ``from . import factories, registry, vis`` eagerly, so an +# unguarded import here makes the whole materials library unusable anywhere the +# visualisation client is not installed — headless exports, CI, and any consumer that +# only wants ``pmma`` / ``water()``. Material data has no dependency on a vis client; +# only the client-backed helpers below do, and those now fail on USE with an +# actionable message instead of at import. +try: + from mat_vis_client import ( + MatVisClient, + get_manifest, + prefetch, + rowmap_entry, + seed_indexes, + ) + + # Shared-singleton accessor: ``get_client`` became public in + # mat-vis-client 0.5.0 (see mat-vis#84). Pinned in pyproject.toml. + from mat_vis_client import get_client as _shared_client + from mat_vis_client import search as _client_search + + _HAVE_VIS_CLIENT = True +except ImportError as _exc: # pragma: no cover - exercised only without the extra + _HAVE_VIS_CLIENT = False + _VIS_CLIENT_ERR = _exc + + def _requires_vis_client(*_a: Any, **_k: Any) -> Any: + raise ImportError( + "this pymat.vis helper needs the optional `mat_vis_client` package " + f"(install the vis extra); original import error: {_VIS_CLIENT_ERR}" + ) + + # Names are still bound so module-level references resolve; calling one raises. + MatVisClient = _requires_vis_client # type: ignore[assignment] + get_manifest = _requires_vis_client # type: ignore[assignment] + prefetch = _requires_vis_client # type: ignore[assignment] + rowmap_entry = _requires_vis_client # type: ignore[assignment] + seed_indexes = _requires_vis_client # type: ignore[assignment] + _shared_client = _requires_vis_client + _client_search = _requires_vis_client # Domain types: re-exported so consumers can construct or type-hint # without reaching into the private ``_model`` module. diff --git a/tests/test_absent.py b/tests/test_absent.py new file mode 100644 index 0000000..cb06992 --- /dev/null +++ b/tests/test_absent.py @@ -0,0 +1,389 @@ +"""Tests for #243 — `_absent`, declared absences. + +Per ADR-0004 §6: `None` on a property cannot distinguish "nobody looked" from +"we looked and the number does not exist". Those two facts should make a +downstream engine behave differently, so the second one gets recorded. +""" + +from __future__ import annotations + +from textwrap import dedent + +import pytest + +import pymat +from pymat.loader import load_toml +from pymat.sources import ABSENT_REASONS, Absent, parse_absent_table + + +class TestAbsentDataclass: + def test_requires_a_reason(self): + with pytest.raises(ValueError, match="missing required key 'reason'"): + Absent.from_toml("optical.x", {"note": "hi"}) + + def test_rejects_an_unknown_reason(self): + with pytest.raises(ValueError, match="unknown reason"): + Absent.from_toml("optical.x", {"reason": "lazy"}) + + def test_error_lists_the_allowed_reasons(self): + with pytest.raises(ValueError, match="not-separable"): + Absent.from_toml("optical.x", {"reason": "lazy"}) + + def test_reason_vocabulary_is_closed(self): + """Free text cannot be counted, and the point of a declared absence is + that it can be audited.""" + assert ABSENT_REASONS == { + "not-measured", + "not-applicable", + "not-separable", + "proprietary", + "pending", + } + + def test_note_is_optional(self): + a = Absent.from_toml("optical.x", {"reason": "pending"}) + assert a.reason == "pending" + assert a.note is None + + def test_non_table_entry_raises(self): + with pytest.raises(ValueError, match="must be an inline table"): + parse_absent_table({"optical.x": "nope"}) + + +class TestAbsentLoading: + def _load(self, tmp_path, body): + p = tmp_path / "m.toml" + p.write_text(dedent(body)) + return load_toml(p) + + def test_absent_is_parsed_and_queryable(self, tmp_path): + mats = self._load( + tmp_path, + """ + [x] + name = "X" + [x._absent] + "optical.reemit_qe" = { reason = "not-measured", note = "searched, nothing" } + """, + ) + a = mats["x"].absent("optical.reemit_qe") + assert a.reason == "not-measured" + assert a.note == "searched, nothing" + assert mats["x"].is_absent("optical.reemit_qe") + + def test_unset_property_without_declaration_is_not_absent(self): + """The distinction the whole mechanism exists to draw.""" + assert pymat.lyso.properties.optical.scattering_length is None + assert not pymat.lyso.is_absent("optical.scattering_length") + + def test_bad_absent_table_raises_at_load(self, tmp_path): + with pytest.raises(ValueError, match="_absent must be a TOML table"): + self._load( + tmp_path, + """ + [x] + name = "X" + _absent = "nope" + """, + ) + + def test_bad_reason_raises_at_load(self, tmp_path): + with pytest.raises(ValueError, match="unknown reason"): + self._load( + tmp_path, + """ + [x] + name = "X" + [x._absent] + "optical.x" = { reason = "shrug" } + """, + ) + + def test_absent_does_not_leak_into_properties(self, tmp_path): + mats = self._load( + tmp_path, + """ + [x] + name = "X" + [x._absent] + "optical.reemit_qe" = { reason = "not-measured" } + """, + ) + assert mats["x"].properties.optical.reemit_qe is None + + +class TestAbsentInheritance: + def _load(self, tmp_path, body): + p = tmp_path / "m.toml" + p.write_text(dedent(body)) + return load_toml(p) + + def test_children_inherit_parent_absences(self, tmp_path): + mats = self._load( + tmp_path, + """ + [x] + name = "X" + [x._absent] + "optical.reemit_qe" = { reason = "not-measured" } + [x.child] + name = "Child" + """, + ) + assert mats["x"]._children["child"].is_absent("optical.reemit_qe") + + def test_child_can_override_the_reason(self, tmp_path): + mats = self._load( + tmp_path, + """ + [x] + name = "X" + [x._absent] + "optical.reemit_qe" = { reason = "not-measured" } + [x.child] + name = "Child" + [x.child._absent] + "optical.reemit_qe" = { reason = "proprietary" } + """, + ) + assert mats["x"].absent("optical.reemit_qe").reason == "not-measured" + assert mats["x"]._children["child"].absent("optical.reemit_qe").reason == "proprietary" + + def test_a_child_that_has_the_measurement_just_sets_it(self, tmp_path): + """The declared absence stays inherited, but the value is now real — + so a consumer must check the value first, absence second.""" + mats = self._load( + tmp_path, + """ + [x] + name = "X" + [x._absent] + "optical.reemit_qe" = { reason = "not-measured" } + [x.child] + name = "Child" + [x.child.optical] + reemit_qe = 0.75 + """, + ) + child = mats["x"]._children["child"] + assert child.properties.optical.reemit_qe == 0.75 + + +class TestAbsentAPI: + def test_short_alias_resolves(self): + """`absent()` accepts the same short aliases as `source_of()`.""" + assert pymat.lyso.absent("decay_components") is not None + assert pymat.lyso.absent("optical.decay_components") is not None + + def test_no_default_fallback(self): + """Unlike `source_of`, an absence is always specific to one property — + a `_default` absence would claim everything is missing.""" + assert pymat.lyso.absent("optical.scattering_length") is None + + def test_material_with_no_absences_returns_none(self): + assert pymat.copper.absent("optical.reemit_qe") is None + assert not pymat.copper.is_absent("optical.reemit_qe") + + +class TestLysoDeclaredAbsences: + """The honest answers to the brief's P1 data asks (see + docs/briefs/strata-optical-response.md).""" + + @pytest.mark.parametrize( + "path,reason", + [ + ("optical.emission_spectrum", "proprietary"), + ("optical.refractive_index_dispersion", "proprietary"), + ("optical.decay_components", "not-measured"), + ("optical.absorption_length_matrix", "not-measured"), + ("optical.reemit_qe", "not-measured"), + ], + ) + def test_lyso_declares_the_gap(self, path, reason): + a = pymat.lyso.absent(path) + assert a is not None, f"{path} should carry a declared absence" + assert a.reason == reason + assert a.note and len(a.note) > 80, "an absence without a searched-for note is a shrug" + + def test_self_absorption_split_is_half_measured_and_says_so(self): + """ADR-0004 §5: populate both channels, or declare the missing one.""" + opt = pymat.lyso.properties.optical + assert opt.absorption_length_reabs == 588.0 # measured, CC-BY + assert opt.absorption_length_matrix is None # not measured + assert pymat.lyso.is_absent("optical.absorption_length_matrix") + + def test_measured_channel_is_cited(self): + src = pymat.lyso.source_of("optical.absorption_length_reabs") + assert src.citation == "bosca_lopez_2023" + assert src.license == "CC-BY-4.0" + + def test_lumped_absorption_length_is_flagged_as_a_convention(self): + """The 200 mm literal has a provenance chain, but it is a Monte-Carlo + convention rather than a measurement, and the note must say so.""" + src = pymat.lyso.source_of("optical.absorption_length") + assert "CONVENTION" in src.note + + def test_absorption_length_carries_its_sensitivity_warning(self): + """A downstream 2D sweep showed this value is NOT second-order: it + moves collection efficiency +17% at reflector R=0.97 but +53% at + R=0.999, because the two parameters interact. The caveat is the kind of + thing that gets tidied away, so it is pinned.""" + note = pymat.lyso.source_of("optical.absorption_length").note + assert "SENSITIVITY" in note + assert "interact" in note + assert "Sweep this value" in note + + def test_bgo_dispersion_came_from_the_enricher(self): + """ADR-0004 §10: bgo is in the refractiveindex.info enricher's scope, + so its dispersion must arrive via the automated CC0 pull, never + hand-authored. The citation is the check — a hand-written table would + not carry this source row.""" + opt = pymat.bgo.properties.optical + assert opt.refractive_index_dispersion is not None + src = pymat.bgo.source_of("optical.refractive_index_dispersion") + assert src.license == "CC0" + assert "refractiveindex.info" in src.citation or "refractiveindex.info" in src.ref + + def test_bgo_scalar_understates_n_at_the_blue_end(self): + """Why running the enricher mattered: the single scalar was fitted near + the emission peak, so a monochromatic-at-peak simulation is roughly + right, but anything sampling the blue edge of the band gets a critical + angle built on an n that is ~2% low.""" + opt = pymat.bgo.properties.optical + assert opt.refractive_index == 2.15 + assert opt.n_at(420) == pytest.approx(2.198, abs=0.005) + assert opt.n_at(480) == pytest.approx(2.154, abs=0.005) + assert opt.n_at(420) > opt.refractive_index + + +class TestStaleAliasFixed: + """#157 moved radiation_length to NuclearProperties; the short alias kept + pointing at `optical.` until #243 (ADR-0004 §8).""" + + def test_short_alias_resolves_to_nuclear(self): + from pymat.sources import resolve_path + + assert resolve_path("radiation_length") == "nuclear.radiation_length" + assert resolve_path("interaction_length") == "nuclear.interaction_length" + + +class TestSidecarMergeIsPartialNotWholesale: + """A child declaring its OWN entry must keep the parent's other entries. + + Found by mutation audit: replacing `{**parent, **child}` with + `dict(child)` in the loader left all 1217 tests green. The existing + override test used the SAME key on parent and child, where a merge and a + replacement are indistinguishable — the distinguishing case is DISJOINT + keys, which nothing exercised. + + Failure mode if this regresses: a material that declares one absence + silently loses every absence and citation it inherited. Nothing raises; + provenance just quietly thins out down the tree. + """ + + def _load(self, tmp_path, body): + p = tmp_path / "m.toml" + p.write_text(dedent(body)) + return load_toml(p) + + def test_child_absences_merge_with_disjoint_parent_absences(self, tmp_path): + mats = self._load( + tmp_path, + """ + [x] + name = "X" + [x._absent] + "optical.reemit_qe" = { reason = "not-measured" } + "optical.decay_components" = { reason = "not-measured" } + [x.child] + name = "Child" + [x.child._absent] + "optical.emission_spectrum" = { reason = "proprietary" } + """, + ) + child = mats["x"]._children["child"] + assert sorted(child._absent) == [ + "optical.decay_components", + "optical.emission_spectrum", + "optical.reemit_qe", + ] + assert child.is_absent("optical.reemit_qe"), "inherited absence was dropped" + assert child.is_absent("optical.emission_spectrum"), "own absence missing" + + def test_child_sources_merge_with_disjoint_parent_sources(self, tmp_path): + """Same shape, same risk, for `_sources` — audited together because a + gap in one implies a gap in the other.""" + mats = self._load( + tmp_path, + """ + [x] + name = "X" + [x._sources] + "optical.light_yield" = { citation = "a", kind = "doi", ref = "10.1/a", license = "CC0"} + [x.child] + name = "Child" + [x.child._sources] + "optical.decay_time" = { citation = "b", kind = "doi", ref = "10.1/b", license = "CC0" } + """, + ) + child = mats["x"]._children["child"] + assert child.source_of("optical.light_yield").citation == "a", "inherited source dropped" + assert child.source_of("optical.decay_time").citation == "b" + + def test_the_real_corpus_exercises_the_merge(self): + """`lyso.Ce` declares its own `_sources` rows while `lyso` declares + others, so the shipped data depends on this merge rather than only the + synthetic fixtures above.""" + own = pymat.lyso.Ce.source_of("optical.rise_time") + inherited = pymat.lyso.Ce.source_of("optical.absorption_length_reabs") + assert own is not None and own.citation == "seifert_2012" + assert inherited is not None and inherited.citation == "bosca_lopez_2023" + assert pymat.lyso.Ce.is_absent("optical.emission_spectrum") + + +class TestIntrinsicResolutionProvenance: + """`intrinsic_resolution` is a DERIVED quantity, and the schema has to make + that visible or it will be compared across incompatible extractions. + + It is the residual after subtracting an assumed photostatistical term, so + two labs can publish different values for the same crystal purely by using + different photodetectors. A consumer comparing their own extracted value + against a literature one is comparing two numbers that were each produced + under different assumptions — the same class of error as comparing two + differently-normalised crosstalk figures. + """ + + def test_lso_value_carries_its_extraction_method(self): + src = pymat.lso.Ce.source_of("optical.intrinsic_resolution_pct_at_662keV") + assert src.ref == "10.1016/j.phpro.2011.11.035" + # The method is the load-bearing part, not the number. + for token in ("N_pe = 6610", "EXTRACTION METHOD", "transfer term assumed zero"): + assert token in src.note, token + + def test_the_uncertainty_is_not_the_papers_error_bar(self): + """The paper quotes ±0.3 on the TOTAL. The ±1.0 here is a deliberate + widening for extraction-assumption and sample-to-sample spread, and the + note must say so — otherwise it reads as a measurement precision it is + not.""" + opt = pymat.lso.Ce.properties.optical + assert opt.intrinsic_resolution_pct_at_662keV.nominal_value == pytest.approx(7.7) + assert opt.intrinsic_resolution_pct_at_662keV.std_dev == pytest.approx(1.0) + note = pymat.lso.Ce.source_of("optical.intrinsic_resolution_pct_at_662keV").note + assert "NOT THE PAPER ERROR BAR" in note + + def test_511_kev_is_declared_absent_on_both_materials(self): + for mat in (pymat.lso.Ce, pymat.lyso): + a = mat.absent("optical.intrinsic_resolution_pct_at_511keV") + assert a is not None and a.reason == "not-measured" + + def test_lyso_does_not_silently_inherit_the_lso_number(self): + """LYSO is expected to be BETTER than LSO, so borrowing the LSO figure + would bias high. The absence says so rather than leaving a consumer to + assume they are interchangeable.""" + assert pymat.lyso.properties.optical.intrinsic_resolution_pct_at_662keV is None + note = pymat.lyso.absent("optical.intrinsic_resolution_pct_at_662keV").note + assert "BIASED HIGH" in note + + def test_non_proportionality_is_the_stated_cause(self): + assert pymat.lso.Ce.properties.optical.non_proportionality == 43.0 + assert pymat.lso.Ce.source_of("optical.non_proportionality") is not None diff --git a/tests/test_integration.py b/tests/test_integration.py index 1ba91d6..4338598 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -66,7 +66,10 @@ def test_apply_scintillator_to_shape(self): material = shape.material assert "LYSO" in material.name - assert material.properties.optical.light_yield == 34000 + # 33200 ph/MeV, corrected from an uncited 34000 in #243 — the Luxium + # (formerly Saint-Gobain) PreLude 420 data sheet states 33200, and the + # value now carries that citation. + assert material.properties.optical.light_yield == 33200 class TestGltfMaterialPassthrough: diff --git a/tests/test_public_api_surface.py b/tests/test_public_api_surface.py index 45d1948..6f58b8c 100644 --- a/tests/test_public_api_surface.py +++ b/tests/test_public_api_surface.py @@ -390,6 +390,7 @@ def test_vis_public_type_construction(self, name: str) -> None: "parent", "_key", "_sources", + "_absent", # #243 — declared absences, sidecar alongside _sources "tags", # #132 — multi-axial filterable tags } ) diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index b77fa33..4b203de 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -685,3 +685,70 @@ def test_aerospace_alloys(self): assert inconel718.density == 8.22 # Inconel 718 STA — much higher Ftu than 625 annealed assert inconel718.properties.mechanical.tensile_strength == 1241 + + def test_wavelength_dependent_optics(self): + """ + ## 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. + """ + 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) + + def test_declared_absences(self): + """ + ## 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. + """ + 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") + + def test_measured_surface_finishes(self): + """ + ## 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. + """ + 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" diff --git a/tests/test_rs_python_parity.py b/tests/test_rs_python_parity.py new file mode 100644 index 0000000..9cb9ff9 --- /dev/null +++ b/tests/test_rs_python_parity.py @@ -0,0 +1,227 @@ +"""The Python loader and the Rust loader must resolve the same TOML identically. + +Both `src/pymat/loader.py` and `mat-rs/src/db.rs` parse the same data files +independently. Nothing structural stops them drifting apart, and they have: +#157 moved `radiation_length` to `nuclear` and the Rust side lagged; the strata +brief of 2026-08-14 was filed largely because the Rust crate had fallen years +behind the Python schema. + +Conventions did not catch that. A gate does. + +This test drives `cargo run --example dump_parity`, resolves the same materials +through Python, and diffs field by field. It SKIPS when cargo or the crate is +unavailable, so a Python-only checkout is unaffected; CI runs both toolchains. + +If this fails, the two loaders disagree about what the data means. Python is the +source of truth (ADR-0004 §7) — fix the Rust side unless Python is the one that +is wrong, which has happened. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +import pymat + +REPO_ROOT = Path(__file__).resolve().parent.parent +MANIFEST = REPO_ROOT / "mat-rs" / "Cargo.toml" + +pytestmark = pytest.mark.skipif( + shutil.which("cargo") is None or not MANIFEST.exists(), + reason="cargo or the mat-rs crate is unavailable; parity gate runs in CI", +) + +# Rust field name -> how to read the same value from a Python Material. +FIELDS = { + "name": lambda m: m.name, + "formula": lambda m: m.formula, + "density": lambda m: m.properties.mechanical.density, + "grade": lambda m: m.grade, + "temper": lambda m: m.temper, + "treatment": lambda m: m.treatment, + "vendor": lambda m: m.vendor, + "n": lambda m: m.properties.optical.refractive_index, + "ly": lambda m: m.properties.optical.light_yield, + "dt": lambda m: m.properties.optical.decay_time, + "rt": lambda m: m.properties.optical.rise_time, + "ep": lambda m: m.properties.optical.emission_peak, + "refl": lambda m: m.properties.optical.reflectivity, + "transp": lambda m: m.properties.optical.transparency, + "abs": lambda m: m.properties.optical.absorption_length, + "reabs": lambda m: m.properties.optical.absorption_length_reabs, + "matrix": lambda m: m.properties.optical.absorption_length_matrix, + "reemit": lambda m: m.properties.optical.reemit_qe, + "dopant": lambda m: m.properties.optical.dopant, + "dopant_pct": lambda m: m.properties.optical.dopant_pct, + "hygro": lambda m: m.properties.optical.hygroscopic, + "radlen": lambda m: m.properties.nuclear.radiation_length, + "intlen": lambda m: m.properties.nuclear.interaction_length, + "activity": lambda m: m.properties.nuclear.intrinsic_activity_Bq_per_g, + "melt": lambda m: m.properties.thermal.melting_point, + "tc": lambda m: m.properties.thermal.thermal_conductivity, + # Tags are the one leaf key that UNIONS with the parent instead of + # replacing it (#132). Compared as a joined string so ordering is part of + # the contract — parent context first, then the child's own labels. + "tags": lambda m: ",".join(m.tags), + # Provenance and absence keys, not just values. The Rust parsers use + # `filter_map` and would drop a malformed `_sources` row silently, where + # Python raises; comparing the key sets turns that silence into a diff. + "srckeys": lambda m: ",".join(sorted(m._sources)), + "abskeys": lambda m: ",".join(sorted(m._absent)), +} + + +def _parse_rust_value(raw: str): + """Turn a Rust `{:?}` rendering into a Python value.""" + if raw == "None": + return None + if raw.startswith("Some(") and raw.endswith(")"): + raw = raw[5:-1] + if raw.startswith('"') and raw.endswith('"'): + return raw[1:-1] + if raw == "true": + return True + if raw == "false": + return False + try: + return float(raw) + except ValueError: + return raw + + +def _normalise(value): + """Coerce for comparison: ufloat -> nominal, int -> float.""" + value = getattr(value, "nominal_value", value) + if isinstance(value, bool) or value is None or isinstance(value, str): + return value + if isinstance(value, (int, float)): + return float(value) + return value + + +@pytest.fixture(scope="module") +def rust_materials() -> dict[str, dict[str, object]]: + proc = subprocess.run( + ["cargo", "run", "--quiet", "--manifest-path", str(MANIFEST), "--example", "dump_parity"], + capture_output=True, + text=True, + timeout=600, + ) + if proc.returncode != 0: + pytest.fail(f"cargo run --example dump_parity failed:\n{proc.stderr[-4000:]}") + + out: dict[str, dict[str, object]] = {} + for line in proc.stdout.splitlines(): + if not line.strip(): + continue + parts = line.split("|") + fields = {} + for chunk in parts[1:]: + key, _, raw = chunk.partition("=") + fields[key] = _parse_rust_value(raw) + out[parts[0]] = fields + return out + + +@pytest.fixture(scope="module") +def python_materials() -> dict[str, object]: + """Every material keyed by its dotted path, mirroring the Rust key scheme.""" + pymat.load_all() + + def walk(mat, key, out): + out[key] = mat + for child_key, child in mat._children.items(): + walk(child, f"{key}.{child_key}", out) + + out: dict[str, object] = {} + for bases in pymat._CATEGORY_BASES.values(): + for base in bases: + mat = pymat.registry.get(base) + if mat is not None: + walk(mat, base, out) + return out + + +class TestLoaderParity: + def test_rust_dump_is_non_empty(self, rust_materials): + assert len(rust_materials) > 100, "the Rust dump looks truncated" + + def test_every_python_material_exists_in_rust(self, python_materials, rust_materials): + """Every material Python resolves must also resolve in Rust. + + The reverse does not hold: `_CATEGORY_BASES` does not list every + top-level TOML key, so the Rust side legitimately sees more. + """ + missing = sorted(set(python_materials) - set(rust_materials)) + # `inconel625`/`inconel718`/`OFHC` are declared as category bases but + # live nested in the TOML, so their dotted paths differ. They are + # reachable in both; only the path spelling differs. + missing = [m for m in missing if m not in {"inconel625", "inconel718", "OFHC"}] + assert not missing, f"materials Python resolves but Rust does not: {missing}" + + def test_all_fields_agree(self, python_materials, rust_materials): + shared = sorted(set(python_materials) & set(rust_materials)) + assert shared, "no overlap — the key schemes have diverged entirely" + + disagreements = [] + for key in shared: + mat = python_materials[key] + rust = rust_materials[key] + for field, read in FIELDS.items(): + if field not in rust: + continue + py_value = _normalise(read(mat)) + rs_value = _normalise(rust[field]) + if isinstance(py_value, float) and isinstance(rs_value, float): + if abs(py_value - rs_value) < 1e-9: + continue + elif py_value == rs_value: + continue + disagreements.append(f" {key}.{field}: python={py_value!r} rust={rs_value!r}") + + assert not disagreements, ( + "the Python and Rust loaders disagree about the same TOML " + f"({len(disagreements)} field(s)). Python is the source of truth " + "(ADR-0004 §7).\n" + "\n".join(disagreements[:40]) + ) + + def test_the_fields_that_regressed_before_are_covered(self, rust_materials): + """Pin the specific values behind past drift, so a future refactor that + breaks them fails here with a recognisable name. + + - `radiation_length` moved optical -> nuclear in #157. + - `esr.reflectivity` was dropped by both loaders until #243. + - Root-material `grade`/`vendor` were dropped by Python until #243 (an + operator-precedence bug this very parity check surfaced). + """ + assert rust_materials["lyso"]["radlen"] == 1.14 + assert rust_materials["esr"]["refl"] == 98.5 + assert rust_materials["beryllium"]["grade"] == "S-200F" + assert rust_materials["esr"]["vendor"] == "3M" + assert pymat.beryllium.grade == "S-200F" + assert pymat.esr.vendor == "3M" + + def test_child_tags_union_with_the_parent(self, rust_materials): + """A child declares only what is new and inherits the rest. Replacing + rather than unioning made s316L claim 4 tags where py-mat reports 7.""" + parent = rust_materials["stainless"]["tags"] + child = rust_materials["stainless.s316L"]["tags"] + assert parent == "ferrous,stainless,corrosion-resistant" + assert child.startswith(parent), "child tags must keep the parent's, in order, first" + assert "316-family" in child + assert child == ",".join(pymat.stainless.s316L.tags) + + def test_provenance_rows_are_not_dropped_in_translation(self, rust_materials): + """Rust parses `_sources`/`_absent` with `filter_map`, so a malformed + row would vanish rather than raise. The key-set comparison in + `test_all_fields_agree` is what catches that; this pins the two + materials with the richest provenance so a regression is legible.""" + lyso = rust_materials["lyso"] + assert "optical.absorption_length_reabs" in lyso["srckeys"] + assert "optical.reemit_qe" in lyso["abskeys"] + assert lyso["srckeys"] == ",".join(sorted(pymat.lyso._sources)) + assert lyso["abskeys"] == ",".join(sorted(pymat.lyso._absent)) diff --git a/tests/test_surfaces.py b/tests/test_surfaces.py new file mode 100644 index 0000000..eb449a7 --- /dev/null +++ b/tests/test_surfaces.py @@ -0,0 +1,544 @@ +"""Tests for #243 — the measured surface-finish catalogue. + +Per ADR-0004 §2/§3: +- `Surface` is its own type, not a `Material`, with its own registry. +- The catalogue holds exactly the 30 MEASURED interfaces in Geant4 + `G4RealSurface` 2.2: 21 LBNL + 9 DAVIS. Analytic UNIFIED finishes are + model selections and are deliberately absent. +- Air-gap and optical-contact coupling are distinguishable, which is the + thing the consuming engine could not previously express. +- Every entry carries provenance. +- The `[lyso.Ce.polished]` inherited-variant shape works end to end. +""" + +from __future__ import annotations + +from textwrap import dedent + +import pytest + +import pymat +from pymat.surfaces import COUPLINGS, LUT_FAMILIES, MODELS, Surface, load_surfaces, surfaces + +# The exact G4OpticalSurfaceFinish spellings, transcribed from +# source/materials/include/G4OpticalSurface.hh. If the catalogue ever drifts +# from these, downstream LUT lookups break silently — so they are pinned here +# rather than derived from the data file the test is supposed to be checking. +LBNL_EXPECTED = { + f"{treatment}{wrap}" + for treatment in ("polished", "etched", "ground") + for wrap in ( + "lumirrorair", + "lumirrorglue", + "teflonair", + "tioair", + "tyvekair", + "vm2000air", + "vm2000glue", + ) +} +DAVIS_EXPECTED = { + "Rough_LUT", + "RoughTeflon_LUT", + "RoughESR_LUT", + "RoughESRGrease_LUT", + "Polished_LUT", + "PolishedTeflon_LUT", + "PolishedESR_LUT", + "PolishedESRGrease_LUT", + "Detector_LUT", +} + +# Enum members of dielectric_LUT that ship NO measured .dat file. They must not +# appear in the catalogue — an entry with no measurement behind it is exactly +# the uncited value this repo exists to prevent (ADR-0004 §3). +LBNL_UNMEASURED = {"polishedair", "etchedair", "groundair"} + +# Analytic UNIFIED/GLISUR finishes — model selections, not measurements. +ANALYTIC_FINISHES = { + "polished", + "polishedfrontpainted", + "polishedbackpainted", + "ground", + "groundfrontpainted", + "groundbackpainted", +} + + +# Entries that carry measured reflectance but no angular look-up table +# (ADR-0004 §3 — the test is measurement, not LUT-backing). +NON_LUT_KEYS = {"diffuse.baso4_air", "specular.aluminium_air"} + + +def lut_entries(): + """Every entry backed by a G4RealSurface look-up table.""" + return [s for s in surfaces.values() if s.lut_family is not None] + + +class TestCatalogueCompleteness: + def test_thirty_lut_entries(self): + assert len(lut_entries()) == 30 + + def test_non_lut_entries_are_exactly_the_known_set(self): + """Non-LUT entries are cheap to add and easy to add carelessly, so the + set is pinned. Adding one should be a deliberate edit here too.""" + got = {s.key for s in surfaces.values() if s.lut_family is None} + assert got == NON_LUT_KEYS + + def test_every_entry_is_lut_backed_or_names_a_reflector(self): + """The §3 test in executable form: an entry earns its place by carrying + measured numbers — either a LUT, or a reflector whose reflectance is + measured and cited on the material it names.""" + for s in surfaces.values(): + assert s.lut_family is not None or s.reflector_material is not None, s.key + + def test_all_21_lbnl_lut_names_present(self): + got = {s.lut_surface for s in surfaces(lut_family="lbnl")} + assert got == LBNL_EXPECTED + assert len(got) == 21 + + def test_all_9_davis_lut_names_present(self): + got = {s.lut_surface for s in surfaces(lut_family="davis")} + assert got == DAVIS_EXPECTED + assert len(got) == 9 + + def test_unmeasured_lbnl_enum_members_are_excluded(self): + got = {s.lut_surface for s in surfaces.values()} + assert not (got & LBNL_UNMEASURED) + + def test_analytic_finishes_are_excluded(self): + """These are strata's, not ours — see ADR-0004 §3.""" + got = {s.lut_surface for s in surfaces.values()} + assert not (got & ANALYTIC_FINISHES) + + def test_lut_surface_names_are_unique(self): + names = [s.lut_surface for s in lut_entries()] + assert len(names) == len(set(names)) + assert all(names), "every LUT entry must name its G4 finish" + + def test_every_lut_entry_declares_its_dataset(self): + for s in lut_entries(): + assert s.lut_dataset == "G4RealSurface-2.2", s.key + + def test_non_lut_entries_claim_no_dataset(self): + """A dataset name on a non-LUT entry would claim a measurement file + that does not exist for it.""" + for key in NON_LUT_KEYS: + assert surfaces[key].lut_dataset is None + assert surfaces[key].lut_surface is None + + def test_g4_surface_types_match_family(self): + for s in lut_entries(): + expected = "dielectric_LUT" if s.lut_family == "lbnl" else "dielectric_LUTDAVIS" + assert s.g4_surface_type == expected, s.key + + +class TestProvenance: + def test_every_lut_entry_cites_its_name_and_family(self): + for s in lut_entries(): + assert s.source_of("lut_surface") is not None, s.key + assert s.source_of("lut_family") is not None, s.key + + def test_every_non_lut_entry_cites_its_reflector(self): + """Their measured content is the reflector's reflectance, so that is + what has to carry a citation.""" + for key in NON_LUT_KEYS: + assert surfaces[key].source_of("reflector_material") is not None, key + + def test_every_entry_produces_bibtex(self): + for s in surfaces.values(): + assert s.cite().startswith("@misc{"), s.key + + def test_coupling_index_is_cited_wherever_set(self): + """The index of the filling medium is the whole physical difference + between air-gap and optical contact — it does not get to be uncited.""" + for s in surfaces.values(): + if s.coupling_index is not None: + assert s.source_of("coupling_index") is not None, s.key + + def test_licenses_are_in_the_allowed_set(self): + allowed = { + "CC0", + "PD-USGov", + "CC-BY-3.0", + "CC-BY-4.0", + "CC-BY-SA-4.0", + "Geant4-SL", + "proprietary-reference-only", + } + for s in surfaces.values(): + for src in s._sources.values(): + assert src.license in allowed, f"{s.key}: {src.license}" + + def test_family_citations_are_the_real_papers(self): + lbnl = surfaces["lbnl.polished.vm2000_air"] + assert lbnl.source_of("lut_family").ref == "10.1109/TNS.2010.2042731" + davis = surfaces["davis.polished_esr_grease"] + assert davis.source_of("lut_family").ref == "10.1088/0031-9155/58/7/2185" + + +class TestCouplingDistinction: + """The brief's load-bearing ask: air gap and optical contact are + physically different and must be distinguishable.""" + + def test_air_gap_and_contact_are_both_represented(self): + # 15 LBNL air + 4 DAVIS air + 2 non-LUT (BaSO4, aluminium) + assert len(surfaces(coupling="air_gap")) == 21 + assert len(surfaces(coupling="optical_contact")) == 8 + assert len(surfaces(coupling="none")) == 2 + + def test_same_reflector_differs_only_by_coupling(self): + air = surfaces["lbnl.polished.vm2000_air"] + glue = surfaces["lbnl.polished.vm2000_glue"] + assert air.reflector_material == glue.reflector_material == "esr" + assert air.treatment == glue.treatment == "polished" + assert air.lut_surface != glue.lut_surface + assert air.coupling == "air_gap" + assert glue.coupling == "optical_contact" + assert air.is_optical_contact is False + assert glue.is_optical_contact is True + + def test_contact_entries_carry_the_filler_index(self): + for s in surfaces(coupling="optical_contact"): + assert s.coupling_index is not None, s.key + assert 1.3 < s.coupling_index < 1.7, s.key + + def test_air_gap_entries_have_no_filler_index(self): + for s in surfaces(coupling="air_gap"): + assert s.coupling_index is None, s.key + + def test_davis_grease_is_bc630_and_resolves_to_a_material(self): + s = surfaces["davis.polished_esr_grease"] + assert s.coupling_material == "bc630" + assert s.coupling_index == 1.465 + # The coupling medium is a real material in this same database. + assert pymat.bc630 is not None + + def test_lbnl_glue_is_meltmount(self): + s = surfaces["lbnl.ground.lumirror_glue"] + assert s.coupling_index == 1.582 + assert "meltmount" in s.source_of("coupling_index").note.lower() + + def test_reflector_material_refs_resolve(self): + for s in surfaces.values(): + if s.reflector_material: + assert pymat.materials[s.reflector_material] is not None, s.key + + +class TestInheritance: + def test_family_fields_flow_down(self): + s = surfaces["lbnl.etched.tyvek_air"] + assert s.lut_family == "lbnl" # from surface.lbnl + assert s.treatment == "etched" # from surface.lbnl.etched + assert s.lut_surface == "etchedtyvekair" # own + + def test_abstract_nodes_are_not_registered(self): + assert "lbnl" not in surfaces + assert "lbnl.polished" not in surfaces + assert "davis" not in surfaces + + def test_identity_fields_do_not_leak_to_children(self): + """A child must never inherit a parent's `lut_surface`, `name` or + `note` — those are what make each entry a distinct measurement.""" + luts = [s.lut_surface for s in lut_entries()] + assert len(set(luts)) == len(luts) + names = [s.name for s in surfaces.values()] + assert len(set(names)) == len(names), "two entries share a name" + + def test_parent_is_recorded(self): + assert surfaces["lbnl.polished.teflon_air"]._parent == "lbnl.polished" + + def test_sources_inherit_and_can_be_overridden(self): + # Family default inherited from surface.lbnl ... + s = surfaces["lbnl.polished.lumirror_glue"] + assert s.source_of("lut_family").citation == "janecek_moses_2010" + # ... and a leaf-level row wins for its own path. + assert s.source_of("coupling_index").citation == "cargille_meltmount_1582" + + +class TestRegistryAPI: + def test_mapping_protocol(self): + assert "davis.rough" in surfaces + assert isinstance(surfaces["davis.rough"], Surface) + assert len(list(surfaces)) == len(surfaces) + + def test_prefixed_key_also_resolves(self): + """`surface.davis.rough` is how the key is spelled on disk and how it + travels in downstream files.""" + assert surfaces["surface.davis.rough"] is surfaces["davis.rough"] + assert "surface.davis.rough" in surfaces + + def test_callable_lookup(self): + assert surfaces("davis.rough").lut_surface == "Rough_LUT" + + def test_callable_filter(self): + polished_lbnl = surfaces(lut_family="lbnl", treatment="polished") + assert len(polished_lbnl) == 7 + + def test_filter_by_reflector(self): + esr = surfaces(reflector_material="esr") + assert len(esr) == 10 # 6 LBNL vm2000 + 4 DAVIS ESR + + def test_unknown_key_error_explains_the_scope(self): + with pytest.raises(KeyError, match="measured interfaces only"): + surfaces["polished"] # analytic finish — deliberately not here + + def test_exported_from_package_root(self): + assert pymat.surfaces is surfaces + assert pymat.Surface is Surface + + def test_surface_is_not_in_the_material_registry(self): + """The two namespaces stay separate — a Surface has no density.""" + assert "davis.rough" not in pymat.materials + + +class TestSurfaceValidation: + def test_unknown_model_raises(self): + with pytest.raises(ValueError, match="model="): + Surface(key="x", name="X", model="glossy") + + def test_unknown_coupling_raises(self): + with pytest.raises(ValueError, match="coupling="): + Surface(key="x", name="X", coupling="damp") + + def test_lut_model_requires_a_lut_surface(self): + with pytest.raises(ValueError, match="requires 'lut_surface'"): + Surface(key="x", name="X", model="lut") + + def test_lut_surface_without_lut_model_raises(self): + with pytest.raises(ValueError, match="not 'lut'"): + Surface(key="x", name="X", model="specular", lut_surface="Rough_LUT") + + def test_optical_contact_requires_an_index(self): + with pytest.raises(ValueError, match="requires 'coupling_index'"): + Surface(key="x", name="X", coupling="optical_contact") + + def test_air_gap_with_a_polymer_index_raises(self): + """Guards the exact confusion the catalogue exists to prevent.""" + with pytest.raises(ValueError, match="use coupling='optical_contact'"): + Surface(key="x", name="X", coupling="air_gap", coupling_index=1.465) + + def test_air_gap_may_state_unity_index(self): + s = Surface(key="x", name="X", coupling="air_gap", coupling_index=1.0) + assert s.coupling_index == 1.0 + + def test_reflectivity_out_of_range_raises(self): + with pytest.raises(ValueError, match="percent"): + Surface(key="x", name="X", reflectivity=101.0) + assert Surface(key="x", name="X", reflectivity=98.5).reflectivity == 98.5 + + def test_fraction_shaped_reflectivity_warns_but_is_not_rewritten(self, caplog): + """0.985 is legal (a very dark surface) but is usually a fraction typed + where a percent was meant. Warn — never silently multiply by 100.""" + with caplog.at_level("WARNING", logger="pymat.surfaces"): + s = Surface(key="x", name="X", reflectivity=0.985) + assert s.reflectivity == 0.985 # untouched + assert "PERCENT" in caplog.text + + def test_malformed_spectrum_raises(self): + with pytest.raises(ValueError, match="length"): + Surface( + key="x", + name="X", + reflectivity_spectrum={"wavelengths_nm": [400, 500], "values": [0.9]}, + ) + + def test_vocabularies_are_closed(self): + assert MODELS == {"lut", "specular", "diffuse"} + assert LUT_FAMILIES == {"lbnl", "davis"} + assert COUPLINGS == {"air_gap", "optical_contact", "none"} + + +class TestSurfaceAccessors: + def test_reflectivity_at_falls_back_to_scalar(self): + s = Surface(key="x", name="X", reflectivity=98.5) + assert s.reflectivity_at(420) == 98.5 + + def test_reflectivity_at_interpolates_spectrum(self): + s = Surface( + key="x", + name="X", + reflectivity_spectrum={"wavelengths_nm": [400, 500], "values": [98.0, 99.0]}, + ) + assert s.reflectivity_at(450) == pytest.approx(98.5) + assert s.reflectivity_at(200) == 98.0 # clamped + + def test_lut_entries_declare_reflectivity_absent(self): + """A LUT IS the angular reflectance; a scalar would discard it.""" + s = surfaces["davis.polished_esr"] + assert s.reflectivity is None + assert s.absent("reflectivity").reason == "not-applicable" + + def test_detector_lut_declares_its_coupling_unknown(self): + s = surfaces["davis.detector"] + assert s.coupling is None + assert s.absent("coupling").reason == "not-measured" + + def test_repr_is_informative(self): + assert "PolishedESRGrease_LUT" in repr(surfaces["davis.polished_esr_grease"]) + + +class TestLoaderErrors: + def _load(self, tmp_path, body): + p = tmp_path / "s.toml" + p.write_text(dedent(body)) + return load_surfaces(p) + + def test_bad_sources_table_raises(self, tmp_path): + with pytest.raises(ValueError, match="_sources must be a table"): + self._load( + tmp_path, + """ + [surface.x] + name = "X" + _sources = "nope" + """, + ) + + def test_unknown_absent_reason_raises(self, tmp_path): + with pytest.raises(ValueError, match="unknown reason"): + self._load( + tmp_path, + """ + [surface.x] + name = "X" + [surface.x._absent] + reflectivity = { reason = "dunno" } + """, + ) + + def test_abstract_parent_yields_only_children(self, tmp_path): + cat = self._load( + tmp_path, + """ + [surface.fam] + abstract = true + model = "specular" + [surface.fam.a] + name = "A" + reflectivity = 90.0 + """, + ) + assert set(cat) == {"fam.a"} + assert cat["fam.a"].model == "specular" + + +class TestInheritedVariant: + """The `[lyso.Ce.polished]` shape the brief asks us to confirm end to end. + + Accepted as a substance-with-treatment variant. NOT carrying a + `default_surface` — see ADR-0004 §9. + """ + + def test_variant_exists_with_its_treatment(self): + p = pymat.lyso.Ce.polished + assert p.name == "LYSO:Ce, polished" + assert p.treatment == "polished" + assert p.path == "lyso.Ce.polished" + + def test_variant_inherits_parent_optical_scalars(self): + opt = pymat.lyso.Ce.polished.properties.optical + assert opt.light_yield == 33000 # from lyso.Ce + assert opt.refractive_index == 1.82 # from lyso + assert opt.emission_peak == 420 # from lyso + assert opt.dopant == "Ce" # from lyso.Ce + assert opt.rise_time == 0.072 # from lyso.Ce + + def test_variant_inherits_the_self_absorption_channel(self): + opt = pymat.lyso.Ce.polished.properties.optical + assert opt.absorption_length_reabs_at(420).magnitude == 588.0 + + def test_variant_inherits_provenance(self): + p = pymat.lyso.Ce.polished + assert p.source_of("optical.absorption_length_reabs").citation == "bosca_lopez_2023" + assert p.cite("optical.absorption_length_reabs").startswith("@misc{") + + def test_variant_inherits_declared_absences(self): + p = pymat.lyso.Ce.polished + assert p.is_absent("optical.emission_spectrum") + assert p.is_absent("optical.reemit_qe") + + def test_variant_does_not_carry_an_assembly_default(self): + """ADR-0004 §9: which finish a polished crystal is wrapped in is a + fact about a built detector, not about the crystal.""" + opt = pymat.lyso.Ce.polished.properties.optical + assert not hasattr(opt, "default_surface") + assert getattr(opt, "default_surface", None) is None + + def test_pairing_a_material_with_a_finish_is_a_two_key_lookup(self): + """What a consumer actually does instead — and both halves resolve.""" + crystal = pymat.lyso.Ce.polished + finish = surfaces["davis.polished_esr_grease"] + assert crystal.properties.optical.n_at(420) == 1.82 + assert finish.treatment == crystal.treatment == "polished" + assert finish.is_optical_contact + + +class TestConcreteDetectorConfiguration: + """The 8x8 LYSO / 0.2 mm BaSO4 septum / Al wrap / grease-SiPM module that + strata simulates first. Every key it needs must resolve, and the numbers + must be the cited ones.""" + + def test_crystal_resolves_with_its_optics(self): + c = pymat.lyso.Ce.polished + assert c.treatment == "polished" + assert c.properties.optical.n_at(420) == 1.82 + assert c.properties.optical.light_yield == 33000 + + def test_septum_reflector_resolves_and_is_cited(self): + s = surfaces["diffuse.baso4_air"] + assert s.model == "diffuse" + assert s.coupling == "air_gap" + baso4 = pymat.materials[s.reflector_material] + # The number that dominates the whole light-collection model. + assert baso4.properties.optical.reflectivity_at(420) == pytest.approx(99.90) + assert baso4.properties.optical.reflectivity_at(450) == pytest.approx(99.90) + assert baso4.source_of("optical.reflectivity_spectrum").ref == "10.1364/AO.7.002289" + + def test_baso4_reflectance_is_far_above_a_naive_estimate(self): + """0.97^40 = 0.30 against 0.999^40 = 0.96. Pinned because the whole + reflector-loss story turns on which of those is right.""" + r = pymat.baso4.properties.optical.reflectivity_at(420) / 100.0 + assert r > 0.99 + assert r**40 > 0.9 + + def test_outer_wrap_resolves_with_derived_reflectance(self): + s = surfaces["specular.aluminium_air"] + assert s.model == "specular" + al = pymat.materials[s.reflector_material] + r = al.properties.optical.normal_reflectance_at(420) + assert 92.0 < r < 93.0 + # Derived, never stored — so it cannot drift from the n,k it comes from. + assert al.properties.optical.reflectivity is None + assert s.absent("reflectivity").reason == "not-measured" + + def test_readout_stack_indices_resolve_on_both_sides(self): + """grease -> window. The CS part steps DOWN in index (1.465 -> 1.41), + which puts a TIR cone at the readout face; the PE part steps UP and + does not. Getting the variant wrong changes the physics.""" + grease = pymat.materials["bc630"].properties.optical.refractive_index + cs = pymat.materials["sipm_window_silicone"].properties.optical.refractive_index + pe = pymat.materials["sipm_window_epoxy"].properties.optical.refractive_index + assert grease == 1.465 + assert cs == 1.41 and cs < grease # TIR at this boundary + assert pe == 1.55 and pe > grease # no TIR at this boundary + + def test_the_alternative_couplant_is_worse_in_the_blue(self): + """Q2-3067 transmits only 70% at 400 nm against BC-630's flat ~95%, + which matters for a 420 nm emitter.""" + q = pymat.materials["q2_3067"].properties.optical + assert q.transparency_at(400) == pytest.approx(70.0) + assert q.transparency_at(500) == pytest.approx(87.0) + assert q.refractive_index == pytest.approx(1.4658) + + def test_no_photodetector_is_in_the_material_registry(self): + """ADR-0004 §11. The window is a material; the device is not.""" + for key in ("s13360", "s13360_3050cs", "sipm", "mppc"): + assert key not in pymat.materials + + def test_no_assembly_pairing_is_catalogued(self): + """`contact.grease_sipm` was requested and refused: it would carry no + measured number of its own, only a pairing of two materials that each + already carry theirs. Pairing is the consumer's job (ADR-0004 §3).""" + assert "contact.grease_sipm" not in surfaces + assert not [s for s in surfaces.values() if s.key.startswith("contact.")] diff --git a/tests/test_toml_integrity.py b/tests/test_toml_integrity.py index 4890cf7..806654f 100644 --- a/tests/test_toml_integrity.py +++ b/tests/test_toml_integrity.py @@ -19,6 +19,7 @@ from __future__ import annotations import re +import subprocess import sys import warnings from pathlib import Path @@ -33,7 +34,15 @@ from pymat import _CATEGORY_BASES, load_all from pymat.loader import load_category -DATA_DIR = Path(__file__).resolve().parent.parent / "src" / "pymat" / "data" +REPO_ROOT = Path(__file__).resolve().parent.parent +DATA_DIR = REPO_ROOT / "src" / "pymat" / "data" + +# Material-catalogue TOMLs. `surfaces.toml` (#243) lives in the same directory +# but is not a material catalogue — its nodes are `Surface` entries with their +# own field vocabulary and their own loader, so the material-shape walkers below +# do not apply to it. Its integrity is covered by tests/test_surfaces.py. +NON_MATERIAL_TOMLS = {"surfaces.toml"} +MATERIAL_TOMLS = sorted(p for p in DATA_DIR.glob("*.toml") if p.name not in NON_MATERIAL_TOMLS) # The loader accepts these top-level groups inside a material node. # Anything else (other than child material keys + known leaf keys) @@ -116,7 +125,7 @@ def test_every_declared_base_key_resolves(self, all_materials): class TestTOMLShape: """Lint the raw TOML tree, not just the loaded material objects.""" - @pytest.mark.parametrize("toml_path", sorted(DATA_DIR.glob("*.toml"))) + @pytest.mark.parametrize("toml_path", MATERIAL_TOMLS) def test_no_pbr_section(self, toml_path): """3.0 removed [pbr] — the loader rejects it, but catching it in the data files themselves gives a clearer error on contributor PRs.""" @@ -126,7 +135,7 @@ def test_no_pbr_section(self, toml_path): f"{toml_path.name}: legacy [pbr] section(s) present (3.0 uses [vis]): {offenders}" ) - @pytest.mark.parametrize("toml_path", sorted(DATA_DIR.glob("*.toml"))) + @pytest.mark.parametrize("toml_path", MATERIAL_TOMLS) def test_only_known_property_groups(self, toml_path): """Catch typos like [metals.aluminum.mechnical] — the loader would silently ignore the misspelled group, but the data would then be @@ -241,3 +250,95 @@ def _walk_unknown_groups(node, prefix: str): def _looks_like_material_node(d: dict) -> bool: """Heuristic: a child material has a `name` string at its own level.""" return isinstance(d.get("name"), str) + + +# --------------------------------------------------------------------------- +# Structural invariants (#243) +# --------------------------------------------------------------------------- +# These check the SHAPE of the file rather than the meaning of its values. +# +# Motivation: three latent defects in this branch were positional — invisible +# to any test of the thing itself, and visible only when something adjacent +# moved. The enricher appends a key at the end of a material's span, which +# lands it AFTER the following section banner; it parses correctly, so nothing +# fails, but it is filed under the wrong heading and the next person to insert +# a table beside it captures it into their own. +# +# Fixed by hand twice (metals.toml, then scintillators.toml) before being +# written down as an invariant. A structural property is checkable without +# knowing what any key means, which is what makes this class routinizable at +# all: "every key is adjacent to its table header" needs no domain knowledge. + + +def _load_check_data_shape(): + """Import `scripts/check_data_shape.py` as a module. + + The unit-level placement tests below exercise the same function the + pre-commit hook runs. Duplicating the logic here is what would let the + hook and the suite drift apart. + """ + import importlib.util + + path = REPO_ROOT / "scripts" / "check_data_shape.py" + spec = importlib.util.spec_from_file_location("check_data_shape", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_shape = _load_check_data_shape() +misplaced_keys = _shape.misplaced_keys + + +class TestStructuralPlacement: + """Exercises `scripts/check_data_shape.py` through its real entry point. + + The logic lives in the script, not here, so the pre-commit hook and the + test suite cannot disagree about what the invariant is — which would be + the exact drift these gates exist to prevent. + """ + + def test_the_shipped_corpus_is_clean(self): + proc = subprocess.run( + [sys.executable, str(REPO_ROOT / "scripts" / "check_data_shape.py")], + capture_output=True, + text=True, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + + def test_the_check_detects_a_planted_misplacement(self): + """Auditing the auditor: a structural check that cannot fail is worth + nothing, so prove it fires on the historical defect shape.""" + planted = ( + "[a.optical]\nrefractive_index = 1.5\n\n" + "# ====================\n# SECTION B\n# ====================\n" + 'absorption_length = 200.0\n\n[b]\nname = "B"\n' + ) + found = list(misplaced_keys(planted)) + assert len(found) == 1 + assert found[0][1] == "absorption_length" + assert found[0][2] == "[a.optical]" + + def test_prose_comments_do_not_trip_it(self): + """Most values in these files carry an explanatory comment. Flagging + those would make the check unusable, and an unusable check gets + deleted rather than obeyed.""" + ok = ( + "[a.optical]\n" + "# Refractive index at the sodium D line, per the vendor sheet.\n" + "refractive_index = 1.5\n" + "# A second explanatory note, several words long.\n" + "light_yield = 32000\n" + ) + assert list(misplaced_keys(ok)) == [] + + def test_multiline_arrays_do_not_trip_it(self): + """`decay_components` spans lines; its continuations are not keys.""" + arr = ( + "[a.optical]\n" + "decay_components = [\n" + " { tau_ns = 12.0, fraction = 0.3 },\n" + " { tau_ns = 42.0, fraction = 0.7 },\n" + "]\n" + ) + assert list(misplaced_keys(arr)) == [] diff --git a/tests/test_wavelength_curves.py b/tests/test_wavelength_curves.py new file mode 100644 index 0000000..2b65a17 --- /dev/null +++ b/tests/test_wavelength_curves.py @@ -0,0 +1,821 @@ +"""Tests for #243 — `WavelengthCurve` and the wavelength accessors. + +Per ADR-0004 §4: +- New `WavelengthCurve` in `pymat.curves`, mirroring `TempCurve`: piecewise + linear, clamp-outside-range, validated at construction and therefore at load. +- New structured optical slots: `absorption_length_spectrum`, the + `_matrix`/`_reabs` self-absorption split, `reemit_qe`, `reflectivity`. +- New accessors `n_at`, `absorption_length_at`, `emission_at`, + `absorption_length_matrix_at`, `absorption_length_reabs_at` — spectrum + beats scalar, and they take a Pint Quantity or bare nm. +- `refractive_index_at(T)` keeps meaning temperature. That is the whole + reason the wavelength accessors got different names. +""" + +from __future__ import annotations + +import math +from textwrap import dedent + +import pytest + +from pymat.curves import TempCurve, WavelengthCurve +from pymat.loader import load_toml +from pymat.properties import OpticalProperties +from pymat.units import ureg + + +class TestWavelengthCurveInterpolation: + def test_exact_knot_returns_knot_value(self): + c = WavelengthCurve(wavelengths_nm=[400, 500, 600], values=[1.0, 2.0, 3.0]) + assert c.interpolate(500.0) == pytest.approx(2.0) + + def test_between_knots_linear_interp(self): + c = WavelengthCurve(wavelengths_nm=[400, 500], values=[1.0, 2.0]) + assert c.interpolate(450.0) == pytest.approx(1.5) + + def test_below_min_clamps(self): + c = WavelengthCurve(wavelengths_nm=[400, 500], values=[1.8, 1.7]) + assert c.interpolate(250.0) == 1.8 + + def test_above_max_clamps(self): + c = WavelengthCurve(wavelengths_nm=[400, 500], values=[1.8, 1.7]) + assert c.interpolate(900.0) == 1.7 + + def test_single_point_curve_returns_constant(self): + c = WavelengthCurve(wavelengths_nm=[420.0], values=[1.82]) + assert c.interpolate(300.0) == 1.82 + assert c.interpolate(420.0) == 1.82 + assert c.interpolate(800.0) == 1.82 + + def test_range_nm_reports_measured_span(self): + c = WavelengthCurve(wavelengths_nm=[400, 500, 600], values=[1, 2, 3]) + assert c.range_nm == (400, 600) + + def test_clamping_is_visible_via_range(self): + """A consumer must be able to tell it got a clamped value. + + The curve never raises out-of-range — it clamps, per ADR-0003 §2 — so + `range_nm` is the only way a resampler can know where the data stops. + """ + c = WavelengthCurve(wavelengths_nm=[400, 600], values=[1.0, 2.0]) + lo, hi = c.range_nm + assert c.interpolate(300) == c.interpolate(lo) + assert c.interpolate(900) == c.interpolate(hi) + + +class TestWavelengthCurveValidation: + def test_unsorted_raises_at_construction(self): + with pytest.raises(ValueError, match="sorted"): + WavelengthCurve(wavelengths_nm=[500, 400, 600], values=[1.0, 2.0, 3.0]) + + def test_equal_adjacent_knots_raise(self): + with pytest.raises(ValueError, match="sorted"): + WavelengthCurve(wavelengths_nm=[400, 400], values=[1.0, 2.0]) + + def test_mismatched_lengths_raise(self): + with pytest.raises(ValueError, match="length"): + WavelengthCurve(wavelengths_nm=[400, 500, 600], values=[1.0, 2.0]) + + def test_empty_raises(self): + with pytest.raises(ValueError, match="at least one"): + WavelengthCurve(wavelengths_nm=[], values=[]) + + +class TestWavelengthCurveFromToml: + """The on-disk shapes predate this primitive, so `from_toml` has to accept + all three value-column spellings that #153 / #164 already wrote.""" + + def test_canonical_values_column(self): + c = WavelengthCurve.from_toml({"wavelengths_nm": [400, 500], "values": [1.0, 2.0]}) + assert c.interpolate(450) == pytest.approx(1.5) + + def test_dispersion_n_column(self): + c = WavelengthCurve.from_toml({"wavelengths_nm": [400, 500], "n": [1.9, 1.8]}) + assert c.interpolate(400) == 1.9 + + def test_emission_intensities_column(self): + c = WavelengthCurve.from_toml({"wavelengths_nm": [400, 500], "intensities": [0.5, 1.0]}) + assert c.interpolate(500) == 1.0 + + def test_explicit_value_key_wins(self): + raw = {"wavelengths_nm": [400, 500], "n": [1.9, 1.8], "k": [0.1, 0.2]} + c = WavelengthCurve.from_toml(raw, value_key="k") + assert c.interpolate(400) == 0.1 + + def test_ambiguous_columns_raise(self): + """Two value columns must not be silently disambiguated — the file's + meaning would then depend on this function's internal ordering.""" + raw = {"wavelengths_nm": [400, 500], "values": [1, 2], "n": [3, 4]} + with pytest.raises(ValueError, match="ambiguous"): + WavelengthCurve.from_toml(raw) + + def test_missing_value_column_raises(self): + with pytest.raises(ValueError, match="no value column"): + WavelengthCurve.from_toml({"wavelengths_nm": [400, 500]}) + + def test_missing_wavelengths_raises(self): + with pytest.raises(ValueError, match="wavelengths_nm"): + WavelengthCurve.from_toml({"values": [1, 2]}) + + def test_non_table_raises(self): + with pytest.raises(ValueError, match="must be a table"): + WavelengthCurve.from_toml([1, 2, 3]) + + def test_missing_explicit_value_key_raises(self): + with pytest.raises(ValueError, match="'k'"): + WavelengthCurve.from_toml({"wavelengths_nm": [400], "n": [1.8]}, value_key="k") + + +class TestTempCurveUnchanged: + """The shared-helper refactor must not have moved TempCurve's behaviour.""" + + def test_interpolation_still_works(self): + c = TempCurve(temps_K=[100, 200, 300], values=[1.0, 2.0, 3.0]) + assert c.interpolate(150) == pytest.approx(1.5) + assert c.interpolate(50) == 1.0 + assert c.interpolate(400) == 3.0 + + def test_validation_messages_unchanged(self): + with pytest.raises(ValueError, match="sorted"): + TempCurve(temps_K=[300, 100], values=[1.0, 2.0]) + with pytest.raises(ValueError, match="length"): + TempCurve(temps_K=[100, 200], values=[1.0]) + with pytest.raises(ValueError, match="at least one"): + TempCurve(temps_K=[], values=[]) + + +class TestWavelengthUnits: + def test_bare_float_is_nanometres(self): + opt = OpticalProperties( + refractive_index_dispersion={"wavelengths_nm": [400, 500], "n": [1.9, 1.8]} + ) + assert opt.n_at(400) == 1.9 + + def test_pint_quantity_accepted(self): + opt = OpticalProperties( + refractive_index_dispersion={"wavelengths_nm": [400, 500], "n": [1.9, 1.8]} + ) + assert opt.n_at(400 * ureg.nm) == 1.9 + + def test_quantity_is_converted_not_stripped(self): + """0.4 um is 400 nm. If the magnitude were taken bare it would clamp low.""" + opt = OpticalProperties( + refractive_index_dispersion={"wavelengths_nm": [400, 500], "n": [1.9, 1.8]} + ) + assert opt.n_at(0.4 * ureg.micrometer) == pytest.approx(1.9) + assert opt.n_at(0.5 * ureg.micrometer) == pytest.approx(1.8) + + def test_non_length_quantity_raises(self): + opt = OpticalProperties(refractive_index=1.8) + with pytest.raises(ValueError, match="must be a length"): + opt.n_at(300 * ureg.kelvin) + + +class TestOpticalWavelengthAccessors: + def test_n_at_prefers_dispersion_over_scalar(self): + opt = OpticalProperties( + refractive_index=1.5, + refractive_index_dispersion={"wavelengths_nm": [400, 500], "n": [1.9, 1.8]}, + ) + assert opt.n_at(400) == 1.9 + + def test_n_at_falls_back_to_scalar(self): + assert OpticalProperties(refractive_index=1.82).n_at(420) == 1.82 + + def test_n_at_returns_none_when_nothing_set(self): + assert OpticalProperties().n_at(420) is None + + def test_absorption_length_at_carries_units(self): + opt = OpticalProperties(absorption_length=200.0) + q = opt.absorption_length_at(420) + assert q.magnitude == 200.0 + assert q.units == ureg.mm + + def test_absorption_length_spectrum_beats_scalar(self): + opt = OpticalProperties( + absorption_length=200.0, + absorption_length_spectrum={"wavelengths_nm": [400, 500], "values": [50.0, 150.0]}, + ) + assert opt.absorption_length_at(450).magnitude == pytest.approx(100.0) + + def test_self_absorption_channels_are_independent(self): + opt = OpticalProperties( + absorption_length_matrix=1200.0, + absorption_length_reabs=588.0, + reemit_qe=0.75, + ) + assert opt.absorption_length_matrix_at(420).magnitude == 1200.0 + assert opt.absorption_length_reabs_at(420).magnitude == 588.0 + assert opt.reemit_qe == 0.75 + + def test_absent_channel_returns_none_not_zero(self): + """A missing channel must be None. Zero would read as 'absorbs + instantly', which is the opposite of 'we have no measurement'.""" + opt = OpticalProperties(absorption_length_reabs=588.0) + assert opt.absorption_length_matrix_at(420) is None + + def test_emission_at_has_no_scalar_fallback(self): + """`emission_peak` is one point on a band, not its shape (ADR-0004 §4).""" + opt = OpticalProperties(emission_peak=420) + assert opt.emission_at(420) is None + + def test_emission_at_uses_spectrum(self): + opt = OpticalProperties( + emission_spectrum={"wavelengths_nm": [400, 420, 500], "intensities": [0.2, 1.0, 0.3]} + ) + assert opt.emission_at(420) == 1.0 + assert opt.emission_at(410) == pytest.approx(0.6) + + def test_curve_properties_expose_wavelength_curves(self): + opt = OpticalProperties( + refractive_index_dispersion={"wavelengths_nm": [400, 500], "n": [1.9, 1.8]}, + emission_spectrum={"wavelengths_nm": [400, 500], "intensities": [0.5, 1.0]}, + absorption_length_spectrum={"wavelengths_nm": [400, 500], "values": [10, 20]}, + ) + assert isinstance(opt.refractive_index_dispersion_curve, WavelengthCurve) + assert isinstance(opt.emission_spectrum_curve, WavelengthCurve) + assert isinstance(opt.absorption_length_curve, WavelengthCurve) + assert opt.refractive_index_dispersion_curve.range_nm == (400, 500) + + def test_curve_properties_are_none_when_slot_empty(self): + opt = OpticalProperties() + assert opt.refractive_index_dispersion_curve is None + assert opt.emission_spectrum_curve is None + assert opt.absorption_length_curve is None + + +class TestTemperatureAccessorNotBroken: + """The reason the wavelength accessors are named `n_at` and not + `refractive_index_at` overloaded on type.""" + + def test_refractive_index_at_still_means_temperature(self): + opt = OpticalProperties( + refractive_index=1.82, + refractive_index_curve=TempCurve(temps_K=[250, 350], values=[1.83, 1.81]), + ) + assert opt.refractive_index_at(300 * ureg.kelvin) == pytest.approx(1.82) + + def test_the_two_axes_do_not_interfere(self): + opt = OpticalProperties( + refractive_index=1.82, + refractive_index_curve=TempCurve(temps_K=[250, 350], values=[1.83, 1.81]), + refractive_index_dispersion={"wavelengths_nm": [400, 500], "n": [1.90, 1.80]}, + ) + assert opt.refractive_index_at(250 * ureg.kelvin) == pytest.approx(1.83) + assert opt.n_at(400) == pytest.approx(1.90) + + +class TestLoadTimeValidation: + """ADR-0004 §4: a malformed spectrum raises at load, not at first query.""" + + def _write(self, tmp_path, body): + p = tmp_path / "m.toml" + p.write_text(dedent(body)) + return p + + def test_mismatched_spectrum_raises_at_load(self, tmp_path): + p = self._write( + tmp_path, + """ + [x] + name = "X" + [x.optical] + emission_spectrum = { wavelengths_nm = [400, 500, 600], intensities = [1.0, 2.0] } + """, + ) + with pytest.raises(ValueError, match="emission_spectrum"): + load_toml(p) + + def test_unsorted_dispersion_raises_at_load(self, tmp_path): + p = self._write( + tmp_path, + """ + [x] + name = "X" + [x.optical] + refractive_index_dispersion = { wavelengths_nm = [500, 400], n = [1.8, 1.9] } + """, + ) + with pytest.raises(ValueError, match="sorted"): + load_toml(p) + + def test_valid_spectrum_round_trips_and_stays_a_dict(self, tmp_path): + """Storage is unchanged — the dataclass field stays a plain dict so the + JSON round-trip in the MCP client keeps working (ADR-0004 §4).""" + p = self._write( + tmp_path, + """ + [x] + name = "X" + [x.optical] + absorption_length_spectrum = { wavelengths_nm = [400, 500], values = [10.0, 20.0] } + """, + ) + mats = load_toml(p) + opt = mats["x"].properties.optical + assert isinstance(opt.absorption_length_spectrum, dict) + assert opt.absorption_length_at(450).magnitude == pytest.approx(15.0) + + +class TestRecoveredSilentDrops: + """Fields that existed on disk with no dataclass slot to land in, so the + loader's `hasattr` guard dropped them on every load (ADR-0004 §8).""" + + def test_esr_reflectivity_now_loads(self): + import pymat + + assert pymat.esr.properties.optical.reflectivity == 98.5 + + def test_scintillator_dopant_now_loads(self): + import pymat + + assert pymat.lyso.Ce.properties.optical.dopant == "Ce" + assert pymat.lyso.Ce.properties.optical.dopant_pct == 0.1 + assert pymat.nai.Tl.properties.optical.dopant == "Tl" + + def test_compliance_hazards_now_load(self): + import pymat + + assert pymat.hydrogen.properties.compliance.flammable is True + assert pymat.beryllia.properties.compliance.toxic is True + + def test_ferrite_permeability_moved_to_magnetic(self): + import pymat + + assert pymat.materials["ferrite"].properties.magnetic.permeability_relative == 100 + + def test_no_data_file_key_is_silently_dropped(self): + """Regression net for the whole bug class, via the same script the + pre-commit hook runs — one implementation, so the hook and the suite + cannot disagree about the invariant.""" + import subprocess + import sys + from pathlib import Path + + root = Path(__file__).resolve().parent.parent + proc = subprocess.run( + [sys.executable, str(root / "scripts" / "check_data_shape.py"), "--drop"], + capture_output=True, + text=True, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + + +class TestWavelengthSlotsAllHaveFields: + """Structural guard for the silent-drop bug class, in the direction the + corpus scan cannot see. + + `test_no_data_file_key_is_silently_dropped` only checks keys that appear in + a shipped TOML. A slot registered for validation in the loader but never + given a dataclass field would pass that scan (no file uses it yet) and then + silently drop the first time someone wrote it. That happened during #243 + with `reflectivity_spectrum`. + """ + + def test_every_validated_slot_has_a_field_to_land_in(self): + from pymat.loader import _WAVELENGTH_SLOTS + + opt = OpticalProperties() + missing = [slot for slot in _WAVELENGTH_SLOTS if not hasattr(opt, slot)] + assert not missing, ( + "these slots are validated by the loader but have no dataclass " + f"field, so the value is dropped after validation: {missing}" + ) + + def test_reflectivity_spectrum_round_trips(self, tmp_path): + p = tmp_path / "m.toml" + p.write_text( + dedent( + """ + [x] + name = "X" + [x.optical] + reflectivity = 97.0 + reflectivity_spectrum = { wavelengths_nm = [400, 500], values = [99.5, 99.8] } + """ + ) + ) + opt = load_toml(p)["x"].properties.optical + assert opt.reflectivity_spectrum is not None + assert opt.reflectivity_at(450) == pytest.approx(99.65) + assert opt.reflectivity_at(200) == pytest.approx(99.5) # clamped + + def test_reflectivity_at_falls_back_to_the_scalar(self): + assert OpticalProperties(reflectivity=98.5).reflectivity_at(420) == 98.5 + assert OpticalProperties().reflectivity_at(420) is None + + +class TestKubelkaMunk: + """K-M two-flux coefficients for diffusing media (#243). + + Added when a downstream crosstalk measurement showed a 0.2 mm BaSO4 septum + transmits, refuting an "optically thick" conclusion that had been drawn + from a reflectance argument. + """ + + KM = {"wavelengths_nm": [300, 500, 700], "k": [0.455, 0.100, 0.062], "s": [619.0, 572.0, 517.0]} + + def test_reproduces_pattersons_published_reflectance(self): + """The strongest available check: these coefficients and this formula + must reproduce the R_inf the same paper publishes. 0.9624 / 0.9815 / + 0.9846 at 300 / 500 / 700 nm.""" + opt = OpticalProperties(kubelka_munk=self.KM) + for wl, published in ((300, 96.24), (500, 98.15), (700, 98.46)): + assert opt.km_reflectance_infinite_at(wl) == pytest.approx(published, abs=0.01) + + def test_both_columns_are_readable(self): + opt = OpticalProperties(kubelka_munk=self.KM) + assert opt.km_k_at(500) == pytest.approx(0.100) + assert opt.km_s_at(500) == pytest.approx(572.0) + + def test_absent_table_yields_none_everywhere(self): + opt = OpticalProperties() + assert opt.km_k_at(420) is None + assert opt.km_reflectance_infinite_at(420) is None + assert opt.km_transmittance_at(420, 0.02) is None + + def test_transmittance_falls_with_thickness(self): + opt = OpticalProperties(kubelka_munk=self.KM) + ts = [opt.km_transmittance_at(420, d) for d in (0.01, 0.02, 0.03, 0.05, 0.06)] + assert ts == sorted(ts, reverse=True) + assert all(0.0 < t < 100.0 for t in ts) + + def test_a_thin_layer_transmits_even_when_reflectance_has_converged(self): + """The finding that mattered. Reflectance converging to its thick-layer + limit does NOT mean transmission is negligible — they are different + questions, and in a segmented detector the difference is the + inter-crystal crosstalk channel.""" + opt = OpticalProperties(kubelka_munk=self.KM) + t = opt.km_transmittance_at(420, 0.02) # 0.2 mm + assert t > 5.0, "a 0.2 mm septum is not opaque" + # Even the vendor-recommended coating thickness still transmits. + assert opt.km_transmittance_at(420, 0.06) > 1.0 + + def test_both_columns_validate_at_load(self, tmp_path): + """k and s are one model's parameters — a malformed `s` must raise even + though `k` is fine, which a single-column validator would miss.""" + p = tmp_path / "m.toml" + p.write_text( + dedent( + """ + [x] + name = "X" + [x.optical] + kubelka_munk = { wavelengths_nm = [300, 500], k = [0.4, 0.1], s = [619.0] } + """ + ) + ) + with pytest.raises(ValueError, match="length"): + load_toml(p) + + +class TestBaSO4TwoSourceDisagreement: + """py-mat carries two cited primaries for BaSO4 reflectance that disagree + by 1.3-2.3 points. Both are right for their own sample; the gap is the + packing-density sensitivity, and it is why this number ships as a bracket. + """ + + def test_the_two_routes_really_do_disagree(self): + import pymat + + opt = pymat.baso4.properties.optical + grum = opt.reflectivity_at(420) + patterson = opt.km_reflectance_infinite_at(420) + assert grum == pytest.approx(99.90, abs=0.01) + assert patterson == pytest.approx(97.18, abs=0.05) + assert grum > patterson + + def test_the_disagreement_is_documented_not_silent(self): + """Carrying two inconsistent numbers is defensible. Carrying them + without saying so is not.""" + import pymat + + note = pymat.baso4.source_of("optical.kubelka_munk").note + assert "TWO-SOURCE DISAGREEMENT" in note + assert "bracket" in note + + def test_patterson_sits_near_the_bottom_of_the_shipped_bracket(self): + """The bracket published to consumers is 0.98-0.999. An independent + primary landing at 0.9718 is evidence the bracket was not overdrawn.""" + import pymat + + patterson = pymat.baso4.properties.optical.km_reflectance_infinite_at(420) / 100.0 + assert 0.96 < patterson < 0.98 + + +class TestKubelkaMunkFiniteLayer: + """R(d), T(d), A(d) — the fates of a photon meeting a real reflector. + + Added when a downstream reframing showed that a high semi-infinite + reflectance and a leaking septum are the same finite-thickness solution, + not two competing facts. + """ + + KM = {"wavelengths_nm": [300, 500, 700], "k": [0.455, 0.100, 0.062], "s": [619.0, 572.0, 517.0]} + + def _opt(self): + return OpticalProperties(kubelka_munk=self.KM) + + def test_split_closes_to_one_hundred_percent(self): + """R + T + A = 100 by construction, at every thickness. If this drifts, + photons are being created or destroyed.""" + opt = self._opt() + for mm in (0.05, 0.1, 0.2, 0.5, 1.0, 5.0): + r, t, a = opt.km_split_at(420, mm / 10) + assert r + t + a == pytest.approx(100.0, abs=1e-9) + assert r >= 0 and t >= 0 and a >= 0 + + def test_finite_reflectance_is_below_the_thick_layer_limit(self): + opt = self._opt() + r_finite = opt.km_reflectance_at(420, 0.02) + r_inf = opt.km_reflectance_infinite_at(420) + assert r_finite < r_inf + assert r_finite == pytest.approx(91.9, abs=0.1) + assert r_inf == pytest.approx(97.18, abs=0.05) + + def test_reflectance_rises_and_transmission_falls_with_thickness(self): + opt = self._opt() + rs = [opt.km_reflectance_at(420, d) for d in (0.01, 0.02, 0.03, 0.05, 0.1)] + ts = [opt.km_transmittance_at(420, d) for d in (0.01, 0.02, 0.03, 0.05, 0.1)] + assert rs == sorted(rs) + assert ts == sorted(ts, reverse=True) + + def test_reflectance_converges_to_the_thick_layer_limit(self): + opt = self._opt() + assert opt.km_reflectance_at(420, 5.0) == pytest.approx( + opt.km_reflectance_infinite_at(420), abs=0.01 + ) + + def test_split_agrees_with_the_standalone_transmittance_accessor(self): + opt = self._opt() + _, t, _ = opt.km_split_at(420, 0.02) + assert t == pytest.approx(opt.km_transmittance_at(420, 0.02)) + + def test_zero_absorption_gives_a_perfect_thick_reflector(self): + """The limit that is easy to get backwards: with k = 0 the thick-layer + reflectance is exactly 1, not 0.999. Absorption is the ONLY thing that + puts R_inf below unity — it is not a small correction to a + non-absorbing model, it is the whole reason the asymptote exists.""" + opt = OpticalProperties( + kubelka_munk={"wavelengths_nm": [400, 500], "k": [0.0, 0.0], "s": [572.0, 572.0]} + ) + assert opt.km_reflectance_infinite_at(450) == pytest.approx(100.0) + r, t, a = opt.km_split_at(450, 0.02) + assert a == pytest.approx(0.0) + # ...and the non-absorbing layer still leaks: R = sd/(1+sd). + sd = 572.0 * 0.02 + assert r == pytest.approx(100.0 * sd / (1 + sd), abs=1e-9) + assert t == pytest.approx(100.0 / (1 + sd), abs=1e-9) + + def test_absorbed_fraction_is_never_negative(self): + """The defect that blocked this branch at review. + + `km_split_at` once took a `backing_reflectance`, and with it above zero + returned NEGATIVE absorption — because K-M's `R` with a backing is the + reflectance of the composite (layer plus backing, including light that + crossed and came back), while `T` stays the layer's own transmittance. + They are not two parts of one photon budget, so `A := 100 - R - T` + stopped meaning "absorbed". + + The only test then exercising it asserted `bright > black`, which + passed throughout. A conservation property has to be checked as a + conservation property; an ordering assertion cannot see this. + """ + opt = self._opt() + for wl in (350, 420, 500, 700): + for d in (0.001, 0.01, 0.02, 0.1, 1.0, 10.0): + r, t, a = opt.km_split_at(wl, d) + assert a >= 0.0, f"negative absorption at {wl} nm, {d} cm: {a}" + assert r >= 0.0 and t >= 0.0 + assert r + t + a == pytest.approx(100.0, abs=1e-9) + + def test_no_backing_parameter_is_exposed(self): + """Pins the removal. Re-adding it needs a physical definition of the + decomposition, not a default argument.""" + import inspect + + params = inspect.signature(OpticalProperties.km_split_at).parameters + assert "backing_reflectance" not in params + assert list(params) == ["self", "wavelength", "thickness_cm", "incidence_deg"] + + def test_baso4_header_numbers_are_what_the_code_computes(self): + """The R/T/A table written into the TOML header is a claim about this + code's output. Pin it, so prose and behaviour cannot diverge.""" + import pymat + + opt = pymat.baso4.properties.optical + for mm, exp_r, exp_t in ((0.1, 85.4, 14.4), (0.2, 91.9, 7.6), (0.6, 96.4, 2.3)): + r, t, _ = opt.km_split_at(420, mm / 10) + assert r == pytest.approx(exp_r, abs=0.05), mm + assert t == pytest.approx(exp_t, abs=0.05), mm + + +class TestObliqueIncidence: + """`incidence_deg` on the K-M accessors (#243). + + The accessor is for COLLIMATED light at a known angle. These tests pin the + mathematics of `1/cos(theta)`; they deliberately do NOT assert any physical + angle for a real detector, because two successive attempts to do that were + both wrong — see `TestDiffuseReflectorErasesAngle` below for why the + reflector, not the geometry, sets the angle. + """ + + KM = {"wavelengths_nm": [300, 500, 700], "k": [0.455, 0.100, 0.062], "s": [619.0, 572.0, 517.0]} + + def _opt(self): + return OpticalProperties(kubelka_munk=self.KM) + + def test_obliquity_factor_is_one_over_cos(self): + f = OpticalProperties.obliquity_factor + assert f(0) == pytest.approx(1.0) + assert f(60) == pytest.approx(2.0) + assert f(76.7) == pytest.approx(4.34, abs=0.02) + + def test_obliquity_factor_is_capped_at_grazing(self): + """1/cos diverges at 90 degrees; a plane-parallel slab model has stopped + describing anything real well before that, so it caps rather than + returning infinity.""" + f = OpticalProperties.obliquity_factor + assert f(89.99) == 40.0 + assert f(90) == 40.0 + assert f(120) == 40.0 # |theta| folded + assert f(-60) == pytest.approx(2.0) + + def test_default_is_normal_incidence(self): + opt = self._opt() + assert opt.km_split_at(420, 0.02) == opt.km_split_at(420, 0.02, 0.0) + + def test_a_long_enough_path_recovers_the_semi_infinite_limit(self): + """Pure mathematics of the accessor: enough path in any guise reaches + the thick-layer limit. 76.7 degrees is used as an ARBITRARY long-path + example — it is not a claim about any real geometry. An earlier version + of this test asserted it was the physical angle in a wrapped crystal; + that was retracted twice over.""" + opt = self._opt() + r_normal = opt.km_reflectance_at(420, 0.02, incidence_deg=0) + r_grazing = opt.km_reflectance_at(420, 0.02, incidence_deg=76.7) + r_inf = opt.km_reflectance_infinite_at(420) + assert r_normal == pytest.approx(91.9, abs=0.1) + assert r_grazing == pytest.approx(96.9, abs=0.1) + assert r_inf - r_grazing < 0.5, "grazing should nearly reach the thick-layer limit" + assert r_inf - r_normal > 5.0, "normal incidence is nowhere near it" + + def test_transmission_collapses_with_angle(self): + """Transmission collapses with path length. Both figures are outputs of + the model at the stated angle, not assertions about a detector.""" + opt = self._opt() + assert opt.km_transmittance_at(420, 0.02, 0) == pytest.approx(7.63, abs=0.05) + assert opt.km_transmittance_at(420, 0.02, 76.7) == pytest.approx(1.35, abs=0.05) + + def test_split_still_closes_at_every_angle(self): + opt = self._opt() + for th in (0, 30, 60, 76.7, 85, 89): + r, t, a = opt.km_split_at(420, 0.02, incidence_deg=th) + assert r + t + a == pytest.approx(100.0, abs=1e-9), th + + def test_reflectance_rises_monotonically_with_angle(self): + opt = self._opt() + rs = [ + opt.km_reflectance_at(420, 0.02, incidence_deg=t) for t in (0, 15, 30, 45, 60, 75, 85) + ] + assert rs == sorted(rs) + + def test_oblique_thin_matches_normal_thick(self): + """A consistency check on the mechanism: doubling the path by angle + must equal doubling it by thickness.""" + opt = self._opt() + by_angle = opt.km_split_at(420, 0.02, incidence_deg=60) # 1/cos(60) = 2 + by_thickness = opt.km_split_at(420, 0.04, incidence_deg=0) + for a, b in zip(by_angle, by_thickness): + assert a == pytest.approx(b, abs=1e-9) + + +class TestDiffuseReflectorErasesAngle: + """A Lambertian reflector destroys the angular distribution it is given. + + Recorded as executable physics because two independent, plausible, + peer-reviewed-by-both-sides attempts to reason about septum incidence angle + were wrong in the same way: both treated the angle as a property of the + crystal geometry when the reflector sets it. + """ + + KM = {"wavelengths_nm": [300, 500, 700], "k": [0.455, 0.100, 0.062], "s": [619.0, 572.0, 517.0]} + + def test_lambertian_mean_cosine_is_two_thirds(self): + """<|cos|> over a cosine-weighted hemisphere is exactly 2/3, i.e. 48.19 + degrees. No aspect ratio appears anywhere in that statement.""" + n = 400_000 + mean_cos = sum(math.sqrt((i + 0.5) / n) for i in range(n)) / n + assert mean_cos == pytest.approx(2 / 3, abs=1e-3) + assert math.degrees(math.acos(2 / 3)) == pytest.approx(48.19, abs=0.01) + + def test_mean_path_multiplier_is_not_one_over_mean_cosine(self): + """Jensen: <1/cos> = 2 for a Lambertian distribution, while 1/ is + 1.5. Reaching for the second is a natural mistake and gives the wrong + path length.""" + n = 400_000 + mean_inv_cos = sum(1.0 / math.sqrt((i + 0.5) / n) for i in range(n)) / n + assert mean_inv_cos == pytest.approx(2.0, rel=2e-3) + assert 1.0 / (2 / 3) == pytest.approx(1.5) + + def test_evaluating_at_the_mean_angle_is_close_but_not_equal(self): + """T is nonlinear in path, so != T(). Here the gap is + ~0.1 points — small, but it is a real approximation and not an + identity.""" + opt = OpticalProperties(kubelka_munk=self.KM) + n = 20_000 + t_avg = ( + sum( + opt.km_transmittance_at( + 420, 0.02, incidence_deg=math.degrees(math.acos(math.sqrt((i + 0.5) / n))) + ) + for i in range(n) + ) + / n + ) + t_at_mean = opt.km_transmittance_at(420, 0.02, 48.19) + assert t_avg == pytest.approx(t_at_mean, abs=0.25) + assert t_avg != t_at_mean + + def test_a_thin_septum_is_not_optically_thick_at_realistic_angles(self): + """The claim that died twice. At the Lambertian mean angle a 0.2 mm + septum still transmits ~5%, and at normal incidence ~7.6%. Only an + unphysical ~77 degrees would make it behave semi-infinite, and a + diffuse reflector cannot deliver that.""" + opt = OpticalProperties(kubelka_munk=self.KM) + assert opt.km_transmittance_at(420, 0.02, 48.19) == pytest.approx(5.1, abs=0.1) + assert opt.km_transmittance_at(420, 0.02, 0) == pytest.approx(7.6, abs=0.1) + r_inf = opt.km_reflectance_infinite_at(420) + r_real = opt.km_reflectance_at(420, 0.02, incidence_deg=48.19) + r_unphysical = opt.km_reflectance_at(420, 0.02, incidence_deg=76.7) + # ~3 points short of the limit at the realistic angle... + assert r_inf - r_real > 2.5 + # ...and roughly an order of magnitude closer at the angle that was + # claimed and then retracted. The difference between the two is the + # whole of the dead argument. + assert (r_inf - r_unphysical) < (r_inf - r_real) / 5 + + +class TestOpticalThicknessDegeneracy: + """`thickness_cm` and `incidence_deg` enter only as a product (#243). + + This degeneracy is why a falsified mechanism kept producing correct + numbers: "77 degrees at 0.2 mm" and "0.87 mm at normal incidence" are the + same optical thickness, so no check on the OUTPUT can separate them. + Recorded as a test because it is a property of the model that a fitter + needs to know before fitting. + """ + + KM = {"wavelengths_nm": [300, 500, 700], "k": [0.455, 0.100, 0.062], "s": [619.0, 572.0, 517.0]} + + def test_angle_and_thickness_are_indistinguishable(self): + opt = OpticalProperties(kubelka_munk=self.KM) + by_angle = opt.km_split_at(420, 0.02, incidence_deg=76.7) + by_thickness = opt.km_split_at(420, 0.02 * OpticalProperties.obliquity_factor(76.7)) + for a, b in zip(by_angle, by_thickness): + assert a == pytest.approx(b, abs=1e-9) + + def test_thick_layer_reflectance_cannot_validate_a_thickness(self): + """A proposed falsification test that turned out to be blind, kept as + an executable statement of WHY. + + `R_inf` depends only on `k/s`; `d` cancels. So checking a thick layer + against the published `R_inf` passes identically for any thickness + multiplier, and cannot detect one that is wrong. A test that a wrong + model passes is not a weak test, it is a non-test.""" + opt = OpticalProperties(kubelka_munk=self.KM) + r_inf = opt.km_reflectance_infinite_at(420) + for multiplier in (1.0, 1.49, 2.0, 4.34, 10.0): + assert opt.km_reflectance_at(420, 5.0 * multiplier) == pytest.approx(r_inf, abs=1e-6) + + +class TestThickLayerNumerics: + """The hyperbolic form overflows for large optical thickness; the thick + limit is branched before it can. Found by running the degeneracy check + above at a 50 cm layer.""" + + KM = {"wavelengths_nm": [300, 500, 700], "k": [0.455, 0.100, 0.062], "s": [619.0, 572.0, 517.0]} + + @pytest.mark.parametrize("thickness_cm", [1.0, 5.0, 50.0, 1e4, 1e6]) + def test_no_overflow_and_split_still_closes(self, thickness_cm): + opt = OpticalProperties(kubelka_munk=self.KM) + r, t, a = opt.km_split_at(420, thickness_cm) + assert r + t + a == pytest.approx(100.0, abs=1e-9) + assert t >= 0.0 + + def test_thick_branch_is_exact_not_approximate(self): + """`coth -> 1` gives `R = 1/(a+b)`, and `(1+x+sqrt(x^2+2x))` times + `(1+x-sqrt(x^2+2x))` is identically 1 — so the branch returns exactly + `R_inf`, not a value near it.""" + opt = OpticalProperties(kubelka_munk=self.KM) + assert opt.km_reflectance_at(420, 1e6) == pytest.approx( + opt.km_reflectance_infinite_at(420), abs=1e-12 + ) + + def test_the_branch_boundary_is_continuous(self): + """No step at the bsd > 20 cutover.""" + opt = OpticalProperties(kubelka_munk=self.KM) + below = opt.km_reflectance_at(420, 0.9) + above = opt.km_reflectance_at(420, 1.1) + assert abs(above - below) < 0.01