""" 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: //spd.csv full spectrum, wavelength_nm,value (380-1050 nm) //tm30.csv TM-30-18 hue-bin data for the color vector graphic //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()