From 15d8444cacdb238f58bbfa056bc6fa07ac930c84 Mon Sep 17 00:00:00 2001 From: grabowski Date: Thu, 5 Feb 2026 09:13:07 +0700 Subject: [PATCH] Add calibration.py and force auto integration in spectrometer.py Co-Authored-By: Claude Opus 4.5 --- calibration.py | 895 ++++++++++++++++++++++++++++++++++++++++++++++++ spectrometer.py | 1 + 2 files changed, 896 insertions(+) create mode 100644 calibration.py diff --git a/calibration.py b/calibration.py new file mode 100644 index 0000000..39e4137 --- /dev/null +++ b/calibration.py @@ -0,0 +1,895 @@ +#!/usr/bin/env python3 +""" +LED Light Calibration Tool + +Calibrates the bilinear interpolation table for CCT/Lumen mixing ratios. +Works with an external measurement script that writes to measurement.json. + +Usage: +1. Start the external measurement script (reads spectrometer/lux meter) +2. Run this calibration tool +3. The tool sets test points and waits for measurements +4. After all points are measured, it generates a calibration table +""" + +import serial +import time +import json +import sys +import argparse +import subprocess +from pathlib import Path + +# Calibration grid (matches firmware) +CCT_POINTS = [2600, 2900, 3200, 3500, 3800, 4100, 4400, 4700, 5000] +LUMEN_POINTS = [200, 375, 550, 725, 900] +NUM_CCT = len(CCT_POINTS) +NUM_LUMEN = len(LUMEN_POINTS) + +# UART commands +CMD_SET_COLOR = 0x81 +CMD_SET_INTENSITY = 0x82 +CMD_PROFILE_OFF = 0x84 +CMD_CAL_START = 0x8A +CMD_CAL_WRITE = 0x8B +CMD_CAL_COMMIT = 0x8C +CMD_CAL_CLEAR = 0x8D +CMD_CAL_READ = 0x8E +CMD_CAL_WRITE_Y = 0x8F +RSP_ACK = 0xC0 + +# Default y-data tables (mA points: 0, 11, 33, 65, 150 -> lumen output) +Y27_DEFAULT = [0, 39, 179, 369, 844] +Y50_DEFAULT = [0, 51, 217, 441, 969] +X_DATA = [0, 11, 33, 65, 150] # mA drive points + +# Default paths +CALIBRATION_OUTPUT = Path(__file__).parent / "calibration_table.json" +DEFAULT_SPECTROMETER = Path(__file__).parent / "spectrometer.py" + + +class CalibrationTool: + def __init__(self, port, baudrate=115200, spectrometer_script=None): + self.port = port + self.baudrate = baudrate + self.spectrometer_script = spectrometer_script or DEFAULT_SPECTROMETER + self.ser = None + self.measurements = {} # (cct, lumen) -> {'measured_cct': x, 'measured_lumen': y} + + def connect(self): + """Connect to the light via serial port.""" + self.ser = serial.Serial(self.port, self.baudrate, timeout=1) + time.sleep(0.5) # Wait for connection to stabilize + self.ser.reset_input_buffer() + print(f"Connected to {self.port}") + + def disconnect(self): + """Disconnect from the light.""" + if self.ser: + self.ser.close() + self.ser = None + + def send_command(self, cmd, payload, timeout=2.0): + """Send a 5-byte command and wait for response, skipping diagnostic messages.""" + if len(payload) != 4: + raise ValueError("Payload must be 4 bytes") + + # Flush any pending input + self.ser.reset_input_buffer() + + data = bytes([cmd]) + bytes(payload) + self.ser.write(data) + + # Read 5-byte messages until we get a response (0xC0 or 0xC1) + start = time.time() + while time.time() - start < timeout: + response = self.ser.read(5) + if len(response) < 5: + continue + + # Check if this is a command response (0xC0=ACK, 0xC1=NAK) + if response[0] == RSP_ACK: + # Verify the echoed command matches what we sent + if response[1] == cmd: + return True + else: + print(f"ACK mismatch: sent 0x{cmd:02x}, got {response.hex()}") + # Keep reading, this might be from a previous command + continue + elif response[0] == 0xC1: # RSP_NAK + print(f"NAK received: {response.hex()}") + return False + # Otherwise it's a diagnostic message, skip it + + print("Timeout waiting for response") + return False + + def set_intensity(self, intensity): + """Set light intensity (0-1000 lux target).""" + lo = intensity & 0xFF + hi = (intensity >> 8) & 0xFF + return self.send_command(CMD_SET_INTENSITY, [lo, hi, 0, 0]) + + def set_color(self, cct): + """Set color temperature (2600-5000K).""" + lo = cct & 0xFF + hi = (cct >> 8) & 0xFF + return self.send_command(CMD_SET_COLOR, [lo, hi, 0, 0]) + + def disable_profile(self): + """Disable automatic profile, switch to manual mode.""" + return self.send_command(CMD_PROFILE_OFF, [0, 0, 0, 0]) + + def cal_start(self): + """Start calibration write sequence (erases flash).""" + result = self.send_command(CMD_CAL_START, [0, 0, 0, 0], timeout=5.0) + print(f" cal_start result: {result}") + return result + + def cal_write(self, index, value): + """Write a single calibration value (index 0-44, value 0-255).""" + result = self.send_command(CMD_CAL_WRITE, [index, value, 0, 0]) + if not result: + print(f" cal_write failed at index {index}, value {value}") + return result + + def cal_write_y(self, table, index, value): + """Write a y-data calibration value. table: 0=y27, 1=y50. value: uint16.""" + lo = value & 0xFF + hi = (value >> 8) & 0xFF + return self.send_command(CMD_CAL_WRITE_Y, [table, index, lo, hi]) + + def cal_commit(self): + """Commit calibration data to flash.""" + return self.send_command(CMD_CAL_COMMIT, [0, 0, 0, 0], timeout=5.0) + + def cal_clear(self): + """Clear calibration (revert to defaults).""" + return self.send_command(CMD_CAL_CLEAR, [0, 0, 0, 0]) + + def write_calibration_table(self, table, y27_cal=None, y50_cal=None, verbose=True): + """Write calibration data to the device. + + Args: + table: 9x5 mixing ratio table + y27_cal: 5-element list of lumen values for 2700K LED (optional) + y50_cal: 5-element list of lumen values for 5000K LED (optional) + """ + if verbose: + print("Writing calibration to device...") + + # Ensure clean UART state before starting + self.ser.reset_input_buffer() + time.sleep(0.1) + + if not self.cal_start(): + if verbose: + print(" Failed to start calibration write") + return False + + time.sleep(1.0) # Wait for flash erase to complete + + # Write mixing ratios + index = 0 + for i, row in enumerate(table): + for j, value in enumerate(row): + if not self.cal_write(index, value): + if verbose: + print(f" Failed to write mixing ratio index {index}") + return False + index += 1 + time.sleep(0.05) + + # Write y27 data (use defaults if not provided) + y27 = y27_cal if y27_cal else Y27_DEFAULT + for i, value in enumerate(y27): + if not self.cal_write_y(0, i, int(value)): + if verbose: + print(f" Failed to write y27[{i}]") + return False + time.sleep(0.05) + + # Write y50 data (use defaults if not provided) + y50 = y50_cal if y50_cal else Y50_DEFAULT + for i, value in enumerate(y50): + if not self.cal_write_y(1, i, int(value)): + if verbose: + print(f" Failed to write y50[{i}]") + return False + time.sleep(0.05) + + time.sleep(0.5) # Wait before commit + + if not self.cal_commit(): + if verbose: + print(" Failed to commit calibration") + return False + + if verbose: + print(" Calibration written successfully!") + return True + + def cal_read(self, index): + """Read a single calibration value. Returns (index, value, valid) or None.""" + self.ser.write(bytes([CMD_CAL_READ, index, 0, 0, 0])) + response = self.ser.read(5) + if len(response) < 5 or response[0] != RSP_ACK: + return None + return (response[1], response[2], response[3]) + + def read_calibration_table(self, verbose=True): + """Read the entire calibration table from device.""" + if verbose: + print("Reading calibration table from device...") + table = [] + valid = None + for i in range(NUM_CCT): + row = [] + for j in range(NUM_LUMEN): + index = i * NUM_LUMEN + j + result = self.cal_read(index) + if result is None: + if verbose: + print(f" Failed to read index {index}") + return None + row.append(result[1]) + if valid is None: + valid = result[2] + table.append(row) + + if verbose: + status = "calibrated" if valid else "defaults" + print(f" Read complete ({status})") + return table, valid + + def set_test_point(self, cct, lumen, verbose=False): + """Set the light to a specific CCT and lumen test point.""" + if verbose: + print(f" Setting CCT={cct}K, Lumen={lumen}") + + if not self.set_color(cct): + if verbose: + print(" Failed to set color") + return False + time.sleep(0.2) + + if not self.set_intensity(lumen): + if verbose: + print(" Failed to set intensity") + return False + + return True + + def take_measurement(self, timeout=30, verbose=False): + """ + Call spectrometer script to take a measurement. + + Expected JSON output: {"lumen": 450.5, "cct": 3250} + """ + if verbose: + print(f" Taking measurement...") + try: + result = subprocess.run( + ['uv', 'run', 'python', str(self.spectrometer_script), '--measure'], + capture_output=True, + text=True, + timeout=timeout + ) + if result.returncode != 0: + if verbose: + print(f" Spectrometer error: {result.stderr.strip()}") + return None + data = json.loads(result.stdout) + if 'lumen' in data and 'cct' in data: + return data + if verbose: + print(f" Invalid response: missing lumen or cct") + return None + except subprocess.TimeoutExpired: + if verbose: + print(f" Timeout waiting for spectrometer") + return None + except json.JSONDecodeError as e: + if verbose: + print(f" Invalid JSON from spectrometer: {e}") + return None + except FileNotFoundError: + if verbose: + print(f" Spectrometer script not found: {self.spectrometer_script}") + return None + + def measure_all_points(self): + """Measure all calibration points once. Returns True if all succeeded. + + Points are ordered to minimize drive current changes between consecutive + measurements: sorted by lumen (ascending), with CCT in snake pattern + (alternating direction) to avoid large jumps. + """ + total_points = len(CCT_POINTS) * len(LUMEN_POINTS) + current_point = 0 + success_count = 0 + + # Generate measurement order: snake pattern to minimize current jumps + # At each lumen level, alternate CCT direction + measurement_order = [] + for i, lumen in enumerate(LUMEN_POINTS): + ccts = CCT_POINTS if i % 2 == 0 else list(reversed(CCT_POINTS)) + for cct in ccts: + measurement_order.append((cct, lumen)) + + for cct, lumen in measurement_order: + current_point += 1 + key = (cct, lumen) + + print(f" [{current_point}/{total_points}] CCT={cct}K, Lumen={lumen}", end="") + + # Set test point + if not self.set_test_point(cct, lumen): + print(" - FAILED to set") + continue + + # Wait for light to stabilize + time.sleep(4.0) + + # Take measurement with plausibility check + max_retries = 3 + for attempt in range(max_retries): + measurement = self.take_measurement() + if not measurement: + if attempt < max_retries - 1: + time.sleep(2.0) + continue + print(" - NO READING") + break + + # Plausibility check: deviation must be < 60% + cct_dev = abs(measurement['cct'] - cct) / cct + lumen_dev = abs(measurement['lumen'] - lumen) / lumen if lumen > 0 else 0 + + if cct_dev > 0.6 or lumen_dev > 0.6: + if attempt < max_retries - 1: + time.sleep(4.0) + continue + print(f" - IMPLAUSIBLE ({cct_dev*100:.0f}%/{lumen_dev*100:.0f}%)") + break + + # Measurement is plausible, store it + self.measurements[key] = { + 'target_cct': cct, + 'target_lumen': lumen, + 'measured_cct': measurement['cct'], + 'measured_lumen': measurement['lumen'] + } + cct_err = (measurement['cct'] - cct) / cct * 100 + lumen_err = (measurement['lumen'] - lumen) / lumen * 100 + print(f" -> CCT:{cct_err:+5.1f}% Lum:{lumen_err:+5.1f}%") + success_count += 1 + break + + return success_count == total_points + + def print_deviation_table(self): + """Print a table showing deviation of all measured points.""" + print("\n" + "=" * 70) + print("DEVIATION TABLE (% error from target)") + print("=" * 70) + + # Header + header = " |" + for lumen in LUMEN_POINTS: + header += f" {lumen:4d}lm |" + print(header) + print("-" * len(header)) + + max_cct_dev = 0 + max_lumen_dev = 0 + all_within_tolerance = True + cct_devs = [] + lumen_devs = [] + + for cct in CCT_POINTS: + row = f"{cct:5d}K |" + for lumen in LUMEN_POINTS: + key = (cct, lumen) + if key in self.measurements: + m = self.measurements[key] + cct_dev = (m['measured_cct'] - cct) / cct * 100 + lumen_dev = (m['measured_lumen'] - lumen) / lumen * 100 + cct_devs.append(cct_dev) + lumen_devs.append(lumen_dev) + max_cct_dev = max(max_cct_dev, abs(cct_dev)) + max_lumen_dev = max(max_lumen_dev, abs(lumen_dev)) + if abs(cct_dev) > 5 or abs(lumen_dev) > 5: + all_within_tolerance = False + row += f"{cct_dev:+4.1f}/{lumen_dev:+4.1f}|" + else: + row += " --- |" + all_within_tolerance = False + print(row) + + # Calculate standard deviation + def calc_std(values): + if len(values) < 2: + return 0.0 + mean = sum(values) / len(values) + variance = sum((x - mean) ** 2 for x in values) / len(values) + return variance ** 0.5 + + cct_std = calc_std(cct_devs) + lumen_std = calc_std(lumen_devs) + + print("-" * len(header)) + print(f"Format: CCT%/Lumen% | Max CCT: {max_cct_dev:.1f}% | Max Lumen: {max_lumen_dev:.1f}%") + print(f"Standard deviation | CCT: {cct_std:.2f}% | Lumen: {lumen_std:.2f}%") + + return all_within_tolerance, max_cct_dev, max_lumen_dev + + def calibrate_y_data(self): + """Calibrate y-data tables by measuring lumen output at fixed drive points. + + Returns (y27_cal, y50_cal) or (None, None) on failure. + """ + print("\n" + "=" * 70) + print("Y-DATA CALIBRATION") + print("=" * 70) + print("Measuring lumen output at fixed drive currents") + print(f"Drive points (mA): {X_DATA}") + print("=" * 70) + + # First, clear any existing calibration to use defaults + print("\nClearing existing calibration to use default tables...") + time.sleep(0.2) # Brief delay to ensure UART is idle + self.ser.reset_input_buffer() # Clear any pending diagnostics + if not self.cal_clear(): + print("Failed to clear calibration") + return None, None + time.sleep(1.0) + + # Calibrate 2700K LED (use 2600K CCT = pure warm LED, mixing ratio 0) + print("\n--- Calibrating 2700K LED (pure warm) ---") + y27_cal = self._measure_led_curve(2600, Y27_DEFAULT) + if y27_cal is None: + return None, None + + # Turn off LEDs and wait for thermal stabilization before switching + print("\nTurning off LEDs, waiting 10s for thermal stabilization...") + self.set_intensity(0) + time.sleep(10.0) + + # Calibrate 5000K LED (use 5000K CCT = pure cool LED, mixing ratio 255) + print("\n--- Calibrating 5000K LED (pure cool) ---") + y50_cal = self._measure_led_curve(5000, Y50_DEFAULT) + if y50_cal is None: + return None, None + + print("\n" + "=" * 70) + print("Y-DATA CALIBRATION RESULTS") + print("=" * 70) + print(f" mA points: {X_DATA}") + print(f" y27 (2700K): {y27_cal} (default: {Y27_DEFAULT})") + print(f" y50 (5000K): {y50_cal} (default: {Y50_DEFAULT})") + + return y27_cal, y50_cal + + def _measure_led_curve(self, cct, default_lumens): + """Measure lumen output at each drive point for a single LED type. + + Args: + cct: Color temperature to set (2600 for pure 2700K LED, 5000 for pure 5000K LED) + default_lumens: Default y-data values to use as intensity targets + + Returns: + List of 5 measured lumen values, or None on failure + """ + measured = [] + + # Set color (this determines which LED is primarily driven) + if not self.set_color(cct): + print(f" Failed to set color to {cct}K") + return None + time.sleep(0.5) + + for i, target_lumen in enumerate(default_lumens): + ma = X_DATA[i] + print(f" Point {i+1}/5: {ma:3d}mA (intensity={target_lumen:3d})", end="") + + if ma == 0: + # Zero point is always 0 lumen + measured.append(0) + print(" -> 0 lm (zero point)") + continue + + # Set intensity to the default lumen value + # With cleared calibration (defaults), this drives at the target mA + if not self.set_intensity(target_lumen): + print(" - FAILED to set intensity") + return None + + # Wait for LED to stabilize (longer for last point at max current) + stabilize_time = 5.0 if i == len(default_lumens) - 1 else 2.0 + time.sleep(stabilize_time) + + # Take measurement with retry + reading = None + for attempt in range(3): + reading = self.take_measurement() + if reading and reading['lumen'] > 0: + break + time.sleep(1.0) + + if reading is None: + print(" - NO READING") + return None + + actual_lumen = int(round(reading['lumen'])) + deviation = (actual_lumen - target_lumen) / target_lumen * 100 if target_lumen > 0 else 0 + measured.append(actual_lumen) + print(f" -> {actual_lumen:4d} lm ({deviation:+5.1f}%)") + + return measured + + def run_calibration(self, skip_y=False): + """Run full calibration: y-data first, then iterative mixing ratios. + + Args: + skip_y: If True, skip y-data calibration (only do mixing ratios) + """ + print("=" * 70) + print("LED Light Full Calibration") + print("=" * 70) + print("Phase 1: Y-data calibration (LED lumen curves)") + print("Phase 2: Mixing ratio calibration (CCT/lumen targeting)") + print("=" * 70) + + self.connect() + y27_cal = None + y50_cal = None + + try: + # Disable profile mode + print("\nDisabling profile mode...") + self.disable_profile() + time.sleep(0.5) + + # Phase 1: Y-data calibration + if not skip_y: + y27_cal, y50_cal = self.calibrate_y_data() + if y27_cal is None: + print("\nY-data calibration failed, aborting.") + return + # Write y-data immediately with default mixing ratios + print("\nWriting y-data to device...") + default_mixing = [ + [ 0, 0, 0, 0, 0], + [ 32, 38, 36, 37, 36], + [ 84, 72, 70, 71, 71], + [142, 100, 99, 99, 99], + [175, 131, 126, 127, 126], + [155, 154, 154, 157, 153], + [206, 170, 184, 184, 185], + [242, 223, 208, 216, 217], + [255, 255, 255, 255, 255] + ] + if not self.write_calibration_table(default_mixing, y27_cal, y50_cal, verbose=False): + print("Failed to write y-data, aborting.") + return + print("Y-data written, proceeding to mixing ratio calibration...") + time.sleep(1.0) + + # Phase 2: Mixing ratio calibration + print("\n" + "=" * 70) + print("MIXING RATIO CALIBRATION") + print("=" * 70) + print("Target: < 5% deviation on all points") + print("Maximum passes: 10") + + max_passes = 10 + for pass_num in range(1, max_passes + 1): + print(f"\n{'='*70}") + print(f"PASS {pass_num}/{max_passes}") + print("=" * 70) + + # Clear previous measurements for fresh pass + self.measurements = {} + + # Measure all points + print("\nMeasuring all points...") + self.measure_all_points() + + # Print deviation table + converged, max_cct, max_lumen = self.print_deviation_table() + + # Save progress + self.save_calibration() + + if converged: + print(f"\n*** CONVERGED after {pass_num} pass(es)! ***") + print("All points within 5% tolerance.") + break + + if pass_num == max_passes: + print(f"\n*** MAX PASSES REACHED ***") + print(f"Best achieved: CCT {max_cct:.1f}%, Lumen {max_lumen:.1f}%") + break + + # Generate and apply corrections + print("\nCalculating and applying corrections...") + calibrated_table = self.generate_table(verbose=False) + + if calibrated_table: + if self.write_calibration_table(calibrated_table, y27_cal, y50_cal, verbose=False): + print("Corrections applied, starting next pass...") + time.sleep(1.0) + else: + print("Failed to write calibration, stopping.") + break + + finally: + self.disconnect() + + print("\n" + "=" * 70) + print("Calibration complete!") + print("=" * 70) + + def save_calibration(self): + """Save current calibration state to file.""" + data = { + 'measurements': { + f"{k[0]},{k[1]}": v for k, v in self.measurements.items() + }, + 'cct_points': CCT_POINTS, + 'lumen_points': LUMEN_POINTS + } + with open(CALIBRATION_OUTPUT, 'w') as f: + json.dump(data, f, indent=2) + + def generate_table(self, verbose=False): + """ + Generate the calibrated mixing ratio table using direct proportional correction. + + Simple approach: mixing ratio controls CCT. + - If measured CCT < target CCT: increase ratio (more 5000K LED) + - If measured CCT > target CCT: decrease ratio (less 5000K LED) + - Correction is proportional to error, scaled by CCT-to-ratio gradient + """ + if verbose: + print("\nGenerating calibration table...") + + # Read current mixing ratios from device (baseline) + baseline_table = None + if self.ser and self.ser.is_open: + result = self.read_calibration_table(verbose=verbose) + if result: + baseline_table, _ = result + if verbose: + print("Using current device values as baseline") + + if baseline_table is None: + if verbose: + print("Using firmware defaults as baseline") + baseline_table = [ + [ 0, 0, 0, 0, 0], # 2600K + [ 32, 38, 36, 37, 36], # 2900K + [ 84, 72, 70, 71, 71], # 3200K + [142, 100, 99, 99, 99], # 3500K + [175, 131, 126, 127, 126], # 3800K + [155, 154, 154, 157, 153], # 4100K + [206, 170, 184, 184, 185], # 4400K + [242, 223, 208, 216, 217], # 4700K + [255, 255, 255, 255, 255] # 5000K + ] + + # CCT range: 2600K to 5000K = 2400K maps to ratio 0-255 + # Gradient: ~9.4 K per ratio unit (but use 10 for simpler math) + K_PER_RATIO = 10.0 + + corrections = {} + for (cct, lumen), m in self.measurements.items(): + i = CCT_POINTS.index(cct) + j = LUMEN_POINTS.index(lumen) + baseline_r = baseline_table[i][j] + + target_cct = m['target_cct'] + target_lumen = m['target_lumen'] + measured_cct = m['measured_cct'] + measured_lumen = m['measured_lumen'] + + cct_error = target_cct - measured_cct # positive = need higher CCT = more 5000K = higher ratio + lumen_error = target_lumen - measured_lumen + + # Simple proportional correction: dr = cct_error / K_PER_RATIO + # If measured CCT is 100K too low, increase ratio by 10 units + dr = cct_error / K_PER_RATIO + + # Apply correction with gain factor (can be tuned) + # Use 1.0 for full correction, <1 for damping, >1 for aggressive + gain = 1.0 + dr = dr * gain + + # Limit adjustment per iteration to prevent oscillation + dr = max(-30, min(30, dr)) + + corrections[(cct, lumen)] = { + 'cct_error': cct_error, + 'lumen_error': lumen_error, + 'lumen_error_pct': 100 * lumen_error / target_lumen if target_lumen > 0 else 0, + 'mixing_adjust': dr, + 'baseline_ratio': baseline_r + } + + # Apply corrections to generate calibrated table + calibrated_table = [] + for i, cct in enumerate(CCT_POINTS): + row = [] + for j, lumen in enumerate(LUMEN_POINTS): + baseline_r = baseline_table[i][j] + key = (cct, lumen) + if key in corrections: + c = corrections[key] + adjusted = baseline_r + c['mixing_adjust'] + adjusted = max(0, min(255, round(adjusted))) + else: + adjusted = baseline_r + row.append(int(adjusted)) + calibrated_table.append(row) + + # Print correction details + if verbose: + print("\nCorrection summary:") + print(f"{'CCT':>5} {'Lumen':>5} | {'CCT err':>8} {'Lum err':>8} | {'Base':>4} {'Adj':>6} {'New':>4}") + print("-" * 65) + for i, cct in enumerate(CCT_POINTS): + for j, lumen in enumerate(LUMEN_POINTS): + key = (cct, lumen) + if key in corrections: + c = corrections[key] + new_r = calibrated_table[i][j] + print(f"{cct:>5} {lumen:>5} | {c['cct_error']:>+7.0f}K {c['lumen_error']:>+7.0f}lm | {c['baseline_ratio']:>4} {c['mixing_adjust']:>+6.1f} {new_r:>4}") + + # Print final table + print("\nCalibrated mixing_ratios table:") + print("const uint8_t mixing_ratios[NUM_CCT][NUM_LUMEN] = {") + for i, (cct, row) in enumerate(zip(CCT_POINTS, calibrated_table)): + values = ", ".join(f"{v:3d}" for v in row) + comment = f"// {cct}K" + if i == 0: + comment += " (pure 2700K)" + elif i == len(CCT_POINTS) - 1: + comment += " (pure 5000K)" + comma = "," if i < len(CCT_POINTS) - 1 else "" + print(f" {{{values}}}{comma} {comment}") + print("};") + + # Save to calibration file + data = { + 'measurements': {f"{k[0]},{k[1]}": v for k, v in self.measurements.items()}, + 'corrections': {f"{k[0]},{k[1]}": v for k, v in corrections.items()}, + 'cct_points': CCT_POINTS, + 'lumen_points': LUMEN_POINTS, + 'baseline_table': baseline_table, + 'calibrated_table': calibrated_table + } + with open(CALIBRATION_OUTPUT, 'w') as f: + json.dump(data, f, indent=2) + if verbose: + print(f"\nCalibration saved to {CALIBRATION_OUTPUT}") + + return calibrated_table + + +def main(): + parser = argparse.ArgumentParser(description='LED Light Calibration Tool') + parser.add_argument('port', nargs='?', default='COM3', + help='Serial port (default: COM3)') + parser.add_argument('--baudrate', '-b', type=int, default=115200, + help='Baud rate (default: 115200)') + parser.add_argument('--spectrometer', '-s', type=Path, + default=DEFAULT_SPECTROMETER, + help='Path to spectrometer script (default: spectrometer.py)') + parser.add_argument('--generate-only', '-g', action='store_true', + help='Only generate table from existing measurements') + parser.add_argument('--write', '-w', action='store_true', + help='Write calibration table to device') + parser.add_argument('--clear', '-c', action='store_true', + help='Clear calibration on device (revert to defaults)') + parser.add_argument('--read', action='store_true', + help='Read calibration table from device') + parser.add_argument('--skip-y', action='store_true', + help='Skip y-data calibration (only do mixing ratios)') + parser.add_argument('--y-only', action='store_true', + help='Only run y-data calibration (no mixing ratios)') + args = parser.parse_args() + + tool = CalibrationTool(args.port, args.baudrate, args.spectrometer) + + if args.read: + # Read calibration from device + tool.connect() + try: + result = tool.read_calibration_table() + if result: + table, valid = result + status = "CALIBRATED" if valid else "DEFAULTS" + print(f"\nCalibration status: {status}") + print("\nMixing ratios table:") + for i, (cct, row) in enumerate(zip(CCT_POINTS, table)): + values = ", ".join(f"{v:3d}" for v in row) + print(f" {cct}K: [{values}]") + finally: + tool.disconnect() + elif args.clear: + # Clear calibration on device + tool.connect() + try: + if tool.cal_clear(): + print("Calibration cleared successfully") + else: + print("Failed to clear calibration") + finally: + tool.disconnect() + elif args.y_only: + # Only run y-data calibration + tool.connect() + try: + tool.disable_profile() + time.sleep(0.5) + y27_cal, y50_cal = tool.calibrate_y_data() + if y27_cal and y50_cal: + # Write y-data with default mixing ratios + print("\nWriting y-data calibration (with default mixing ratios)...") + default_mixing = [ + [ 0, 0, 0, 0, 0], # 2600K + [ 32, 38, 36, 37, 36], # 2900K + [ 84, 72, 70, 71, 71], # 3200K + [142, 100, 99, 99, 99], # 3500K + [175, 131, 126, 127, 126], # 3800K + [155, 154, 154, 157, 153], # 4100K + [206, 170, 184, 184, 185], # 4400K + [242, 223, 208, 216, 217], # 4700K + [255, 255, 255, 255, 255] # 5000K + ] + if tool.write_calibration_table(default_mixing, y27_cal, y50_cal): + print("Y-data calibration saved successfully!") + # Save to file as well + data = { + 'y27_cal': y27_cal, + 'y50_cal': y50_cal, + 'x_data': X_DATA + } + with open(CALIBRATION_OUTPUT, 'w') as f: + json.dump(data, f, indent=2) + else: + print("Failed to write y-data calibration") + finally: + tool.disconnect() + elif args.generate_only or args.write: + # Load existing measurements and generate/write table + if CALIBRATION_OUTPUT.exists(): + with open(CALIBRATION_OUTPUT, 'r') as f: + saved = json.load(f) + if 'measurements' in saved: + tool.measurements = { + tuple(map(int, k.split(','))): v + for k, v in saved['measurements'].items() + } + + # Connect to read current values from device + tool.connect() + try: + table = tool.generate_table(verbose=True) + + if args.write and table: + # Load y-data if available + y27 = saved.get('y27_cal') + y50 = saved.get('y50_cal') + tool.write_calibration_table(table, y27, y50) + finally: + tool.disconnect() + else: + print(f"No calibration file found: {CALIBRATION_OUTPUT}") + else: + tool.run_calibration(skip_y=args.skip_y) + + +if __name__ == "__main__": + main() diff --git a/spectrometer.py b/spectrometer.py index 6bdc3a0..d7564f7 100644 --- a/spectrometer.py +++ b/spectrometer.py @@ -16,6 +16,7 @@ def measure(): dev = HPCS6500(port) try: dev.identify() + dev.set_integration_time(0) # auto reading = dev.take_reading(psu=False) finally: dev.close()