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.
This commit is contained in:
commit
2a3f28001c
241 files changed
+46390
No files matched your search
@@ -0,0 +1,235 @@
|
||||
//! 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);
|
||||
}
|
||||
Reference in new issue
Block a user