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
+1
View File
@@ -6,3 +6,4 @@ build/
.venv/
*.csv
!samples/*.csv
logs/
+77 -4
View File
@@ -84,8 +84,42 @@ The GUI provides:
- HIOKI channel range selectors + degauss buttons
- Meter format selector (scientific/normal)
- 2D sweep panel with time estimate
- Live-updating power, efficiency, voltage, and current plots
- Console log panel
- Live-updating power, efficiency, voltage, and current plots plus STM32
Vfly and vfly_correction/vfly_ofs_applied plots (selectable) — all graphs
show the last 20 seconds
- STM32 telemetry panel ("Link STM32", COM4 @ 460800 8-O-1): every field of
the 100 Hz broadcast (V/I/P + net efficiency, temps, Vfly group, control
mode, HRTIM compare registers, status flags, fault registers, param echo),
with Ping and Clear Flags buttons; auto-reconnects on serial errors
- Data logging: instrument rows (with the latest STM32 snapshot merged in as
`stm_*` columns) to the chosen CSV, plus the full-rate 100 Hz telemetry
stream to `<name>_telem.csv` alongside. Logging is ON by default: every
launch auto-starts a log at `logs/data_<timestamp>.csv` (relative to the
working directory); use Stop Log / Start Log to switch to a custom path
- Console log panel (STM32 fault flags are reported here as they latch)
- PSU capability guard: the HV supply can source at most 20 A
(`PSU_MAX_CURRENT_A` in `gui.py`). Sweep steps whose estimated input draw
`I_in = P_out / (0.90 * V_in)` exceeds that are rejected (skipped and
reported per voltage), regardless of the requested step range; a measured
backstop additionally drops any point where the supply actually exceeded
the limit and backs the load off. The programmed supply current limit
(sweep "I limit" field and manual supply controls) is clamped to 20 A, and
manual CC/CP load setpoints are checked against live Vin/Vout readings.
- Load range pinning: a mid-sweep auto-range transition on the Prodigit
momentarily unloads the converter, so at sweep start the CC range is
pinned to Range II for the whole run (auto-ranging restored after, with
the load off). The reachable maximum of the selected range is verified
empirically -- the sweep max is programmed with the load off and read
back; if the readback comes back clamped, steps above it are rejected
instead of silently clamped, and a sweep that fits nothing aborts up
front.
- Thermal sweep guard (needs the STM32 link): before every sweep step the
heatsink/board temperatures are checked against the firmware trip limits
(60 C / 80 C). At 57 C / 77 C the sweep pauses, holds the load at 1 A
(CC; ~1 A worth of W in CP), and waits until both temps drop 5 C below
the pause thresholds, then resumes at the same step -- no points are lost.
If the STM32 link is down the sweep still runs, with a console warning
that the guard is inactive.
### 5. Run efficiency sweeps
@@ -141,6 +175,32 @@ Produces three PNG files:
- `*_heatmap.png` -- 2D efficiency surface (voltage x load)
- `*_loss.png` -- power loss vs load, all voltages overlaid
#### Efficiency vs Vin vs current from any logged CSV
`bench-plot` auto-detects all three CSV formats the tooling produces -- sweep
CSVs, GUI data logs (`data_*.csv`), and full-rate telemetry logs
(`*_telem.csv`) -- and draws an operating-point map (x = Vin, y = current,
color = efficiency) plus efficiency-vs-current curves grouped by Vin bin:
```bash
uv run bench-plot # no args -> file-picker dialog
uv run bench-plot data_20260703_140000.csv
uv run bench-plot run1_telem.csv run2_telem.csv --vin-bin 2 --save eff.png
# options: --current iout|iin, --source auto|hioki|instr|stm (data logs),
# --min-pout W (default 5), --vin-bin V (default 1), --save PNG
```
Also reachable via the GUI's "Plot Eff..." button (Logging section, opens the
same dialog preselecting the last log) and `plot_eff.bat` one level up
(double-click for the dialog, or drag && drop CSV files onto it).
For GUI data logs the efficiency source defaults to `auto`: HIOKI EFF1 if the
meter was connected, else supply/load power ratio, else the board's own
`stm_eff_net_pct`. Telemetry logs always use the board's net efficiency
(`(P_out - P_sys) / P_in`, iout_slow). Points below `--min-pout` (default
5 W, same as the GUI display gate) are dropped.
### 7. Tune converter parameters
The tuning commands combine the testbench instruments (ground truth efficiency from HIOKI) with direct STM32 parameter writes to find optimal settings.
@@ -317,6 +377,8 @@ Names, IDs, types and ranges mirror the firmware (`code64/debug_console/protocol
| `dither_band_lo` / `dither_band_hi` | uint16 | 716-6442 | Forbidden duty band edges (CMP ticks) |
| `dither_anear` / `dither_afar` | uint16 | 716-6442 | Out-of-band dither anchors |
| `dither_dzero` | uint16 | 716-6442 | \|e\| fold center (D=0.5) |
| `adc4_trig_phase` | uint16 | 3-14313 | HRTIM master CMP3: iout_slow sample instant |
| `iin_zero_sum` | uint16 | 0-32760 | IIN software zero offset (sum-of-8 counts) |
## CSV Output Format
@@ -335,6 +397,16 @@ Sweep CSV files contain:
Tuning CSV files additionally contain `param_name`, `param_value`, and STM32 telemetry columns (`stm_vin`, `stm_vout`, `stm_iin`, `stm_iout`, `stm_eff`, `stm_vfly`, `stm_etemp`).
GUI data-log CSVs contain the instrument columns plus the latest STM32
broadcast snapshot per row (`stm_counter``stm_age_s`; flag/fault registers
as hex). While logging, the full-rate 100 Hz telemetry stream is additionally
written to `<name>_telem.csv` with every broadcast field (one row per fresh
publish, `pc_time`/`t_mono` timestamps, computed `p_in_W`/`p_out_W`).
Note: STM32-derived `power_out_W`/`efficiency` now use `iout_slow` (the
PWM-synchronous ADC4 output current) instead of the fast protection-path
`iout` — tuner numbers shift slightly vs. old logs.
## Project Structure
```
@@ -348,7 +420,8 @@ mppt-testbench/
| +-- cli.py unified CLI entry point
| +-- gui.py tkinter GUI with live plots
| +-- gui_workers.py background instrument I/O thread
| +-- stm32_link.py synchronous STM32 debug protocol interface
| +-- stm32_link.py STM32 debug protocol: 114B broadcast RX + CRC-framed TX commands (8-O-1)
| +-- plot_eff.py efficiency vs Vin vs current plots from any logged CSV
| +-- tuner.py automated tuning routines (param sweep, deadtime opt)
+-- code64/
| +-- Core/ STM32G474 firmware (C)
@@ -356,7 +429,7 @@ mppt-testbench/
| +-- debug_console/ Textual TUI for live debugging
| +-- pyproject.toml uv-compatible package config
+-- samples/ shade profile CSV examples
+-- pyproject.toml package config, entry points: bench, bench-gui
+-- pyproject.toml package config, entry points: bench, bench-gui, bench-plot
```
## Dependencies
+1
View File
@@ -26,3 +26,4 @@ packages = ["testbench"]
[project.scripts]
bench = "testbench.cli:main"
bench-gui = "testbench.gui:main"
bench-plot = "testbench.plot_eff:main"
+6 -6
View File
@@ -138,7 +138,7 @@ class MPPTTestbench:
self.meter.set_current_auto(5, True)
self.meter.set_voltage_auto(6, True)
self.meter.set_current_auto(6, True)
self.meter.set_response_speed("SLOW")
self.meter.set_response_speed("FAST")
self.meter.set_efficiency(1, "P6", "P5")
# Display: 16-item SELECT view
@@ -300,7 +300,7 @@ class MPPTTestbench:
v_stop: float,
v_step: float,
current_limit: float,
settle_time: float = 1.0,
settle_time: float = 5.0,
load_setpoint: float = 0.0,
) -> list[SweepPoint]:
"""Sweep supply voltage and record measurements at each point.
@@ -366,7 +366,7 @@ class MPPTTestbench:
i_start: float,
i_stop: float,
i_step: float,
settle_time: float = 1.0,
settle_time: float = 5.0,
) -> list[SweepPoint]:
"""Sweep load current (CC mode) at a fixed supply voltage.
@@ -473,7 +473,7 @@ class MPPTTestbench:
def run_shade_profile(
self,
steps: list[dict],
settle_time: float = 2.0,
settle_time: float = 5.0,
) -> list[SweepPoint]:
"""Run a shade / irradiance profile sequence.
@@ -629,7 +629,7 @@ class MPPTTestbench:
l_stop: float,
l_step: float,
current_limit: float,
settle_time: float = 2.0,
settle_time: float = 5.0,
load_mode: str = "CC",
) -> list[SweepPoint]:
"""2D sweep: voltage (outer) × load setpoint (inner).
@@ -770,7 +770,7 @@ class MPPTTestbench:
self,
voltage: float,
current_limit: float,
settle_time: float = 2.0,
settle_time: float = 5.0,
samples: int = 5,
sample_interval: float = 1.0,
) -> dict[str, float]:
+22 -13
View File
@@ -572,20 +572,29 @@ def cmd_stm32_read(bench: MPPTTestbench, args: argparse.Namespace) -> None:
print(f" {name:<20s} = {val}")
print()
# Read telemetry
# Read telemetry (100 Hz broadcast, 20 fresh publishes averaged)
t = link.read_telemetry_avg(n=20)
if t:
from testbench.stm32_link import flags_to_names
modes = ("OFF", "MPPT", "CV", "CC")
mode = modes[t.ctrl_mode] if 0 <= t.ctrl_mode < len(modes) else str(t.ctrl_mode)
print("Telemetry (20-sample avg):")
print(f" Vin = {t.vin_V:8.2f} V")
print(f" Vout = {t.vout_V:8.2f} V")
print(f" Iin = {t.iin_A:8.2f} A")
print(f" Iout = {t.iout_A:8.2f} A")
print(f" Iin = {t.iin_A:8.2f} A (avg {t.iin_avg_ma:+d} mA)")
print(f" Iout = {t.iout_slow/1000:8.2f} A (fast {t.iout_A:.2f} A)")
print(f" Isys = {t.sys_current_ma:8d} mA")
print(f" Pin = {t.power_in_W:8.2f} W")
print(f" Pout = {t.power_out_W:8.2f} W")
print(f" EFF = {t.efficiency:8.1f} %")
print(f" EFF = {t.efficiency:8.1f} % (net {t.efficiency_net:.1f} %)")
print(f" Vfly = {t.vfly/1000:8.2f} V")
print(f" Temp = {t.etemp:8.1f} °C (FET)")
print(f" Temp = {t.etemp:8.1f} °C (heatsink)")
print(f" Tbrd = {t.btemp:8.1f} °C (board)")
print(f" Mode = {mode}")
if t.status_flags:
print(f" Flags= 0x{t.status_flags:08X}")
for name in flags_to_names(t.status_flags):
print(f" {name}")
def cmd_stm32_write(bench: MPPTTestbench, args: argparse.Namespace) -> None:
@@ -1192,7 +1201,7 @@ examples:
p_sweep.add_argument("--v-stop", type=float, required=True, help="Stop voltage (V)")
p_sweep.add_argument("--v-step", type=float, required=True, help="Voltage step (V)")
p_sweep.add_argument("--current-limit", type=float, required=True, help="Current limit (A)")
p_sweep.add_argument("--settle", type=float, default=1.0, help="Settle time per step (s)")
p_sweep.add_argument("--settle", type=float, default=5.0, help="Settle time per step (s)")
p_sweep.add_argument("--load-mode", choices=["CC", "CR", "CV", "CP"], help="Set load mode before sweep")
p_sweep.add_argument("--load-value", type=float, help="Set load value before sweep")
p_sweep.add_argument("-o", "--output", help="CSV output file")
@@ -1204,7 +1213,7 @@ examples:
p_swl.add_argument("--i-start", type=float, required=True, help="Start load current (A)")
p_swl.add_argument("--i-stop", type=float, required=True, help="Stop load current (A)")
p_swl.add_argument("--i-step", type=float, required=True, help="Current step (A)")
p_swl.add_argument("--settle", type=float, default=1.0, help="Settle time per step (s)")
p_swl.add_argument("--settle", type=float, default=5.0, help="Settle time per step (s)")
p_swl.add_argument("-o", "--output", help="CSV output file")
# efficiency
@@ -1212,7 +1221,7 @@ examples:
p_eff.add_argument("--voltage", type=float, required=True, help="Supply voltage (V)")
p_eff.add_argument("--current-limit", type=float, required=True, help="Current limit (A)")
p_eff.add_argument("--samples", type=int, default=5, help="Number of readings to average")
p_eff.add_argument("--settle", type=float, default=2.0, help="Initial settle time (s)")
p_eff.add_argument("--settle", type=float, default=5.0, help="Initial settle time (s)")
p_eff.add_argument("-i", "--interval", type=float, default=1.0, help="Interval between samples")
p_eff.add_argument("--load-mode", choices=["CC", "CR", "CV", "CP"])
p_eff.add_argument("--load-value", type=float)
@@ -1227,13 +1236,13 @@ examples:
p_svi.add_argument("--l-step", type=float, required=True, help="Load step size")
p_svi.add_argument("--load-mode", choices=["CC", "CP"], default="CC", help="Load mode: CC (current) or CP (power)")
p_svi.add_argument("--current-limit", type=float, required=True, help="Supply current limit (A)")
p_svi.add_argument("--settle", type=float, default=2.0, help="Settle time per step (s)")
p_svi.add_argument("--settle", type=float, default=5.0, help="Settle time per step (s)")
p_svi.add_argument("-o", "--output", help="CSV output file")
# shade-profile
p_shade = sub.add_parser("shade-profile", help="Run a shade/irradiance profile from CSV")
p_shade.add_argument("--profile", required=True, help="Profile CSV file (time,voltage,current_limit,...)")
p_shade.add_argument("--settle", type=float, default=2.0, help="Settle time per step (s)")
p_shade.add_argument("--settle", type=float, default=5.0, help="Settle time per step (s)")
p_shade.add_argument("-o", "--output", help="CSV output file for results")
# supply (direct control)
@@ -1275,7 +1284,7 @@ examples:
p_tp.add_argument("--current-limit", type=float, required=True, help="Supply current limit (A)")
p_tp.add_argument("--load-mode", choices=["CC", "CP"], default="CP", help="Load mode")
p_tp.add_argument("--load-value", type=float, default=200.0, help="Load setpoint (A or W)")
p_tp.add_argument("--settle", type=float, default=3.0, help="Settle time per step (s)")
p_tp.add_argument("--settle", type=float, default=5.0, help="Settle time per step (s)")
p_tp.add_argument("--no-plot", action="store_true", help="Skip plot")
p_tp.add_argument("-o", "--output", help="CSV output file")
@@ -1291,7 +1300,7 @@ examples:
p_tv.add_argument("--current-limit", type=float, required=True, help="Supply current limit (A)")
p_tv.add_argument("--load-mode", choices=["CC", "CP"], default="CP", help="Load mode")
p_tv.add_argument("--load-value", type=float, default=200.0, help="Load setpoint (A or W)")
p_tv.add_argument("--settle", type=float, default=3.0, help="Settle time per step (s)")
p_tv.add_argument("--settle", type=float, default=5.0, help="Settle time per step (s)")
p_tv.add_argument("-o", "--output", help="Output prefix for CSVs (default: param name)")
# tune-deadtime
@@ -1303,7 +1312,7 @@ examples:
p_td.add_argument("--current-limit", type=float, default=20.0, help="Supply current limit (A)")
p_td.add_argument("--load-mode", choices=["CC", "CP"], default="CP", help="Load mode")
p_td.add_argument("--load-values", help="Comma-separated load values to test (e.g. 100,300,500)")
p_td.add_argument("--settle", type=float, default=3.0, help="Settle time per step (s)")
p_td.add_argument("--settle", type=float, default=5.0, help="Settle time per step (s)")
p_td.add_argument("--apply", action="store_true", help="Apply best deadtimes after sweep")
p_td.add_argument("-o", "--output", help="CSV output file")
+762 -38
View File
File diff suppressed because it is too large Load Diff
+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()
+234
View File
@@ -0,0 +1,234 @@
"""Plot efficiency vs input voltage vs current from bench CSV logs.
Auto-detects the three CSV formats produced by the tooling:
- GUI data log (data_*.csv: instrument columns + merged stm_* snapshot)
- GUI telemetry log (*_telem.csv: full-rate 100 Hz board broadcast)
- CLI sweep (sweep_vi_*.csv: voltage_set/load_setpoint grid)
Left panel: operating-point scatter (x = Vin, y = current, color = efficiency).
Right panel: efficiency vs current, one curve per Vin bin.
Usage:
bench-plot data_20260703_120000.csv
bench-plot run_telem.csv another_telem.csv --current iin --save eff.png
"""
from __future__ import annotations
import argparse
import csv
import math
import sys
import matplotlib.pyplot as plt
import numpy as np
MIN_P_IN_W = 0.1 # same gate as the firmware/GUI efficiency calc
def _f(row: dict, key: str) -> float:
"""Float cell value; blank/missing/garbage -> NaN."""
v = row.get(key, "")
if v is None or v == "":
return math.nan
try:
return float(v)
except ValueError:
return math.nan
def _detect_format(header: list[str]) -> str:
cols = set(header)
if "stm_eff_net_pct" in cols:
return "datalog"
if "iout_slow" in cols and "p_in_W" in cols:
return "telem"
if "voltage_set" in cols and "efficiency" in cols:
return "sweep"
raise ValueError(f"unrecognized CSV header: {header[:6]}...")
def _extract(path: str, source: str) -> tuple[dict, str]:
"""Read one CSV -> dict of float lists (vin_V, iin_A, iout_A, eff_pct,
p_out_W) plus the efficiency-source label actually used."""
with open(path, newline="") as fh:
reader = csv.DictReader(fh)
header = reader.fieldnames or []
rows = list(reader)
fmt = _detect_format(header)
out: dict[str, list] = {k: [] for k in ("vin_V", "iin_A", "iout_A", "eff_pct", "p_out_W")}
if fmt == "sweep":
for r in rows:
out["vin_V"].append(_f(r, "supply_V"))
out["iin_A"].append(_f(r, "supply_I"))
out["iout_A"].append(_f(r, "load_I"))
out["eff_pct"].append(_f(r, "efficiency"))
out["p_out_W"].append(_f(r, "output_power"))
return out, "sweep efficiency (HIOKI)"
if fmt == "telem":
# Wire units: vin/vout in mV, currents in mA (iin negative into the
# converter); eff net = (P_out - P_sys) / P_in, same as the GUI panel.
for r in rows:
p_in = _f(r, "p_in_W")
p_out = _f(r, "p_out_W")
p_sys = _f(r, "vout") * _f(r, "sys_current_ma") / 1e6
eff = (p_out - p_sys) / p_in * 100.0 if p_in > MIN_P_IN_W else math.nan
out["vin_V"].append(_f(r, "vin") / 1000.0)
out["iin_A"].append(-_f(r, "iin") / 1000.0)
out["iout_A"].append(_f(r, "iout_slow") / 1000.0)
out["eff_pct"].append(eff)
out["p_out_W"].append(p_out)
return out, "board eff net (iout_slow, -P_sys)"
# datalog: three consistent (vin, current, eff) triples to choose from
if source == "auto":
med_eff1 = np.nanmedian([_f(r, "meter_EFF1") for r in rows]) if rows else math.nan
med_psup = np.nanmedian([_f(r, "supply_P") for r in rows]) if rows else math.nan
if med_eff1 > 1.0:
source = "hioki"
elif med_psup > MIN_P_IN_W:
source = "instr"
else:
source = "stm"
for r in rows:
if source == "hioki":
out["vin_V"].append(_f(r, "meter_U5"))
out["iin_A"].append(_f(r, "meter_I5"))
out["iout_A"].append(_f(r, "meter_I6"))
out["eff_pct"].append(_f(r, "meter_EFF1"))
out["p_out_W"].append(_f(r, "meter_P6"))
elif source == "instr":
p_sup = _f(r, "supply_P")
eff = _f(r, "load_P") / p_sup * 100.0 if p_sup > MIN_P_IN_W else math.nan
out["vin_V"].append(_f(r, "supply_V"))
out["iin_A"].append(_f(r, "supply_I"))
out["iout_A"].append(_f(r, "load_I"))
out["eff_pct"].append(eff)
out["p_out_W"].append(_f(r, "load_P"))
else: # stm
out["vin_V"].append(_f(r, "stm_vin_mV") / 1000.0)
out["iin_A"].append(-_f(r, "stm_iin_mA") / 1000.0)
out["iout_A"].append(_f(r, "stm_iout_slow_mA") / 1000.0)
out["eff_pct"].append(_f(r, "stm_eff_net_pct"))
out["p_out_W"].append(_f(r, "stm_p_out_W"))
labels = {"hioki": "HIOKI EFF1", "instr": "supply/load power",
"stm": "board eff net"}
return out, labels[source]
def main() -> None:
ap = argparse.ArgumentParser(
description="Plot efficiency vs input voltage vs current from bench CSVs.")
ap.add_argument("csv", nargs="*",
help="logged CSV file(s); a file dialog opens if omitted")
ap.add_argument("--current", choices=("iout", "iin"), default="iout",
help="current axis: output (default) or input current")
ap.add_argument("--source", choices=("auto", "hioki", "instr", "stm"),
default="auto",
help="efficiency source for GUI data logs (default auto: "
"HIOKI if present, else supply/load, else board)")
ap.add_argument("--vin-bin", type=float, default=1.0, metavar="V",
help="Vin bin width for the per-voltage curves (default 1.0)")
ap.add_argument("--min-pout", type=float, default=5.0, metavar="W",
help="drop points below this output power (default 5.0)")
ap.add_argument("--save", metavar="PNG", help="write the figure instead of showing it")
ap.add_argument("--title", default=None, help="figure title override")
args = ap.parse_args()
if not args.csv:
import tkinter as tk
from tkinter import filedialog
root = tk.Tk()
root.withdraw()
args.csv = list(filedialog.askopenfilenames(
title="Select logged CSV(s) to plot",
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")]))
root.destroy()
if not args.csv:
sys.exit("no file selected")
data: dict[str, list] = {k: [] for k in ("vin_V", "iin_A", "iout_A", "eff_pct", "p_out_W")}
labels = set()
for path in args.csv:
part, label = _extract(path, args.source)
for k in data:
data[k].extend(part[k])
labels.add(label)
vin = np.asarray(data["vin_V"])
cur = np.asarray(data["iin_A" if args.current == "iin" else "iout_A"])
eff = np.asarray(data["eff_pct"])
pout = np.asarray(data["p_out_W"])
keep = (np.isfinite(vin) & np.isfinite(cur) & np.isfinite(eff)
& (eff > 0.0) & (eff <= 105.0) & (pout >= args.min_pout))
n_total = len(vin)
vin, cur, eff = vin[keep], cur[keep], eff[keep]
if len(vin) == 0:
sys.exit(f"no usable points ({n_total} rows read; all filtered — "
f"check --min-pout / --source)")
ipk = int(np.argmax(eff))
print(f"{len(vin)} points ({n_total - len(vin)} filtered) | "
f"Vin {vin.min():.1f}..{vin.max():.1f} V | "
f"I {cur.min():.2f}..{cur.max():.2f} A | "
f"peak eff {eff[ipk]:.2f} % @ {vin[ipk]:.1f} V, {cur[ipk]:.2f} A")
cur_name = "Input current (A)" if args.current == "iin" else "Output current (A)"
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13.5, 5.8))
fig.suptitle(args.title or f"Efficiency map — {', '.join(sorted(labels))}")
# Left: operating-point scatter, color = efficiency
vmin = np.percentile(eff, 5)
sc = ax1.scatter(vin, cur, c=eff, s=14, cmap="viridis",
vmin=vmin, vmax=eff.max(), rasterized=True)
fig.colorbar(sc, ax=ax1, label="Efficiency (%)")
ax1.plot(vin[ipk], cur[ipk], "r*", ms=14, mec="k",
label=f"peak {eff[ipk]:.2f} %")
ax1.set_xlabel("Input voltage (V)")
ax1.set_ylabel(cur_name)
ax1.legend(loc="best", fontsize=8)
ax1.grid(alpha=0.3)
# Right: efficiency vs current, one mean curve per Vin bin
w = args.vin_bin
centers = np.unique(np.round(vin / w) * w)
cmap = plt.cm.plasma(np.linspace(0.0, 0.9, len(centers)))
for color, c0 in zip(cmap, centers):
m = np.abs(vin - c0) <= w / 2
if m.sum() < 2:
ax2.plot(cur[m], eff[m], "o", color=color, ms=4,
label=f"{c0:g} V")
continue
edges = np.linspace(cur[m].min(), cur[m].max() + 1e-9, 41)
idx = np.digitize(cur[m], edges)
xs, ys = [], []
for b in np.unique(idx):
bm = idx == b
xs.append(cur[m][bm].mean())
ys.append(eff[m][bm].mean())
ax2.plot(xs, ys, "-o", color=color, ms=3, lw=1.2, label=f"{c0:g} V")
ax2.set_xlabel(cur_name)
ax2.set_ylabel("Efficiency (%)")
ax2.grid(alpha=0.3)
if len(centers) <= 14:
ax2.legend(title="Vin bin", fontsize=8, ncols=1 + len(centers) // 8)
else:
norm = plt.Normalize(centers.min(), centers.max())
fig.colorbar(plt.cm.ScalarMappable(norm=norm, cmap="plasma"),
ax=ax2, label="Vin bin (V)")
fig.tight_layout()
if args.save:
fig.savefig(args.save, dpi=140)
print(f"saved: {args.save}")
else:
plt.show()
if __name__ == "__main__":
main()
+329 -212
View File
@@ -1,17 +1,20 @@
"""Synchronous serial link to the STM32 debug protocol.
Provides blocking read/write of telemetry and parameters, suitable
for automated tuning scripts (not a TUI). Mirrors the binary protocol
from code64/debug_console/protocol.py (kept in sync with the firmware's
debug_protocol.h — CRC-16, 78-byte telemetry, current parameter map).
STM32 -> PC is a continuous 114-byte binary broadcast (magic-delimited,
repetition-validated, UART odd parity, ~100 Hz publish rate, each publish
repeated ~3-4x at line rate). PC -> STM32 commands remain CRC-16 framed.
Mirrors code64/debug_console/protocol.py (kept in sync with the firmware's
debug_protocol.h).
"""
from __future__ import annotations
import dataclasses
import struct
import time
from collections import deque
from dataclasses import dataclass, field
from typing import Optional
from typing import Iterator, Optional
import serial
@@ -19,13 +22,13 @@ import serial
SYNC_BYTE = 0xAA
CMD_TELEMETRY = 0x01
CMD_TELEMETRY = 0x01 # legacy; STM32->PC framed telemetry no longer sent
CMD_PARAM_WRITE = 0x02
CMD_PARAM_WRITE_ACK = 0x03
CMD_PARAM_READ_ALL = 0x04
CMD_PARAM_VALUE = 0x05
CMD_PARAM_WRITE_ACK = 0x03 # legacy; acks now come via the broadcast round-robin
CMD_PARAM_READ_ALL = 0x04 # restarts the broadcast param round-robin cursor
CMD_PARAM_VALUE = 0x05 # legacy; STM32->PC framed replies no longer sent
CMD_PING = 0x10
CMD_PONG = 0x11
CMD_PONG = 0x11 # legacy; pong now increments a broadcast field
CMD_SHUTDOWN = 0x12 # turn off converter
CMD_RESET = 0x13 # system reset
CMD_TEST_50 = 0x14 # 50% duty test mode
@@ -33,7 +36,8 @@ CMD_RELAY_ON = 0x15 # latch input relay closed (bench test)
CMD_RELAY_OFF = 0x16 # latch input relay open (bench test)
CMD_HOLD_CONVERTER = 0x17 # toggle "hold converter off" (boot guard + disarm trips)
CMD_TOGGLE_PRECHARGE = 0x18 # toggle the precharge FET (bench test)
CMD_ERROR_MSG = 0xE0
CMD_CLEAR_FLAGS = 0x19 # clear latched status flags
CMD_ERROR_MSG = 0xE0 # legacy; STM32->PC no longer sends framed text
PTYPE_FLOAT = 0
PTYPE_UINT16 = 1
@@ -57,36 +61,116 @@ def crc16(data: bytes) -> int:
return crc
# ── Telemetry ────────────────────────────────────────────────────────
# ── STM32 -> PC broadcast ────────────────────────────────────────────
# A single fixed 114-byte struct streamed continuously (circular DMA). No framing
# length, no CRC. Frames are delimited by the 4-byte magic. Integrity = REPETITION
# (each `counter` value is re-sent back-to-back, so a corrupt/torn copy differs
# from its neighbours) + UART odd parity. See debug_protocol.h BroadcastFrame.
BCAST_MAGIC = 0xAA55AA55
BROADCAST_FMT = "<II16fhHhHHhBBIIIIBBhIHH" # 114 bytes; h after param_type = iin_avg_ma, trailing H = sys_current_ma
BROADCAST_SIZE = struct.calcsize(BROADCAST_FMT)
MAGIC_BYTES = struct.pack("<I", BCAST_MAGIC)
# status_flags bit -> human label (matches the firmware FLAG_* defines)
FLAG_NAMES = {
0: "STARTUP: waiting (Vfly guard)",
1: "PRECHARGE TIMEOUT",
2: "GUARD: VIN_MAX",
3: "GUARD: VIN<VOUT+5V",
4: "GUARD: IIN",
5: "GUARD: IOUT",
6: "GUARD: VFLY",
7: "GUARD: ETEMP",
8: "GUARD: BTEMP",
9: "LIMIT: VOUT_MAX",
10: "LIMIT: VIN_SHUTOFF",
11: "LIMIT: IIN reverse-feed",
12: "LIMIT: IIN_MIN",
13: "LIMIT: IOUT_MAX",
14: "LIMIT: IOUT_MIN",
15: "LIMIT: VFLY_MAX",
16: "LIMIT: VIN<VOUT+5V",
17: "FMAC OVF",
18: "FMAC UNF",
19: "FMAC SAT",
20: "TEMP: ETEMP",
21: "TEMP: BTEMP",
22: "HARDFAULT",
23: "OCP: VOUT comparator",
24: "OCP: IIN comparator",
25: "OCP: ILOAD comparator",
26: "Clock Security System",
27: "Error_Handler reached",
28: "OUTPUTS ENABLED (converter went active)",
29: "TURNOFF: current-decay timeout (not COMP4)",
}
# flags that are informational / transient (yellow), the rest are faults (red):
# STARTUP_WAITING (bit 0) + all GUARD_* (bits 2..8) + OUTPUTS ENABLED (bit 28)
FLAG_INFO_MASK = (1 << 0) | sum(1 << b for b in range(2, 9)) | (1 << 28)
def flags_to_names(flags: int) -> list:
return [name for bit, name in FLAG_NAMES.items() if flags & (1 << bit)]
def decode_param_bits(param_type: int, raw: int) -> float:
"""Interpret a raw 32-bit param_value from the broadcast per its type."""
if param_type == PTYPE_FLOAT:
return struct.unpack("<f", struct.pack("<I", raw & 0xFFFFFFFF))[0]
if param_type == PTYPE_UINT16:
return float(raw & 0xFFFF)
if param_type == PTYPE_UINT8:
return float(raw & 0xFF)
if param_type in (PTYPE_INT32, PTYPE_INT16):
return float(struct.unpack("<i", struct.pack("<I", raw & 0xFFFFFFFF))[0])
return float(raw)
@dataclass
class Telemetry:
"""Decoded telemetry packet from the STM32 (78-byte payload)."""
class BroadcastData:
"""One decoded broadcast frame — everything the board publishes."""
counter: int = 0
vin: float = 0.0 # mV
vout: float = 0.0 # mV
iin: float = 0.0 # mA (negative = into converter)
iout: float = 0.0 # mA
iout: float = 0.0 # mA (fast ADC5; protection only — use iout_slow for power)
vfly: float = 0.0 # mV
etemp: float = 0.0 # °C (FET / external)
etemp: float = 0.0 # °C (heatsink / external)
btemp: float = 0.0 # °C (board)
last_tmp: int = 0
VREF: int = 0
vfly_correction: int = 0
cmp_outer: int = 0 # HRTIM Timer F CMP1xR (outer pair, T1/T4)
vfly_integral: float = 0.0
vfly_avg_debug: float = 0.0
cc_output_f: float = 0.0
mppt_iref: float = 0.0
mppt_last_vin: float = 0.0
mppt_last_iin: float = 0.0
p_in: float = 0.0
p_out: float = 0.0
iout_slow: float = 0.0
seq: int = 0
cmp_inner: int = 0 # HRTIM Timer E CMP1xR (inner pair, T2/T3)
p_in: float = 0.0 # board-computed
p_out: float = 0.0 # board-computed
iout_slow: float = 0.0 # mA (ADC4, PWM-synchronous avg-point)
last_tmp: int = 0
VREF: int = 0
vfly_correction: int = 0
cmp_outer: int = 0 # HRTIM Timer F CMP1xR (outer pair, T1/T4)
cmp_inner: int = 0 # HRTIM Timer E CMP1xR (inner pair, T2/T3)
vfly_ofs_applied: int = 0 # master-phase offset last written, signed ticks
ctrl_mode: int = 0 # 0=OFF 1=MPPT 2=CV 3=CC
vfly_active: int = 0
status_flags: int = 0
fmac_sr: int = 0
fault_pc: int = 0
cfsr: int = 0
param_id: int = 0 # broadcast param round-robin
param_type: int = 0
param_value: int = 0 # raw bits; interpret via decode_param_bits(param_type, ...)
pong: int = 0 # increments when the MCU processes CMD_PING
sys_current_ma: int = 0 # Vout-rail housekeeping current, mA
iin_avg_ma: int = 0 # 8-sample boxcar of iin, mA (the IIN_MAX trip quantity)
timestamp: float = field(default_factory=time.time)
@property
def seq(self) -> int: # back-compat for code that used t.seq
return self.counter & 0xFFFF
@property
def vin_V(self) -> float:
return self.vin / 1000.0
@@ -103,39 +187,91 @@ class Telemetry:
def iout_A(self) -> float:
return self.iout / 1000.0
@property
def vfly_V(self) -> float:
return self.vfly / 1000.0
@property
def power_in_W(self) -> float:
return self.vin * (-self.iin) / 1e6
@property
def power_out_W(self) -> float:
return self.vout * self.iout / 1e6
# iout_slow is the accurate output current (fast iout is protection-only)
return self.vout * self.iout_slow / 1e6
@property
def power_sys_W(self) -> float:
return self.vout * self.sys_current_ma / 1e6
@property
def efficiency(self) -> float:
"""Gross efficiency P_out/P_in (%)."""
p_in = self.power_in_W
return (self.power_out_W / p_in * 100.0) if p_in > 0.1 else 0.0
_TELEM_FMT = "<7f hHhH 6f 3f BxH h" # 78 bytes
_TELEM_SIZE = struct.calcsize(_TELEM_FMT)
@property
def efficiency_net(self) -> float:
"""Net efficiency (P_out - P_sys)/P_in (%) — self-supply subtracted."""
p_in = self.power_in_W
if p_in <= 0.1:
return 0.0
return (self.power_out_W - self.power_sys_W) / p_in * 100.0
def _decode_telemetry(payload: bytes) -> Optional[Telemetry]:
if len(payload) < _TELEM_SIZE:
return None
v = struct.unpack(_TELEM_FMT, payload[:_TELEM_SIZE])
return Telemetry(
vin=v[0], vout=v[1], iin=v[2], iout=v[3], vfly=v[4], etemp=v[5], btemp=v[6],
last_tmp=v[7], VREF=v[8], vfly_correction=v[9], cmp_outer=v[10],
vfly_integral=v[11], vfly_avg_debug=v[12],
cc_output_f=v[13], mppt_iref=v[14],
mppt_last_vin=v[15], mppt_last_iin=v[16],
p_in=v[17], p_out=v[18], iout_slow=v[19],
seq=v[20], cmp_inner=v[21], vfly_ofs_applied=v[22],
# Back-compat alias: tuner/cli were written against the old Telemetry class.
Telemetry = BroadcastData
def decode_broadcast(frame: bytes) -> BroadcastData:
v = struct.unpack(BROADCAST_FMT, frame)
return BroadcastData(
counter=v[1],
vin=v[2], vout=v[3], iin=v[4], iout=v[5], vfly=v[6], etemp=v[7], btemp=v[8],
vfly_integral=v[9], vfly_avg_debug=v[10], cc_output_f=v[11],
mppt_iref=v[12], mppt_last_vin=v[13], mppt_last_iin=v[14],
p_in=v[15], p_out=v[16], iout_slow=v[17],
last_tmp=v[18], VREF=v[19], vfly_correction=v[20],
cmp_outer=v[21], cmp_inner=v[22], vfly_ofs_applied=v[23],
ctrl_mode=v[24], vfly_active=v[25],
status_flags=v[26], fmac_sr=v[27], fault_pc=v[28], cfsr=v[29],
param_id=v[30], param_type=v[31], param_value=v[33], pong=v[34],
sys_current_ma=v[35], iin_avg_ma=v[32],
)
class BroadcastParser:
"""Parse the continuous 114-byte broadcast stream. Resync on the 4-byte magic;
validate a frame by REPETITION -- accept only when two consecutive byte-identical
copies arrive (a corrupt/torn copy differs from its neighbours -> discarded). No
CRC. Re-sends of the same `counter` are normal; dedup on counter downstream."""
def __init__(self):
self.buf = bytearray()
self._prev = None # previous raw frame awaiting a matching repeat
def feed(self, data: bytes):
self.buf += data
while True:
i = self.buf.find(MAGIC_BYTES)
if i < 0:
# no magic yet; keep only a trailing partial-magic (3 bytes)
if len(self.buf) > 3:
del self.buf[:-3]
return
if i > 0:
del self.buf[:i] # drop junk / dropped-byte shift before magic
if len(self.buf) < BROADCAST_SIZE:
return # wait for a full frame
frame = bytes(self.buf[:BROADCAST_SIZE])
del self.buf[:BROADCAST_SIZE]
if frame == self._prev:
self._prev = None # two identical copies -> accept, need a fresh pair next
yield decode_broadcast(frame)
else:
self._prev = frame # first sighting / differs -> hold, wait for the repeat
# ── Parameter definitions ────────────────────────────────────────────
@dataclass
@@ -196,22 +332,25 @@ PARAMS = [
ParamDef(0x73, "dither_anear", PTYPE_UINT16, "Dither", 716, 6442, ".0f"),
ParamDef(0x74, "dither_afar", PTYPE_UINT16, "Dither", 716, 6442, ".0f"),
ParamDef(0x75, "dither_dzero", PTYPE_UINT16, "Dither", 716, 6442, ".0f"),
# ADC calibration
ParamDef(0x79, "adc4_trig_phase", PTYPE_UINT16, "ADC", 3, 14313, ".0f"), # HRTIM master CMP3: iout_slow sample instant
ParamDef(0x7A, "iin_zero_sum", PTYPE_UINT16, "ADC", 0, 32760, ".0f"), # IIN software zero offset, sum-of-8 counts
]
PARAM_BY_ID: dict[int, ParamDef] = {p.id: p for p in PARAMS}
PARAM_BY_NAME: dict[str, ParamDef] = {p.name: p for p in PARAMS}
# ── Frame building ───────────────────────────────────────────────────
# ── Frame building (PC -> STM32 commands, CRC-16 framed) ─────────────
def _build_frame(cmd: int, payload: bytes = b"") -> bytes:
def build_frame(cmd: int, payload: bytes = b"") -> bytes:
header = bytes([SYNC_BYTE, cmd, len(payload)])
frame = header + payload
crc = crc16(frame)
return frame + bytes([(crc >> 8) & 0xFF, crc & 0xFF]) # big-endian: hi, lo
def _build_param_write(param_id: int, ptype: int, value) -> bytes:
def build_param_write(param_id: int, ptype: int, value) -> bytes:
if ptype == PTYPE_FLOAT:
val_bytes = struct.pack("<f", float(value))
elif ptype == PTYPE_UINT16:
@@ -225,90 +364,57 @@ def _build_param_write(param_id: int, ptype: int, value) -> bytes:
else:
val_bytes = struct.pack("<I", int(value))
payload = struct.pack("<BBxx", param_id, ptype) + val_bytes
return _build_frame(CMD_PARAM_WRITE, payload)
return build_frame(CMD_PARAM_WRITE, payload)
def _decode_param_value(payload: bytes) -> Optional[tuple[int, float]]:
if len(payload) < 8:
return None
param_id, ptype = payload[0], payload[1]
vb = payload[4:8]
if ptype == PTYPE_FLOAT:
value = struct.unpack("<f", vb)[0]
elif ptype == PTYPE_UINT16:
value = float(struct.unpack("<H", vb[:2])[0])
elif ptype == PTYPE_UINT8:
value = float(vb[0])
elif ptype == PTYPE_INT32:
value = float(struct.unpack("<i", vb)[0])
elif ptype == PTYPE_INT16:
value = float(struct.unpack("<i", vb)[0]) # sign-extended 32-bit wire
else:
value = float(struct.unpack("<I", vb)[0])
return (param_id, value)
def build_ping() -> bytes:
return build_frame(CMD_PING)
# ── Frame parser state machine ───────────────────────────────────────
def build_shutdown() -> bytes:
return build_frame(CMD_SHUTDOWN)
class _FrameParser:
WAIT_SYNC = 0
WAIT_CMD = 1
WAIT_LEN = 2
WAIT_PAYLOAD = 3
WAIT_CRC_HI = 4
WAIT_CRC_LO = 5
def __init__(self):
self.state = self.WAIT_SYNC
self.cmd = 0
self.length = 0
self.buf = bytearray()
self.payload = bytearray()
self.idx = 0
self.crc_hi = 0
def build_reset() -> bytes:
return build_frame(CMD_RESET)
def feed(self, data: bytes):
for b in data:
if self.state == self.WAIT_SYNC:
if b == SYNC_BYTE:
self.buf = bytearray([b])
self.state = self.WAIT_CMD
elif self.state == self.WAIT_CMD:
self.cmd = b
self.buf.append(b)
self.state = self.WAIT_LEN
elif self.state == self.WAIT_LEN:
self.length = b
self.buf.append(b)
self.payload = bytearray()
self.idx = 0
if b == 0:
self.state = self.WAIT_CRC_HI
elif b > 128:
self.state = self.WAIT_SYNC
else:
self.state = self.WAIT_PAYLOAD
elif self.state == self.WAIT_PAYLOAD:
self.payload.append(b)
self.buf.append(b)
self.idx += 1
if self.idx >= self.length:
self.state = self.WAIT_CRC_HI
elif self.state == self.WAIT_CRC_HI:
self.crc_hi = b
self.state = self.WAIT_CRC_LO
elif self.state == self.WAIT_CRC_LO:
received = (self.crc_hi << 8) | b
expected = crc16(bytes(self.buf))
self.state = self.WAIT_SYNC
if received == expected:
yield (self.cmd, bytes(self.payload))
def build_test_50() -> bytes:
return build_frame(CMD_TEST_50)
def build_relay_on() -> bytes:
return build_frame(CMD_RELAY_ON)
def build_relay_off() -> bytes:
return build_frame(CMD_RELAY_OFF)
def build_hold_converter() -> bytes:
return build_frame(CMD_HOLD_CONVERTER)
def build_toggle_precharge() -> bytes:
return build_frame(CMD_TOGGLE_PRECHARGE)
def build_param_read_all() -> bytes:
return build_frame(CMD_PARAM_READ_ALL)
def build_clear_flags() -> bytes:
return build_frame(CMD_CLEAR_FLAGS)
# ── STM32Link — synchronous serial interface ─────────────────────────
class STM32Link:
"""Blocking serial link to STM32 debug protocol.
"""Blocking serial link to the STM32 debug protocol.
Telemetry and parameter echoes arrive via the continuous broadcast;
commands go out CRC-16 framed. The port MUST be opened with odd
parity (8-O-1) or the broadcast never validates.
Usage::
@@ -321,9 +427,17 @@ class STM32Link:
"""
def __init__(self, port: str, baudrate: int = 460800, timeout: float = 2.0):
self.ser = serial.Serial(port, baudrate, timeout=timeout)
self._parser = _FrameParser()
self._param_cache: dict[int, float] = {}
self.timeout = timeout
self.ser = serial.Serial(
port, baudrate, timeout=0.1,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_ODD,
stopbits=serial.STOPBITS_ONE,
)
self._parser = BroadcastParser()
self._params: dict[int, float] = {}
self._pending: deque = deque() # decoded frames not yet consumed
self._last_counter = -1
def close(self):
if self.ser and self.ser.is_open:
@@ -340,92 +454,95 @@ class STM32Link:
def _send(self, frame: bytes):
self.ser.write(frame)
def _recv_frames(self, timeout: float = 1.0) -> list[tuple[int, bytes]]:
"""Read available data and return decoded frames."""
frames = []
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
data = self.ser.read(self.ser.in_waiting or 1)
if data:
for cmd, payload in self._parser.feed(data):
frames.append((cmd, payload))
if frames:
# Drain any remaining data
time.sleep(0.02)
data = self.ser.read(self.ser.in_waiting)
if data:
for cmd, payload in self._parser.feed(data):
frames.append((cmd, payload))
break
return frames
def _drain_serial(self) -> None:
"""Read one serial chunk and queue ALL decoded frames.
def _wait_for(self, target_cmd: int, timeout: float = 2.0) -> Optional[bytes]:
"""Wait for a specific command response, processing others."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
data = self.ser.read(self.ser.in_waiting or 1)
if data:
for cmd, payload in self._parser.feed(data):
if cmd == target_cmd:
return payload
# Cache param values seen in passing
if cmd in (CMD_PARAM_VALUE, CMD_PARAM_WRITE_ACK):
result = _decode_param_value(payload)
if result:
self._param_cache[result[0]] = result[1]
# Cache telemetry too
if cmd == CMD_TELEMETRY:
self._last_telemetry = _decode_telemetry(payload)
return None
Param round-robin echoes are stashed into self._params in passing
(only once counter > 0 — fields are stale during the post-reset window).
"""
data = self.ser.read(4096)
if not data:
return
for b in self._parser.feed(data):
if b.counter > 0 and b.param_id in PARAM_BY_ID:
self._params[b.param_id] = decode_param_bits(b.param_type, b.param_value)
self._pending.append(b)
def _pump(self, deadline: float) -> Iterator[BroadcastData]:
"""Yield validated frames until deadline.
Frames are staged through self._pending so nothing is lost when a
caller stops iterating early (generator abandoned mid-chunk).
"""
while True:
while self._pending:
yield self._pending.popleft()
if time.monotonic() >= deadline:
return
self._drain_serial()
# ── Commands ─────────────────────────────────────────────────────
def ping(self, timeout: float = 2.0) -> bool:
"""Send PING, return True if PONG received."""
self._send(_build_frame(CMD_PING))
return self._wait_for(CMD_PONG, timeout) is not None
"""Send PING, return True when the broadcast pong counter increments."""
baseline: Optional[int] = None
for b in self._pump(time.monotonic() + min(0.7, timeout)):
baseline = b.pong # pong is valid even at counter == 0
break
if baseline is None:
return False # no broadcast at all -> not connected
self._send(build_ping())
for b in self._pump(time.monotonic() + timeout):
if b.pong != baseline:
return True
return False
def shutdown(self):
"""Command the converter off."""
self._send(_build_frame(CMD_SHUTDOWN))
self._send(build_shutdown())
def reset(self):
"""Command a system reset."""
self._send(_build_frame(CMD_RESET))
self._send(build_reset())
def test_50(self):
"""Enter 50% duty test mode."""
self._send(_build_frame(CMD_TEST_50))
self._send(build_test_50())
def relay_on(self):
"""Latch the input relay closed (bench test)."""
self._send(_build_frame(CMD_RELAY_ON))
self._send(build_relay_on())
def relay_off(self):
"""Latch the input relay open (bench test)."""
self._send(_build_frame(CMD_RELAY_OFF))
self._send(build_relay_off())
def hold_converter(self):
"""Toggle 'hold converter off' (boot guard + disarm trips)."""
self._send(_build_frame(CMD_HOLD_CONVERTER))
self._send(build_hold_converter())
def toggle_precharge(self):
"""Toggle the precharge FET (bench test)."""
self._send(_build_frame(CMD_TOGGLE_PRECHARGE))
self._send(build_toggle_precharge())
def read_telemetry(self, timeout: float = 2.0) -> Optional[Telemetry]:
"""Wait for next telemetry packet."""
payload = self._wait_for(CMD_TELEMETRY, timeout)
if payload:
return _decode_telemetry(payload)
def clear_flags(self):
"""Clear latched status flags."""
self._send(build_clear_flags())
# ── Telemetry ────────────────────────────────────────────────────
def read_telemetry(self, timeout: float = 2.0) -> Optional[BroadcastData]:
"""Return the next FRESH publish (counter-deduped — repeats skipped)."""
for b in self._pump(time.monotonic() + timeout):
if b.counter > 0 and b.counter != self._last_counter:
self._last_counter = b.counter
return b
return None
def read_telemetry_avg(self, n: int = 10, timeout: float = 5.0) -> Optional[Telemetry]:
"""Read n telemetry packets and return the average."""
samples: list[Telemetry] = []
def read_telemetry_avg(self, n: int = 10, timeout: float = 5.0) -> Optional[BroadcastData]:
"""Average n fresh publishes (~n/100 s). Float fields are averaged;
int/flag fields come from the last sample."""
samples: list[BroadcastData] = []
deadline = time.monotonic() + timeout
while len(samples) < n and time.monotonic() < deadline:
t = self.read_telemetry(timeout=deadline - time.monotonic())
@@ -433,43 +550,38 @@ class STM32Link:
samples.append(t)
if not samples:
return None
# Average all analog float fields
avg = Telemetry()
avg = dataclasses.replace(samples[-1])
for attr in ("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"):
"vfly_integral", "vfly_avg_debug", "cc_output_f",
"mppt_iref", "mppt_last_vin", "mppt_last_iin",
"p_in", "p_out", "iout_slow"):
setattr(avg, attr, sum(getattr(s, attr) for s in samples) / len(samples))
avg.seq = samples[-1].seq
return avg
def request_all_params(self):
"""Request all parameter values from the STM32."""
self._send(_build_frame(CMD_PARAM_READ_ALL))
# ── Parameters ───────────────────────────────────────────────────
def read_all_params(self, timeout: float = 3.0) -> dict[str, float]:
"""Request and collect all parameter values."""
self._param_cache.clear()
def request_all_params(self):
"""Restart the broadcast param round-robin from the first param."""
self._send(build_param_read_all())
def read_all_params(self, timeout: float = 5.0) -> dict[str, float]:
"""Collect all parameter values from the broadcast round-robin
(one param per publish -> full rotation ~0.4 s at 100 Hz)."""
self._params.clear()
self.request_all_params()
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
data = self.ser.read(self.ser.in_waiting or 1)
if data:
for cmd, payload in self._parser.feed(data):
if cmd == CMD_PARAM_VALUE:
result = _decode_param_value(payload)
if result:
self._param_cache[result[0]] = result[1]
time.sleep(0.05)
# Convert to name->value
for _ in self._pump(time.monotonic() + timeout):
if len(self._params) >= len(PARAMS):
break
return {
PARAM_BY_ID[pid].name: val
for pid, val in self._param_cache.items()
for pid, val in self._params.items()
if pid in PARAM_BY_ID
}
def write_param(self, name: str, value: float, wait_ack: bool = True) -> bool:
"""Write a parameter by name. Returns True if ACK received."""
"""Write a parameter by name. The write is acknowledged when the
broadcast round-robin echoes the new value back (clamped + formatted
the way the firmware reports it); retries until it matches."""
pdef = PARAM_BY_NAME.get(name)
if not pdef:
raise ValueError(f"Unknown parameter: {name!r}")
@@ -477,17 +589,22 @@ class STM32Link:
raise ValueError(
f"{name}: {value} out of range [{pdef.min_val}, {pdef.max_val}]"
)
frame = _build_param_write(pdef.id, pdef.ptype, value)
self._send(frame)
if wait_ack:
payload = self._wait_for(CMD_PARAM_WRITE_ACK, timeout=2.0)
if payload:
result = _decode_param_value(payload)
if result:
self._param_cache[result[0]] = result[1]
return True
return False
return True
frame = build_param_write(pdef.id, pdef.ptype, value)
if not wait_ack:
self._send(frame)
return True
clamped = max(pdef.min_val, min(pdef.max_val, float(value)))
expect = f"{clamped:{pdef.fmt}}"
for _ in range(6):
self._send(frame)
# one full round-robin rotation is ~0.4 s; 0.5 s sees the echo once
for b in self._pump(time.monotonic() + 0.5):
if b.counter > 0 and b.param_id == pdef.id:
echoed = decode_param_bits(b.param_type, b.param_value)
if f"{echoed:{pdef.fmt}}" == expect:
self._params[pdef.id] = echoed
return True
return False
def write_param_by_id(self, param_id: int, value: float) -> bool:
"""Write a parameter by ID."""
+1 -1
View File
@@ -61,7 +61,7 @@ class Tuner:
self,
bench: MPPTTestbench,
link: STM32Link,
settle_time: float = 3.0,
settle_time: float = 5.0,
stm_avg_samples: int = 10,
):
self.bench = bench