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.
236 lines
7.8 KiB
Rust
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);
|
|
}
|