- stm32_link.py: port to the live 114-byte broadcast protocol (magic 0xAA55AA55, odd parity 8-O-1, 100 Hz publish, repetition-validated, no CRC); 39 params incl. adc4_trig_phase/iin_zero_sum, CLEAR_FLAGS, 30-bit flag table; commands stay CRC-16 framed; Telemetry aliases BroadcastData, efficiency uses iout_slow and eff_net subtracts P_sys - gui_workers.py: STM32Worker reader thread with counter dedup, rate/ loss counters, 20 s graph history, full-rate telemetry CSV writer - gui.py: right-side telemetry panel (link state, power + EFF net, heatsink/board temps, Vfly group, control, HRTIM, status-flag checkboxes, fault registers), Vfly + selectable corr/phase-ofs graphs, 20 s rolling window on all plots, dual CSV logging (merged stm_* columns + <stem>_telem.csv), logging on by default into logs/data_<timestamp>.csv, Plot Eff button - sweep guards: PSU 20 A input-current gate (conservative estimate + measured backstop + I-limit clamp), thermal pause at 57/77 C holding the load at 1 A until cooled 5 C below threshold, CC range pinned to R2 for the whole run with empirical range-max readback rejection - plot_eff.py + bench-plot entry point: efficiency vs Vin vs current maps from any logged CSV (sweep / data log / telem autodetect), file dialog when launched without args - bench.py: HIOKI FAST response speed, 5 s settle defaults; cli.py stm32-read prints the full broadcast; README + .gitignore updates Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
448 lines
16 KiB
Python
448 lines
16 KiB
Python
"""Background worker thread for instrument I/O.
|
|
|
|
Keeps all VISA/serial communication off the GUI thread so tkinter
|
|
never freezes during instrument queries.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import queue
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from enum import Enum, auto
|
|
|
|
import serial
|
|
|
|
from testbench.stm32_link import (
|
|
BroadcastData, BroadcastParser, PARAM_BY_ID, decode_param_bits,
|
|
)
|
|
|
|
|
|
class Cmd(Enum):
|
|
"""Commands sent from GUI to worker thread."""
|
|
|
|
# Supply
|
|
SET_VOLTAGE = auto()
|
|
SET_CURRENT = auto()
|
|
APPLY = auto()
|
|
OUTPUT_ON = auto()
|
|
OUTPUT_OFF = auto()
|
|
SET_OVP = auto()
|
|
SET_RISE_TIME = auto()
|
|
SET_FALL_TIME = auto()
|
|
|
|
# Load
|
|
SET_MODE = auto()
|
|
SET_MODE_VALUE = auto()
|
|
LOAD_ON = auto()
|
|
LOAD_OFF = auto()
|
|
SET_SLEW_RISE = auto()
|
|
SET_SLEW_FALL = auto()
|
|
|
|
# Meter
|
|
SETUP_ALL = auto()
|
|
SET_WIRING = auto()
|
|
SET_COUPLING = auto()
|
|
SET_RESPONSE_SPEED = auto()
|
|
SET_AVERAGING = auto()
|
|
SET_VOLTAGE_RANGE = auto()
|
|
SET_CURRENT_RANGE = auto()
|
|
SET_VOLTAGE_AUTO = auto()
|
|
SET_CURRENT_AUTO = auto()
|
|
DEGAUSS = auto()
|
|
|
|
# System
|
|
SET_INTERVAL = auto()
|
|
SAFE_OFF = auto()
|
|
|
|
|
|
class InstrumentWorker(threading.Thread):
|
|
"""Daemon thread that polls instruments and processes commands.
|
|
|
|
Usage:
|
|
worker = InstrumentWorker(bench)
|
|
worker.start()
|
|
worker.send(Cmd.SET_VOLTAGE, 48.0)
|
|
data = worker.get_data() # dict or None
|
|
worker.stop()
|
|
"""
|
|
|
|
def __init__(self, bench, interval: float = 1.0) -> None:
|
|
super().__init__(daemon=True)
|
|
self.bench = bench
|
|
self.interval = interval
|
|
self.cmd_queue: queue.Queue = queue.Queue()
|
|
self.data_queue: queue.Queue = queue.Queue(maxsize=100)
|
|
self._stop_event = threading.Event()
|
|
|
|
def send(self, cmd: Cmd, *args) -> None:
|
|
"""Queue a command for execution on the worker thread."""
|
|
self.cmd_queue.put((cmd, args))
|
|
|
|
def get_data(self) -> dict | None:
|
|
"""Get the latest measurement data (non-blocking). Returns None if empty."""
|
|
result = None
|
|
# Drain queue, keep only the latest
|
|
while True:
|
|
try:
|
|
result = self.data_queue.get_nowait()
|
|
except queue.Empty:
|
|
break
|
|
return result
|
|
|
|
def stop(self) -> None:
|
|
"""Signal the worker to stop."""
|
|
self._stop_event.set()
|
|
|
|
def run(self) -> None:
|
|
"""Main worker loop: process commands, then read instruments."""
|
|
while not self._stop_event.is_set():
|
|
# Process all pending commands
|
|
while True:
|
|
try:
|
|
cmd, args = self.cmd_queue.get_nowait()
|
|
self._execute(cmd, args)
|
|
except queue.Empty:
|
|
break
|
|
|
|
# Read all instruments
|
|
try:
|
|
data = self.bench.measure_all()
|
|
data["_error"] = None
|
|
data["_timestamp"] = time.time()
|
|
# Query output states for GUI indicators
|
|
try:
|
|
data["supply_on"] = self.bench.supply.get_output_state()
|
|
except Exception:
|
|
data["supply_on"] = None
|
|
try:
|
|
data["load_on"] = self.bench.load.get_load_state()
|
|
except Exception:
|
|
data["load_on"] = None
|
|
# Query meter channel ranges
|
|
for ch in (5, 6):
|
|
try:
|
|
data[f"v_range_{ch}"] = self.bench.meter.get_voltage_range(ch).strip()
|
|
except Exception:
|
|
data[f"v_range_{ch}"] = None
|
|
try:
|
|
data[f"i_range_{ch}"] = self.bench.meter.get_current_range(ch).strip()
|
|
except Exception:
|
|
data[f"i_range_{ch}"] = None
|
|
except Exception as e:
|
|
data = {"_error": str(e), "_timestamp": time.time()}
|
|
|
|
# Push to GUI (drop oldest if full)
|
|
try:
|
|
self.data_queue.put_nowait(data)
|
|
except queue.Full:
|
|
try:
|
|
self.data_queue.get_nowait()
|
|
except queue.Empty:
|
|
pass
|
|
self.data_queue.put_nowait(data)
|
|
|
|
self._stop_event.wait(timeout=self.interval)
|
|
|
|
def _execute(self, cmd: Cmd, args: tuple) -> None:
|
|
"""Execute a single command. Exceptions are swallowed and reported."""
|
|
try:
|
|
bench = self.bench
|
|
match cmd:
|
|
# Supply
|
|
case Cmd.SET_VOLTAGE:
|
|
bench.supply.set_voltage(args[0])
|
|
case Cmd.SET_CURRENT:
|
|
bench.supply.set_current(args[0])
|
|
case Cmd.APPLY:
|
|
bench.supply.set_current(args[1])
|
|
bench.supply.set_voltage(args[0])
|
|
case Cmd.OUTPUT_ON:
|
|
bench.supply.output_on()
|
|
case Cmd.OUTPUT_OFF:
|
|
bench.supply.output_off()
|
|
case Cmd.SET_OVP:
|
|
bench.supply.set_ovp_level(args[0])
|
|
bench.supply.set_ovp_state(True)
|
|
case Cmd.SET_RISE_TIME:
|
|
bench.supply.set_rise_time(args[0])
|
|
case Cmd.SET_FALL_TIME:
|
|
bench.supply.set_fall_time(args[0])
|
|
|
|
# Load
|
|
case Cmd.SET_MODE:
|
|
bench.load.set_mode(args[0])
|
|
case Cmd.SET_MODE_VALUE:
|
|
mode, value = args[0], args[1]
|
|
if mode == "CC":
|
|
bench.load.set_cc_current(value)
|
|
elif mode == "CR":
|
|
bench.load.set_cr_resistance(value)
|
|
elif mode == "CV":
|
|
bench.load.set_cv_voltage(value)
|
|
elif mode == "CP":
|
|
bench.load.set_cp_power(value)
|
|
case Cmd.LOAD_ON:
|
|
bench.load.load_on()
|
|
case Cmd.LOAD_OFF:
|
|
bench.load.load_off()
|
|
case Cmd.SET_SLEW_RISE:
|
|
bench.load.set_rise_slew(args[0])
|
|
case Cmd.SET_SLEW_FALL:
|
|
bench.load.set_fall_slew(args[0])
|
|
|
|
# Meter
|
|
case Cmd.SETUP_ALL:
|
|
bench.setup_all()
|
|
case Cmd.SET_WIRING:
|
|
bench.meter.set_wiring_mode(args[0])
|
|
case Cmd.SET_COUPLING:
|
|
bench.meter.set_coupling(args[0], args[1])
|
|
case Cmd.SET_RESPONSE_SPEED:
|
|
bench.meter.set_response_speed(args[0])
|
|
case Cmd.SET_AVERAGING:
|
|
bench.meter.set_averaging(args[0], args[1] if len(args) > 1 else None)
|
|
case Cmd.SET_VOLTAGE_RANGE:
|
|
bench.meter.set_voltage_auto(args[0], False)
|
|
bench.meter.set_voltage_range(args[0], args[1])
|
|
case Cmd.SET_CURRENT_RANGE:
|
|
bench.meter.set_current_auto(args[0], False)
|
|
bench.meter.set_current_range(args[0], args[1])
|
|
case Cmd.SET_VOLTAGE_AUTO:
|
|
bench.meter.set_voltage_auto(args[0], args[1])
|
|
case Cmd.SET_CURRENT_AUTO:
|
|
bench.meter.set_current_auto(args[0], args[1])
|
|
case Cmd.DEGAUSS:
|
|
channels = args[0] if args else [5, 6]
|
|
items = ",".join(f"I{ch}" for ch in channels)
|
|
bench.meter.write(f":DEMAg {items}")
|
|
|
|
# System
|
|
case Cmd.SET_INTERVAL:
|
|
self.interval = args[0]
|
|
case Cmd.SAFE_OFF:
|
|
bench.safe_off()
|
|
|
|
except Exception as e:
|
|
# Push error to data queue so GUI can display it
|
|
try:
|
|
self.data_queue.put_nowait({
|
|
"_error": f"Command {cmd.name} failed: {e}",
|
|
"_timestamp": time.time(),
|
|
})
|
|
except queue.Full:
|
|
pass
|
|
|
|
|
|
# ── STM32 broadcast reader ───────────────────────────────────────────
|
|
|
|
# All BroadcastData wire fields, in dataclass order (full-rate CSV columns).
|
|
TELEM_CSV_FIELDS = (
|
|
"counter", "vin", "vout", "iin", "iout", "vfly", "etemp", "btemp",
|
|
"vfly_integral", "vfly_avg_debug", "cc_output_f", "mppt_iref",
|
|
"mppt_last_vin", "mppt_last_iin", "p_in", "p_out", "iout_slow",
|
|
"last_tmp", "VREF", "vfly_correction", "cmp_outer", "cmp_inner",
|
|
"vfly_ofs_applied", "ctrl_mode", "vfly_active", "status_flags",
|
|
"fmac_sr", "fault_pc", "cfsr", "param_id", "param_type",
|
|
"param_value", "pong", "sys_current_ma", "iin_avg_ma",
|
|
)
|
|
_TELEM_HEX_FIELDS = frozenset({"status_flags", "fmac_sr", "fault_pc", "cfsr"})
|
|
|
|
|
|
class STM32Worker(threading.Thread):
|
|
"""Daemon thread reading the STM32 broadcast stream.
|
|
|
|
The board streams 114-byte frames continuously (100 Hz publishes, each
|
|
repeated ~3-4x at line rate); this thread validates them, keeps the
|
|
latest sample plus 20 s graph history, and owns the full-rate telemetry
|
|
CSV (single writer). Auto-reconnects on serial errors.
|
|
"""
|
|
|
|
HISTORY = 2200 # 100 Hz x 20 s + margin
|
|
CSV_FLUSH_S = 1.0 # never flush per-row at 100 rows/s
|
|
|
|
def __init__(self, port: str, baudrate: int = 460800) -> None:
|
|
super().__init__(daemon=True)
|
|
self.port = port
|
|
self.baudrate = baudrate
|
|
self.connected = False
|
|
self._stop_event = threading.Event()
|
|
self._tx_queue: queue.Queue = queue.Queue()
|
|
|
|
self._lock = threading.Lock()
|
|
self.latest: BroadcastData | None = None
|
|
self.latest_wall: float = 0.0
|
|
self._last_counter = -1
|
|
self._t: deque = deque(maxlen=self.HISTORY) # time.monotonic per fresh publish
|
|
self._vfly: deque = deque(maxlen=self.HISTORY) # mV
|
|
self._corr: deque = deque(maxlen=self.HISTORY) # vfly_correction, ticks
|
|
self._ofs: deque = deque(maxlen=self.HISTORY) # vfly_ofs_applied, ticks
|
|
self._fresh_count = 0 # validated fresh publishes since last get_rates()
|
|
self._sent_count = 0 # publishes the board sent (counter deltas)
|
|
self.params: dict[int, float] = {}
|
|
|
|
self._csv_lock = threading.Lock()
|
|
self._csv_file = None
|
|
self._csv_writer = None
|
|
self._csv_last_flush = 0.0
|
|
|
|
# ── Thread-safe API for the GUI ──────────────────────────────────
|
|
|
|
def get_latest(self) -> tuple[BroadcastData | None, float]:
|
|
"""Latest validated sample and its wall-clock arrival time."""
|
|
with self._lock:
|
|
return self.latest, self.latest_wall
|
|
|
|
def get_graph_snapshot(self, decimate: int = 4):
|
|
"""(t_mono, vfly_mV, corr, ofs) lists, decimated for display.
|
|
|
|
Decimation is anchored at the end so the newest sample always shows.
|
|
Full rate is still recorded to the CSV.
|
|
"""
|
|
with self._lock:
|
|
t, v = list(self._t), list(self._vfly)
|
|
c, o = list(self._corr), list(self._ofs)
|
|
if decimate > 1 and t:
|
|
k = (len(t) - 1) % decimate
|
|
t, v, c, o = t[k::decimate], v[k::decimate], c[k::decimate], o[k::decimate]
|
|
return t, v, c, o
|
|
|
|
def get_rates(self) -> tuple[int, int]:
|
|
"""(fresh publishes received, publishes sent) since the last call."""
|
|
with self._lock:
|
|
f, s = self._fresh_count, self._sent_count
|
|
self._fresh_count = 0
|
|
self._sent_count = 0
|
|
return f, s
|
|
|
|
def send_frame(self, data: bytes) -> None:
|
|
"""Queue a pre-built command frame for TX on the worker thread."""
|
|
self._tx_queue.put(data)
|
|
|
|
def start_csv(self, path: str) -> None:
|
|
"""Open the full-rate telemetry CSV (one row per fresh publish)."""
|
|
with self._csv_lock:
|
|
self._close_csv_locked()
|
|
f = open(path, "w", newline="", encoding="utf-8")
|
|
w = csv.writer(f)
|
|
w.writerow(("pc_time", "t_mono") + TELEM_CSV_FIELDS + ("p_in_W", "p_out_W"))
|
|
self._csv_file, self._csv_writer = f, w
|
|
self._csv_last_flush = time.monotonic()
|
|
|
|
def stop_csv(self) -> None:
|
|
with self._csv_lock:
|
|
self._close_csv_locked()
|
|
|
|
def _close_csv_locked(self) -> None:
|
|
if self._csv_file is not None:
|
|
try:
|
|
self._csv_file.close()
|
|
except OSError:
|
|
pass
|
|
self._csv_file = None
|
|
self._csv_writer = None
|
|
|
|
def stop(self) -> None:
|
|
"""Stop the thread and close the CSV (blocks up to 2 s)."""
|
|
self._stop_event.set()
|
|
if self.is_alive():
|
|
self.join(timeout=2.0)
|
|
self.stop_csv()
|
|
|
|
# ── Worker loop ──────────────────────────────────────────────────
|
|
|
|
def run(self) -> None:
|
|
ser = None
|
|
parser = None
|
|
while not self._stop_event.is_set():
|
|
if ser is None:
|
|
try:
|
|
ser = serial.Serial(
|
|
self.port, self.baudrate, timeout=0.05,
|
|
bytesize=serial.EIGHTBITS,
|
|
parity=serial.PARITY_ODD,
|
|
stopbits=serial.STOPBITS_ONE,
|
|
)
|
|
parser = BroadcastParser()
|
|
self.connected = True
|
|
except (serial.SerialException, OSError):
|
|
self.connected = False
|
|
self._stop_event.wait(1.0)
|
|
continue
|
|
try:
|
|
while True:
|
|
try:
|
|
ser.write(self._tx_queue.get_nowait())
|
|
except queue.Empty:
|
|
break
|
|
data = ser.read(4096)
|
|
if data:
|
|
for b in parser.feed(data):
|
|
self._on_frame(b)
|
|
except (serial.SerialException, OSError):
|
|
self.connected = False
|
|
try:
|
|
ser.close()
|
|
except Exception:
|
|
pass
|
|
ser = None
|
|
self._stop_event.wait(1.0)
|
|
if ser is not None:
|
|
try:
|
|
ser.close()
|
|
except Exception:
|
|
pass
|
|
self.connected = False
|
|
self.stop_csv()
|
|
|
|
# NB: name must not collide with threading.Thread instance attributes
|
|
# (Thread.__init__ sets self._handle on Python 3.13+).
|
|
def _on_frame(self, b: BroadcastData) -> None:
|
|
now = time.monotonic()
|
|
with self._lock:
|
|
# Flags/pong are valid even at counter == 0 (post-reset window);
|
|
# telemetry/param fields are only valid once counter > 0.
|
|
self.latest = b
|
|
self.latest_wall = time.time()
|
|
fresh = b.counter > 0 and b.counter != self._last_counter
|
|
if fresh:
|
|
if 0 < self._last_counter < b.counter:
|
|
self._sent_count += b.counter - self._last_counter
|
|
else:
|
|
self._sent_count += 1 # first valid frame, or a reboot
|
|
self._fresh_count += 1
|
|
self._last_counter = b.counter
|
|
self._t.append(now)
|
|
self._vfly.append(b.vfly)
|
|
self._corr.append(b.vfly_correction)
|
|
self._ofs.append(b.vfly_ofs_applied)
|
|
if b.param_id in PARAM_BY_ID:
|
|
self.params[b.param_id] = decode_param_bits(b.param_type, b.param_value)
|
|
if fresh:
|
|
self._csv_row(b, now)
|
|
|
|
def _csv_row(self, b: BroadcastData, t_mono: float) -> None:
|
|
with self._csv_lock:
|
|
if self._csv_writer is None:
|
|
return
|
|
row = [f"{b.timestamp:.3f}", f"{t_mono:.3f}"]
|
|
for name in TELEM_CSV_FIELDS:
|
|
v = getattr(b, name)
|
|
if name in _TELEM_HEX_FIELDS:
|
|
row.append(f"0x{v:08X}")
|
|
elif isinstance(v, float):
|
|
row.append(f"{v:.6g}")
|
|
else:
|
|
row.append(v)
|
|
row.append(f"{b.power_in_W:.4f}")
|
|
row.append(f"{b.power_out_W:.4f}")
|
|
try:
|
|
self._csv_writer.writerow(row)
|
|
if t_mono - self._csv_last_flush >= self.CSV_FLUSH_S:
|
|
self._csv_file.flush()
|
|
self._csv_last_flush = t_mono
|
|
except OSError:
|
|
self._close_csv_locked()
|