# tm30 ANSI/IES TM-30-18 colour rendition metrics (fidelity index Rf, gamut index Rg, 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 } pub enum Error { NotVisible, InvalidInput(String) } pub fn compute(wavelengths_nm: &[f64], values: &[f64]) -> Result; // 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 (LA = 100, Yb = 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 Rf,i 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` (Rf 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 \ --lamps-dir /apps/web/comparison-data/lamps ``` `tools/golden.py` writes `tests/golden/*.json` (input wavelengths/values and all outputs of `compute_tm30`; NaN as `null`).