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:
@@ -6,3 +6,4 @@ build/
|
||||
.venv/
|
||||
*.csv
|
||||
!samples/*.csv
|
||||
logs/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
|
||||
+754
-30
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
+322
-205
@@ -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
|
||||
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"):
|
||||
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)
|
||||
frame = build_param_write(pdef.id, pdef.ptype, value)
|
||||
if not wait_ack:
|
||||
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
|
||||
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
|
||||
return True
|
||||
|
||||
def write_param_by_id(self, param_id: int, value: float) -> bool:
|
||||
"""Write a parameter by ID."""
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user