Files
lamp-testbench/crates/tm30/README.md
T
grabowski 2a3f28001c Lamp testbench in Rust around the HPCS 6500
lampbench station / export / label / tm30 / calibrate replace the Python
lamp_station.py, lamp_export.py, label_printer.py, tm30.py and
calibration.py that lived in HPCS6500-py. The driver is a submodule
(HPCS6500/, like mppt-testbench). Workspace crates: tm30 (TM-30-18 port of
colour-science 0.4.7), brother-ql (raster protocol, byte-identical to
brother_ql) and lamp-label (QR + ID labels for the QL-820NWB).

Bundles are byte-identical to lamp_export.py's (golden tests); the station
runs end to end in a test against the simulated instrument.
2026-10-22 17:26:14 +07:00

135 lines
6.2 KiB
Markdown

# tm30
ANSI/IES TM-30-18 colour rendition metrics (fidelity index R<sub>f</sub>,
gamut index R<sub>g</sub>, CCT/Duv and the 16 hue-bin values for the colour
vector graphic) from a measured spectrum, in pure Rust (std only).
This is a numerically faithful port of the HPCS reference script `tm30.py`
(`compute_tm30`), which wraps
`colour.quality.colour_fidelity_index_ANSIIESTM3018` from
[colour-science](https://www.colour-science.org/) 0.4.7. The goal is the same
numbers, not just the same method: every array colour uses on this path is
embedded as it is after colour's own Sprague re-alignment, and the arithmetic
follows colour/numpy's evaluation order.
```rust
let r = tm30::compute(&wavelengths_nm, &values)?;
println!("Rf {:.1} Rg {:.1} CCT {:.0} K Duv {:.4}", r.rf, r.rg, r.cct_k, r.duv);
for b in &r.bins {
// b.bin (1..=16), b.ref_a/b, b.test_a/b, *_norm, b.rcs_pct, b.rhs, b.rf_h
}
```
## API
```rust
pub const N_BINS: usize = 16;
pub struct HueBin { bin, ref_a, ref_b, test_a, test_b,
ref_a_norm, ref_b_norm, test_a_norm, test_b_norm,
rcs_pct, rhs, rf_h } // all f64 except bin: u32
pub struct Tm30 { rf, rg, cct_k, duv, bins: Vec<HueBin> }
pub enum Error { NotVisible, InvalidInput(String) }
pub fn compute(wavelengths_nm: &[f64], values: &[f64]) -> Result<Tm30, Error>;
// extras
pub fn compute_detailed(..) -> Result<(Tm30, SampleData), Error>; // + per-sample J'a'b', ΔE, R_f,i
pub fn warm_up(); // build caches up front
pub const VISIBLE_MIN_NM, VISIBLE_MAX_NM, MIN_VISIBLE_POINTS;
```
Semantics (same as `tm30.py`):
* Only samples with 380 <= λ <= 780 nm are used; fewer than 10 such samples
gives `Error::NotVisible` ("spectrum does not cover the visible range").
* They are resampled with `np.interp` semantics (linear, ends held constant)
onto 380..=780 nm / 1 nm. The input may have any (ascending) grid, e.g. the
HPCS 6500's 350 points over 380-1050 nm.
* CCT/Duv: CIE 1931 2° XYZ -> CIE 1960 uv -> Ohno (2013) with colour's
1000-25000 K Planckian table (spacing 1.001, 360-780 nm CMFs).
* Reference illuminant: Planckian below 4000 K, CIE D-series above 5000 K
(M1/M2 rounded to 3 decimals, no 1.4388/1.4380 correction), Y-normalised
linear mixture in between.
* 99 CIE 2017 colour evaluation samples, CIE 1964 10° observer, CIECAM02
(L<sub>A</sub> = 100, Y<sub>b</sub> = 20, average surround, illuminant
discounted), CAM02-UCS.
* `rcs_pct` is colour's `R_cs` (already in percent), `rhs` is `R_hs`,
`rf_h` is the mean of the per-sample R<sub>f,i</sub> of the samples whose
reference hue falls into the bin (NaN if none) - exactly what `tm30.py`
reports, which is *not* colour's `R_fs` (R<sub>f</sub> of the mean ΔE).
* `*_norm` divide by the norm of the bin's reference average (1.0 if that
norm is exactly 0, as in `tm30.py`).
Deviations from the Python behaviour, on inputs the Python code does not
handle meaningfully: length mismatch, non-finite values inside 380-780 nm,
descending wavelengths and a spectrum with zero luminance return
`Error::InvalidInput` (Python raises, silently produces garbage, or returns
NaN respectively). Negative scale factors behave exactly as in Python.
## Accuracy
`tests/golden.rs` compares every output field of 52 cases generated by the
Python reference (11 HPCS lamp measurements, FL1-FL12, A, D50, D65, LED-B1..5,
LED-BH1, LED-RGB1, LED-V1/V2, HP1-HP5, blackbodies 2700/4300/4700 K, daylight
6500/10000 K, 10 nm, HPCS 380-1050 nm, irregular and partial grids, a noisy
lamp with negative values and a negated spectrum; all three reference regimes
are covered). Max deviations (this machine):
| field | max abs | max rel |
|---|---|---|
| Rf | 8.1e-11 | 1.1e-12 |
| Rg | 2.2e-11 | 1.9e-13 |
| CCT_K | 1.9e-8 K | 4.5e-12 |
| Duv | 1.2e-11 | 2.5e-10 |
| ref_a / ref_b | 2.1e-11 | 4.7e-12 |
| test_a / test_b | 2.7e-13 | 1.8e-13 |
| *_norm | 1.0e-12 | 4.6e-12 |
| Rcs_pct | 4.9e-11 | 4.0e-8 (values ~1e-3 %) |
| Rhs | 1.1e-12 | 7.7e-8 (values ~1e-5) |
| Rf_h | 1.4e-10 | 2.3e-12 |
The test requires, per value, relative error <= 1e-9 *or* absolute error
<= 1e-11 (the latter only matters for quantities that are themselves close to
zero: Duv of Planckian sources, tiny chroma/hue shifts). NaN must match NaN.
Bit-exact agreement is not possible: numpy computes the tristimulus
integrals of 2-D batches (Planckian table, 99 samples) with OpenBLAS `dgemm`,
whose blocked FMA kernel order depends on the CPU (`DYNAMIC_ARCH`), and uses
SIMD `sin`/`cos`/`exp`. Single-spectrum integrals reproduce OpenBLAS'
`dgemv` order and match bit for bit. The remaining ulp-level differences in
the Planckian table are amplified ~1000x by Ohno's parabolic/triangular
interpolation into the CCT (still ~1e-12 relative).
## Performance
`cargo run -p tm30 --release --example bench` (HPCS-like 350-point input):
~20 ms for the first call (parsing the embedded tables and building the
3438-row Planckian table, cached in `OnceLock`s), ~0.25 ms per call after
that. Call `tm30::warm_up()` at start-up to move the first cost out of the
measurement path.
## Data provenance and regeneration
`data/*.csv` are extracted from colour-science 0.4.7 (BSD-3-Clause,
Copyright 2013 Colour Developers) by `tools/extract_data.py`; the underlying
data are CIE publications:
* `cmf_cie1931_2deg_360_780.csv`, `cmf_cie1931_2deg_380_780.csv` - CIE 1931
2° standard observer (CIE 015:2004), as colour aligns it to 360-780 nm and
then 380-780 nm (both are needed, the realignment is not bit-exact).
* `cmf_cie1964_10deg_380_780.csv` - CIE 1964 10° standard observer.
* `d_series_basis_380_780.csv` - CIE D-series S0/S1/S2 (CIE 015:2004),
Sprague-interpolated from 5 nm to 1 nm by colour.
* `tcs_cie2017_380_780.csv` - the 99 CIE 2017 colour evaluation samples
(CIE 224:2017; identical to the ANSI/IES TM-30-18 CES), 1 nm.
* `ciecam02_matrices.csv` - CAT02 and `M_HPE · CAT02⁻¹` as numpy computes it.
Regenerate (needs a Python with colour-science 0.4.7 + numpy):
```sh
python -W ignore crates/tm30/tools/extract_data.py
python -W ignore crates/tm30/tools/golden.py --tm30-dir <dir with tm30.py> \
--lamps-dir <buildfor_life_web>/apps/web/comparison-data/lamps
```
`tools/golden.py` writes `tests/golden/*.json` (input wavelengths/values and
all outputs of `compute_tm30`; NaN as `null`).