Files
lamp-testbench/crates/tm30
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
..

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 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.

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

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 (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 OnceLocks), ~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):

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).