y / n / Enter-if-unknown at the station; only known values are written, and the scaffolded device page gets a Dimmable spec.
284 lines
12 KiB
Python
284 lines
12 KiB
Python
"""
|
|
Lamp comparison export — one measurement, one publishable data bundle.
|
|
|
|
Takes a reading from the HPCS 6500 (or an existing pcap capture) and writes
|
|
the per-lamp files consumed by the buildfor.life comparison pages:
|
|
|
|
<out>/<name>/spd.csv full spectrum, wavelength_nm,value (380-1050 nm)
|
|
<out>/<name>/tm30.csv TM-30-18 hue-bin data for the color vector graphic
|
|
<out>/<name>/metrics.json photometric / colorimetric / CRI / TM-30 /
|
|
electrical summary
|
|
|
|
Usage:
|
|
uv run lamp_export.py --name philips-a60-8w
|
|
uv run lamp_export.py --name x --manufacturer Philips --model "A60 8W 927"
|
|
uv run lamp_export.py --name x --readings 5 # average 5 readings
|
|
uv run lamp_export.py --name x --voltage 230 --frequency 50 --settle 60
|
|
uv run lamp_export.py --name x --passive # vendor SW drives the instrument
|
|
uv run lamp_export.py --name x --parse captures/run.pcap
|
|
|
|
With --voltage/--frequency/--current/--mode (or --psu), the built-in supply is
|
|
configured, switched on for the measurement, and switched off afterwards.
|
|
--settle waits after power-on so the lamp stabilizes before the first reading.
|
|
"""
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import sys
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from hpcs6500 import HPCS6500, find_hpcs_port, parse_pcap_messages
|
|
|
|
SCALAR_GROUPS = {
|
|
"photometric": ["Phi_lm", "eta_lm_W", "CCT_K", "Duv", "SDCM", "TLCI"],
|
|
"chromaticity": ["x", "y", "u", "v", "u_prime", "v_prime", "CIE_X", "CIE_Y", "CIE_Z"],
|
|
"radiometric": [
|
|
"Phi_e_mW", "Phi_euv_mW", "Phi_eb_mW", "Phi_ey_mW",
|
|
"Phi_er_mW", "Phi_efr_mW", "Phi_eir_mW", "Phi_e_total",
|
|
],
|
|
"electrical": ["Voltage_V", "Current_A", "Power_W", "Freq_Hz", "PF", "UThd", "AThd"],
|
|
"sensor": ["PeakSignal", "DarkSignal", "Compensate"],
|
|
}
|
|
|
|
|
|
def average_readings(readings):
|
|
"""Element-wise average of scalars and spectra across readings."""
|
|
result = dict(readings[0])
|
|
n = len(readings)
|
|
if n == 1:
|
|
return result
|
|
for key, value in readings[0].items():
|
|
if isinstance(value, (int, float)):
|
|
result[key] = sum(r.get(key, 0.0) for r in readings) / n
|
|
elif key == "spectrum":
|
|
result[key] = [
|
|
sum(r["spectrum"][i] for r in readings) / n
|
|
for i in range(len(value))
|
|
]
|
|
return result
|
|
|
|
|
|
def readings_from_pcap(path):
|
|
"""Extract parsed readings from a pcap capture."""
|
|
messages = parse_pcap_messages(path)
|
|
blocks = [m for m in messages if m["dir"] == "RX" and len(m["data"]) == 3904
|
|
and m["data"][:8] == b"HPCS6500"]
|
|
elec_blocks = [m for m in messages if m["dir"] == "RX" and len(m["data"]) == 1584]
|
|
dev = HPCS6500.__new__(HPCS6500)
|
|
readings = []
|
|
for i, block in enumerate(blocks):
|
|
r = dev._parse_measurement(block["data"])
|
|
if i < len(elec_blocks):
|
|
r.update(dev._parse_electrical(elec_blocks[i]["data"]))
|
|
readings.append(r)
|
|
return readings
|
|
|
|
|
|
def readings_from_device(port, count, passive, psu):
|
|
dev = HPCS6500(port)
|
|
name = dev.identify()
|
|
if name:
|
|
print(f"Device: {name}")
|
|
readings = []
|
|
supply = None
|
|
try:
|
|
if psu["mode"]:
|
|
ok = dev.set_mode(psu["mode"])
|
|
print(f"Mode {psu['mode'].upper()}: {'OK' if ok else 'FAILED'}")
|
|
if psu["voltage"] is not None:
|
|
if psu["mode"] == "dc":
|
|
ok = dev.set_dc_voltage(psu["voltage"])
|
|
else:
|
|
ok = dev.set_ac_voltage(psu["voltage"])
|
|
print(f"Voltage {psu['voltage']:g} V: {'OK' if ok else 'FAILED'}")
|
|
if psu["frequency"] is not None:
|
|
ok = dev.set_ac_frequency(psu["frequency"])
|
|
print(f"Frequency {psu['frequency']:g} Hz: {'OK' if ok else 'FAILED'}")
|
|
if psu["current"] is not None:
|
|
ok = dev.set_dc_current(psu["current"])
|
|
print(f"DC current limit {psu['current']:g} A: {'OK' if ok else 'FAILED'}")
|
|
|
|
supply = dev.read_psu_settings()
|
|
|
|
if psu["enable"]:
|
|
if not dev.psu_on():
|
|
print("ERROR: failed to turn PSU on")
|
|
sys.exit(1)
|
|
print("PSU on")
|
|
if psu["settle"] > 0:
|
|
print(f"Settling {psu['settle']:g} s ...")
|
|
time.sleep(psu["settle"])
|
|
else:
|
|
time.sleep(0.2)
|
|
|
|
for i in range(count):
|
|
print(f"Reading {i + 1}/{count} ...")
|
|
if passive:
|
|
r = dev.read_current()
|
|
else:
|
|
# Vendor single-shot cycle (verified from USB captures):
|
|
# test config -> trigger (8C 0E 02) -> poll -> read -> reset,
|
|
# repeated per reading. auto_psu stays off; we hold the PSU.
|
|
dev.send_test_config(auto_psu=False)
|
|
r = dev.take_single_reading()
|
|
if r is None:
|
|
print("ERROR: failed to get reading")
|
|
sys.exit(1)
|
|
readings.append(r)
|
|
if i < count - 1:
|
|
if psu["enable"] and not passive:
|
|
# take_single_reading() ends with an instrument reset;
|
|
# make sure the lamp stays powered for the next reading.
|
|
dev.psu_on()
|
|
if psu["interval"] > 0:
|
|
time.sleep(psu["interval"])
|
|
finally:
|
|
if psu["enable"]:
|
|
dev.psu_off()
|
|
print("PSU off")
|
|
dev.close()
|
|
return readings, supply
|
|
|
|
|
|
def write_bundle(reading, out_dir, meta, n_readings, supply=None):
|
|
# Deferred: importing colour-science takes a few seconds, so it happens
|
|
# after the readings rather than at startup.
|
|
from tm30 import compute_tm30
|
|
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
spectrum = reading.get("spectrum") or []
|
|
nm = reading.get("spectrum_nm") or []
|
|
if not spectrum or max(spectrum) <= 0:
|
|
print("ERROR: reading contains no spectrum data")
|
|
sys.exit(1)
|
|
|
|
# spd.csv — full measured range; consumers trim to visible as needed.
|
|
with open(out_dir / "spd.csv", "w", newline="") as f:
|
|
w = csv.writer(f)
|
|
w.writerow(["wavelength_nm", "value"])
|
|
for wl, val in zip(nm, spectrum):
|
|
w.writerow([f"{wl:.2f}", f"{val:.6g}"])
|
|
|
|
# TM-30 from the same spectrum.
|
|
tm30 = compute_tm30(nm, spectrum)
|
|
bin_fields = list(tm30["bins"][0].keys())
|
|
with open(out_dir / "tm30.csv", "w", newline="") as f:
|
|
w = csv.DictWriter(f, fieldnames=bin_fields)
|
|
w.writeheader()
|
|
for b in tm30["bins"]:
|
|
w.writerow({k: (f"{v:.6g}" if isinstance(v, float) else v) for k, v in b.items()})
|
|
|
|
# metrics.json — grouped scalars plus identification. ean/variant are set
|
|
# by lamp_station.py when one barcode covers several versions of a lamp
|
|
# (e.g. daylight and warm white sharing box art and EAN).
|
|
metrics = {"name": meta["name"]}
|
|
if meta.get("ean"):
|
|
metrics["ean"] = meta["ean"]
|
|
if meta.get("type"):
|
|
# Lamp technology: led, halogen, cfl, incandescent, ...
|
|
metrics["type"] = meta["type"]
|
|
if meta.get("dimmable") is not None:
|
|
metrics["dimmable"] = meta["dimmable"]
|
|
if meta.get("variant"):
|
|
metrics["variant"] = meta["variant"]
|
|
if meta.get("rated"):
|
|
# Advertised values from the packaging, keyed like their measured
|
|
# counterparts so consumers can pair them (truth-in-advertising rows).
|
|
metrics["rated"] = meta["rated"]
|
|
metrics |= {
|
|
"manufacturer": meta["manufacturer"],
|
|
"model": meta["model"],
|
|
"notes": meta["notes"],
|
|
"instrument": reading.get("device", "HPCS6500"),
|
|
"measured_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
"instrument_date": reading.get("test_date"),
|
|
"instrument_time": reading.get("test_time"),
|
|
"readings_averaged": n_readings,
|
|
"tm30": {"Rf": round(tm30["Rf"], 1), "Rg": round(tm30["Rg"], 1)},
|
|
"cri": {"Ra": reading.get("Ra")}
|
|
| {f"R{i}": reading.get(f"R{i}") for i in range(1, 16)},
|
|
}
|
|
if supply:
|
|
metrics["supply"] = supply
|
|
for group, keys in SCALAR_GROUPS.items():
|
|
metrics[group] = {k: reading[k] for k in keys if k in reading}
|
|
with open(out_dir / "metrics.json", "w") as f:
|
|
json.dump(metrics, f, indent=2)
|
|
|
|
return tm30
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Export a lamp measurement bundle")
|
|
parser.add_argument("--name", required=True,
|
|
help="Lamp slug, becomes the output directory name")
|
|
parser.add_argument("--manufacturer", default="")
|
|
parser.add_argument("--model", default="")
|
|
parser.add_argument("--notes", default="")
|
|
parser.add_argument("--out", default="lamps", help="Output base directory")
|
|
parser.add_argument("--port", help="COM port (auto-detect if omitted)")
|
|
parser.add_argument("--readings", type=int, default=1,
|
|
help="Number of readings to average (default 1)")
|
|
parser.add_argument("--passive", action="store_true",
|
|
help="Read without controlling the instrument")
|
|
parser.add_argument("--parse", metavar="PCAP",
|
|
help="Export from a pcap capture instead of the device")
|
|
|
|
psu_group = parser.add_argument_group("power supply")
|
|
psu_group.add_argument("--mode", choices=["ac", "dc"],
|
|
help="Supply mode (default ac when --voltage is given)")
|
|
psu_group.add_argument("--voltage", type=float, help="Supply voltage (V)")
|
|
psu_group.add_argument("--frequency", type=float, help="AC frequency (Hz)")
|
|
psu_group.add_argument("--current", type=float, help="DC current limit (A)")
|
|
psu_group.add_argument("--psu", action="store_true",
|
|
help="Power the lamp from the built-in supply "
|
|
"(implied by --mode/--voltage/--frequency/--current)")
|
|
psu_group.add_argument("--settle", type=float, default=0,
|
|
help="Seconds to wait after PSU on before the first reading")
|
|
parser.add_argument("--interval", type=float, default=1.0,
|
|
help="Seconds to wait between readings (default 1)")
|
|
args = parser.parse_args()
|
|
|
|
psu = {
|
|
"mode": args.mode,
|
|
"voltage": args.voltage,
|
|
"frequency": args.frequency,
|
|
"current": args.current,
|
|
"settle": args.settle,
|
|
"interval": args.interval,
|
|
"enable": args.psu or args.mode is not None or args.voltage is not None
|
|
or args.frequency is not None or args.current is not None,
|
|
}
|
|
|
|
supply = None
|
|
if args.parse:
|
|
readings = readings_from_pcap(args.parse)
|
|
if not readings:
|
|
print(f"ERROR: no measurement blocks in {args.parse}")
|
|
sys.exit(1)
|
|
print(f"Using {len(readings)} reading(s) from capture")
|
|
else:
|
|
port = args.port or find_hpcs_port()
|
|
if not port:
|
|
print("ERROR: HPCS 6500 not found. Connect the device or specify --port.")
|
|
sys.exit(1)
|
|
readings, supply = readings_from_device(port, args.readings, args.passive, psu)
|
|
|
|
reading = average_readings(readings)
|
|
out_dir = Path(args.out) / args.name
|
|
meta = {k: getattr(args, k) for k in ("name", "manufacturer", "model", "notes")}
|
|
tm30 = write_bundle(reading, out_dir, meta, len(readings), supply)
|
|
|
|
print(f"\nWrote {out_dir}/spd.csv, tm30.csv, metrics.json")
|
|
print(f" {reading.get('Phi_lm', 0):.0f} lm {reading.get('eta_lm_W', 0):.1f} lm/W "
|
|
f"{reading.get('CCT_K', 0):.0f} K Ra {reading.get('Ra', 0):.1f}")
|
|
print(f" TM-30: Rf {tm30['Rf']:.1f} Rg {tm30['Rg']:.1f}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|