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

215 lines
7.4 KiB
Python

"""Generate golden test vectors for the ``tm30`` crate.
Every case runs the production Python reference ``compute_tm30`` (HPCS
``tm30.py`` on colour-science 0.4.7) and writes one JSON file into
``crates/tm30/tests/golden`` holding the exact input (wavelengths, values)
and every output field. NaN is encoded as ``null``. Floats are written with
``repr`` so they round-trip exactly.
Usage (from the repository root):
python -W ignore crates/tm30/tools/golden.py [--tm30-dir DIR] [--lamps-dir DIR]
``--tm30-dir`` is the folder containing ``tm30.py`` (defaults to the HPCS
scratch copy), ``--lamps-dir`` the buildfor.life ``comparison-data/lamps``
folder with ``L*/spd.csv`` files (read only).
"""
from __future__ import annotations
import argparse
import csv
import glob
import json
import math
import os
import sys
import warnings
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.normpath(os.path.join(HERE, "..", "tests", "golden"))
DEFAULT_TM30_DIR = os.path.join(
os.path.expanduser("~"), "AppData", "Local", "hermes", "cache", "scratch", "hpcs-py"
)
DEFAULT_LAMPS_DIR = "C:/dev/buildfor_life_web/apps/web/comparison-data/lamps"
# HPCS 6500 grid: 350 points, 380..1050 nm.
HPCS_GRID = np.linspace(380.0, 1050.0, 350)
def clean(x):
x = float(x)
return None if math.isnan(x) else x
def read_lamp_csv(path):
wl, vals = [], []
with open(path, newline="") as f:
reader = csv.DictReader(f)
for row in reader:
wl.append(float(row["wavelength_nm"]))
vals.append(float(row["value"]))
return np.array(wl), np.array(vals)
def build_cases(lamps_dir):
import colour
from colour.colorimetry import SDS_ILLUMINANTS, SpectralShape, sd_blackbody
from colour.colorimetry import sd_CIE_illuminant_D_series
from colour.temperature import CCT_to_xy_CIE_D
cases = []
# Real lamps measured with the HPCS 6500 (380-1050 nm, ~1.92 nm steps).
for path in sorted(glob.glob(os.path.join(lamps_dir, "L*", "spd.csv"))):
lamp = os.path.basename(os.path.dirname(path))
wl, vals = read_lamp_csv(path)
cases.append((f"lamp_{lamp}", f"{lamp}/spd.csv", wl, vals))
# colour-science illuminant library.
names = (
[f"FL{i}" for i in range(1, 13)]
+ ["A", "D50", "D65"]
+ [k for k in SDS_ILLUMINANTS.keys() if k.startswith("LED-")]
+ [f"HP{i}" for i in range(1, 6)]
)
for name in names:
sd = SDS_ILLUMINANTS[name]
cases.append(
(
"illuminant_" + name.replace("-", "_").replace(".", "_"),
f"colour.SDS_ILLUMINANTS[{name!r}] {sd.shape}",
sd.wavelengths,
sd.values,
)
)
# Planckian radiators, covering the < 4000 K (pure Planckian reference)
# and 4000-5000 K (Planckian / daylight mixture) regimes.
shape = SpectralShape(360, 830, 1)
for T in (2700, 4300, 4700):
sd = sd_blackbody(T, shape)
cases.append((f"blackbody_{T}K", f"sd_blackbody({T}, {shape})", sd.wavelengths, sd.values))
# CIE daylight (> 5000 K regime).
for T in (6500, 10000):
xy = CCT_to_xy_CIE_D(T * 1.4388 / 1.4380)
sd = sd_CIE_illuminant_D_series(xy)
cases.append((f"daylight_{T}K", f"sd_CIE_illuminant_D_series(CCT_to_xy_CIE_D({T}*1.4388/1.4380))", sd.wavelengths, sd.values))
# Grid variations.
# Coarse 10 nm grid.
d50 = SDS_ILLUMINANTS["D50"]
wl10 = np.arange(380.0, 781.0, 10.0)
cases.append(("grid_10nm_D50", "D50 sampled every 10 nm", wl10, d50[wl10]))
# HPCS 6500 grid (380-1050 nm, 670/349 nm steps), blackbody and LED.
bb = sd_blackbody(3000, SpectralShape(360, 1100, 1))
cases.append(("hpcs_grid_blackbody_3000K", "sd_blackbody(3000) on 380-1050 nm / 350 points", HPCS_GRID, bb[HPCS_GRID]))
led = SDS_ILLUMINANTS["LED-B3"]
cases.append(
(
"hpcs_grid_LED_B3",
"LED-B3 linearly resampled on 380-1050 nm / 350 points (0 beyond 780)",
HPCS_GRID,
np.interp(HPCS_GRID, led.wavelengths, led.values, right=0.0),
)
)
# Irregular, non-uniform grid.
rng = np.random.default_rng(20180)
wl_irr = np.sort(np.concatenate([[371.3], rng.uniform(372.0, 800.0, 240), [801.7]]))
fl11 = SDS_ILLUMINANTS["FL11"]
cases.append(
(
"irregular_grid_FL11",
"FL11 linearly resampled on 242 random wavelengths 371-802 nm",
wl_irr,
np.interp(wl_irr, fl11.wavelengths, fl11.values),
)
)
# Partial coverage (400-700 nm only): np.interp holds the end values.
fl4 = SDS_ILLUMINANTS["FL4"]
wl_p = np.arange(400.0, 701.0, 2.5)
cases.append(
(
"partial_400_700_FL4",
"FL4 on 400-700 nm / 2.5 nm",
wl_p,
np.interp(wl_p, fl4.wavelengths, fl4.values),
)
)
# Negated spectrum: colour normalises by Y, so a negative scale must give
# exactly the positive-scale result.
fl2 = SDS_ILLUMINANTS["FL2"]
cases.append(("negated_FL2", "-1 * FL2 (scale invariance incl. sign)", fl2.wavelengths, -fl2.values))
# Noisy real lamp with negative values (dark-noise like).
if cases and cases[0][0].startswith("lamp_"):
_, _, wl, vals = cases[0]
noise = rng.normal(0.0, 0.01 * float(np.max(vals)), vals.shape)
cases.append(("noisy_" + cases[0][0], "first lamp + 1% gaussian noise (negative values)", wl, vals + noise))
return cases
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--tm30-dir", default=DEFAULT_TM30_DIR)
ap.add_argument("--lamps-dir", default=DEFAULT_LAMPS_DIR)
args = ap.parse_args()
sys.path.insert(0, args.tm30_dir)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
import colour # noqa: F401
from tm30 import compute_tm30
cases = build_cases(args.lamps_dir)
os.makedirs(OUT, exist_ok=True)
for old in glob.glob(os.path.join(OUT, "*.json")):
os.remove(old)
for name, source, wl, vals in cases:
wl = [float(x) for x in np.asarray(wl, dtype=float)]
vals = [float(x) for x in np.asarray(vals, dtype=float)]
with warnings.catch_warnings():
warnings.simplefilter("ignore")
res = compute_tm30(wl, vals)
expected = {
"Rf": clean(res["Rf"]),
"Rg": clean(res["Rg"]),
"CCT_K": clean(res["CCT_K"]),
"Duv": clean(res["Duv"]),
"bins": [
{k: (v if k == "bin" else clean(v)) for k, v in b.items()}
for b in res["bins"]
],
}
doc = {
"name": name,
"source": source,
"generator": "crates/tm30/tools/golden.py (tm30.py + colour-science "
+ __import__("colour").__version__
+ ", numpy "
+ np.__version__
+ ")",
"wavelengths": wl,
"values": vals,
"expected": expected,
}
path = os.path.join(OUT, name + ".json")
with open(path, "w", newline="\n") as f:
json.dump(doc, f, indent=1, allow_nan=False)
print(
f"{name:32s} CCT={expected['CCT_K']:9.2f} Duv={expected['Duv']: .5f} "
f"Rf={expected['Rf']:6.2f} Rg={expected['Rg']:6.2f}"
)
print(f"{len(cases)} cases -> {OUT}")
return 0
if __name__ == "__main__":
sys.exit(main())