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

236 lines
7.8 KiB
Rust

//! Golden tests: every case in `tests/golden/*.json` was produced by the
//! Python reference (`tools/golden.py` -> HPCS `tm30.py` -> colour-science
//! 0.4.7). Every output field is compared; NaN (encoded as `null`) must
//! match NaN.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde_json::Value;
/// Target agreement: relative difference (|a-b| / max(|a|,|b|)).
const REL_TOL: f64 = 1e-9;
/// Absolute floor for quantities that are naturally ~0 (Duv of Planckian
/// sources, hue / chroma shifts close to zero), where a relative measure is
/// meaningless. Far below any displayed precision.
const ABS_TOL: f64 = 1e-11;
fn golden_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("golden")
}
fn golden_files() -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = std::fs::read_dir(golden_dir())
.expect("tests/golden exists")
.map(|e| e.unwrap().path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
.collect();
files.sort();
files
}
fn num(v: &Value) -> f64 {
match v {
Value::Null => f64::NAN,
Value::Number(n) => n.as_f64().unwrap(),
other => panic!("expected number or null, got {other}"),
}
}
fn nums(v: &Value) -> Vec<f64> {
v.as_array().unwrap().iter().map(num).collect()
}
#[derive(Default, Clone, Copy)]
struct Dev {
max_abs: f64,
max_rel: f64,
/// Worst `min(rel / REL_TOL, abs / ABS_TOL)` (must stay <= 1).
worst: f64,
n: usize,
}
struct Checker {
devs: BTreeMap<&'static str, Dev>,
failures: Vec<String>,
}
impl Checker {
fn check(&mut self, case: &str, field: &'static str, expected: f64, got: f64) {
let dev = self.devs.entry(field).or_default();
dev.n += 1;
if expected.is_nan() || got.is_nan() {
if expected.is_nan() != got.is_nan() {
self.failures
.push(format!("{case}: {field}: expected {expected}, got {got}"));
}
return;
}
let abs = (expected - got).abs();
let scale = expected.abs().max(got.abs());
let rel = if scale > 0.0 { abs / scale } else { 0.0 };
dev.max_abs = dev.max_abs.max(abs);
dev.max_rel = dev.max_rel.max(rel);
let score = (rel / REL_TOL).min(abs / ABS_TOL);
dev.worst = dev.worst.max(score);
if score > 1.0 {
self.failures.push(format!(
"{case}: {field}: expected {expected:e}, got {got:e} (abs {abs:e}, rel {rel:e})"
));
}
}
}
#[test]
fn golden_cases_match_python() {
let files = golden_files();
assert!(
files.len() >= 40,
"expected the golden set, found {}",
files.len()
);
let mut checker = Checker {
devs: BTreeMap::new(),
failures: Vec::new(),
};
for path in &files {
let doc: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
let case = doc["name"].as_str().unwrap().to_owned();
let wl = nums(&doc["wavelengths"]);
let values = nums(&doc["values"]);
let exp = &doc["expected"];
let got = tm30::compute(&wl, &values).unwrap_or_else(|e| panic!("{case}: {e}"));
checker.check(&case, "Rf", num(&exp["Rf"]), got.rf);
checker.check(&case, "Rg", num(&exp["Rg"]), got.rg);
checker.check(&case, "CCT_K", num(&exp["CCT_K"]), got.cct_k);
checker.check(&case, "Duv", num(&exp["Duv"]), got.duv);
let bins = exp["bins"].as_array().unwrap();
assert_eq!(bins.len(), tm30::N_BINS, "{case}");
assert_eq!(got.bins.len(), tm30::N_BINS, "{case}");
for (e, g) in bins.iter().zip(&got.bins) {
assert_eq!(e["bin"].as_u64().unwrap(), u64::from(g.bin), "{case}");
let c = &mut checker;
c.check(&case, "ref_a", num(&e["ref_a"]), g.ref_a);
c.check(&case, "ref_b", num(&e["ref_b"]), g.ref_b);
c.check(&case, "test_a", num(&e["test_a"]), g.test_a);
c.check(&case, "test_b", num(&e["test_b"]), g.test_b);
c.check(&case, "ref_a_norm", num(&e["ref_a_norm"]), g.ref_a_norm);
c.check(&case, "ref_b_norm", num(&e["ref_b_norm"]), g.ref_b_norm);
c.check(&case, "test_a_norm", num(&e["test_a_norm"]), g.test_a_norm);
c.check(&case, "test_b_norm", num(&e["test_b_norm"]), g.test_b_norm);
c.check(&case, "Rcs_pct", num(&e["Rcs_pct"]), g.rcs_pct);
c.check(&case, "Rhs", num(&e["Rhs"]), g.rhs);
c.check(&case, "Rf_h", num(&e["Rf_h"]), g.rf_h);
}
}
println!("{} golden cases", files.len());
println!(
"{:<12} {:>12} {:>12} {:>12} {:>6}",
"field", "max_abs", "max_rel", "worst_score", "n"
);
for (field, d) in &checker.devs {
println!(
"{field:<12} {:>12.3e} {:>12.3e} {:>12.3e} {:>6}",
d.max_abs, d.max_rel, d.worst, d.n
);
}
assert!(
checker.failures.is_empty(),
"{} mismatches:\n{}",
checker.failures.len(),
checker.failures.join("\n")
);
}
#[test]
fn not_visible_error() {
// Entirely outside 380-780 nm.
let wl: Vec<f64> = (0..100).map(|i| 790.0 + 2.0 * i as f64).collect();
let v = vec![1.0; wl.len()];
let err = tm30::compute(&wl, &v).unwrap_err();
assert_eq!(err, tm30::Error::NotVisible);
assert_eq!(err.to_string(), "spectrum does not cover the visible range");
// Only 9 points inside the range (tm30.py requires at least 10).
let mut wl: Vec<f64> = (0..9).map(|i| 400.0 + 40.0 * i as f64).collect();
wl.extend((0..50).map(|i| 800.0 + i as f64));
let v = vec![1.0; wl.len()];
assert_eq!(tm30::compute(&wl, &v), Err(tm30::Error::NotVisible));
// Exactly 10 points inside is accepted.
let wl: Vec<f64> = (0..10).map(|i| 380.0 + 40.0 * i as f64).collect();
let v = vec![1.0; wl.len()];
assert!(tm30::compute(&wl, &v).is_ok());
}
#[test]
fn invalid_input_errors() {
use tm30::Error;
let wl: Vec<f64> = (0..=40).map(|i| 380.0 + 10.0 * i as f64).collect();
let ok = vec![1.0; wl.len()];
assert!(matches!(
tm30::compute(&wl, &ok[1..]),
Err(Error::InvalidInput(_))
));
let mut nan = ok.clone();
nan[5] = f64::NAN;
assert!(matches!(
tm30::compute(&wl, &nan),
Err(Error::InvalidInput(_))
));
let zeros = vec![0.0; wl.len()];
assert!(matches!(
tm30::compute(&wl, &zeros),
Err(Error::InvalidInput(_))
));
let mut unsorted = wl.clone();
unsorted.swap(3, 4);
assert!(matches!(
tm30::compute(&unsorted, &ok),
Err(Error::InvalidInput(_))
));
}
#[test]
fn hpcs_grid_ignores_infrared_tail() {
// The HPCS 6500 delivers 350 points over 380-1050 nm; anything past
// 780 nm must not influence the result.
let path = golden_dir().join("lamp_L0003.json");
let doc: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
let wl = nums(&doc["wavelengths"]);
let mut values = nums(&doc["values"]);
assert_eq!(wl.len(), 350);
let a = tm30::compute(&wl, &values).unwrap();
for (w, v) in wl.iter().zip(values.iter_mut()) {
if *w > 780.0 {
*v = 1e6;
}
}
let b = tm30::compute(&wl, &values).unwrap();
assert_eq!(a, b);
}
#[test]
fn scale_invariance() {
let path = golden_dir().join("illuminant_FL2.json");
let doc: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
let wl = nums(&doc["wavelengths"]);
let values = nums(&doc["values"]);
let a = tm30::compute(&wl, &values).unwrap();
let scaled: Vec<f64> = values.iter().map(|v| v * 1234.5).collect();
let b = tm30::compute(&wl, &scaled).unwrap();
assert!((a.rf - b.rf).abs() < 1e-9);
assert!((a.rg - b.rg).abs() < 1e-9);
assert!((a.cct_k - b.cct_k).abs() < 1e-6);
}