GUI: live STM32 telemetry + sweep guards + auto-logging + bench-plot

- 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>
This commit is contained in:
janik
2026-07-03 18:06:02 +07:00
co-authored by Claude Fable 5
parent 606858228c
commit 7f8672d7b9
10 changed files with 1652 additions and 274 deletions
+219
View File
@@ -6,11 +6,19 @@ 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."""
@@ -226,3 +234,214 @@ class InstrumentWorker(threading.Thread):
})
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()