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.
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.interpsemantics (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_pctis colour'sR_cs(already in percent),rhsisR_hs,rf_his the mean of the per-sample Rf,i of the samples whose reference hue falls into the bin (NaN if none) - exactly whattm30.pyreports, which is not colour'sR_fs(Rf of the mean ΔE).*_normdivide by the norm of the bin's reference average (1.0 if that norm is exactly 0, as intm30.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 andM_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).