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/ .venv/
*.csv *.csv
!samples/*.csv !samples/*.csv
logs/
+77 -4
View File
@@ -84,8 +84,42 @@ The GUI provides:
- HIOKI channel range selectors + degauss buttons - HIOKI channel range selectors + degauss buttons
- Meter format selector (scientific/normal) - Meter format selector (scientific/normal)
- 2D sweep panel with time estimate - 2D sweep panel with time estimate
- Live-updating power, efficiency, voltage, and current plots - Live-updating power, efficiency, voltage, and current plots plus STM32
- Console log panel 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 ### 5. Run efficiency sweeps
@@ -141,6 +175,32 @@ Produces three PNG files:
- `*_heatmap.png` -- 2D efficiency surface (voltage x load) - `*_heatmap.png` -- 2D efficiency surface (voltage x load)
- `*_loss.png` -- power loss vs load, all voltages overlaid - `*_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 ### 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. 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_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_anear` / `dither_afar` | uint16 | 716-6442 | Out-of-band dither anchors |
| `dither_dzero` | uint16 | 716-6442 | \|e\| fold center (D=0.5) | | `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 ## 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`). 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 ## Project Structure
``` ```
@@ -348,7 +420,8 @@ mppt-testbench/
| +-- cli.py unified CLI entry point | +-- cli.py unified CLI entry point
| +-- gui.py tkinter GUI with live plots | +-- gui.py tkinter GUI with live plots
| +-- gui_workers.py background instrument I/O thread | +-- 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) | +-- tuner.py automated tuning routines (param sweep, deadtime opt)
+-- code64/ +-- code64/
| +-- Core/ STM32G474 firmware (C) | +-- Core/ STM32G474 firmware (C)
@@ -356,7 +429,7 @@ mppt-testbench/
| +-- debug_console/ Textual TUI for live debugging | +-- debug_console/ Textual TUI for live debugging
| +-- pyproject.toml uv-compatible package config | +-- pyproject.toml uv-compatible package config
+-- samples/ shade profile CSV examples +-- 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 ## Dependencies
+1
View File
@@ -26,3 +26,4 @@ packages = ["testbench"]
[project.scripts] [project.scripts]
bench = "testbench.cli:main" bench = "testbench.cli:main"
bench-gui = "testbench.gui: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_current_auto(5, True)
self.meter.set_voltage_auto(6, True) self.meter.set_voltage_auto(6, True)
self.meter.set_current_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") self.meter.set_efficiency(1, "P6", "P5")
# Display: 16-item SELECT view # Display: 16-item SELECT view
@@ -300,7 +300,7 @@ class MPPTTestbench:
v_stop: float, v_stop: float,
v_step: float, v_step: float,
current_limit: float, current_limit: float,
settle_time: float = 1.0, settle_time: float = 5.0,
load_setpoint: float = 0.0, load_setpoint: float = 0.0,
) -> list[SweepPoint]: ) -> list[SweepPoint]:
"""Sweep supply voltage and record measurements at each point. """Sweep supply voltage and record measurements at each point.
@@ -366,7 +366,7 @@ class MPPTTestbench:
i_start: float, i_start: float,
i_stop: float, i_stop: float,
i_step: float, i_step: float,
settle_time: float = 1.0, settle_time: float = 5.0,
) -> list[SweepPoint]: ) -> list[SweepPoint]:
"""Sweep load current (CC mode) at a fixed supply voltage. """Sweep load current (CC mode) at a fixed supply voltage.
@@ -473,7 +473,7 @@ class MPPTTestbench:
def run_shade_profile( def run_shade_profile(
self, self,
steps: list[dict], steps: list[dict],
settle_time: float = 2.0, settle_time: float = 5.0,
) -> list[SweepPoint]: ) -> list[SweepPoint]:
"""Run a shade / irradiance profile sequence. """Run a shade / irradiance profile sequence.
@@ -629,7 +629,7 @@ class MPPTTestbench:
l_stop: float, l_stop: float,
l_step: float, l_step: float,
current_limit: float, current_limit: float,
settle_time: float = 2.0, settle_time: float = 5.0,
load_mode: str = "CC", load_mode: str = "CC",
) -> list[SweepPoint]: ) -> list[SweepPoint]:
"""2D sweep: voltage (outer) × load setpoint (inner). """2D sweep: voltage (outer) × load setpoint (inner).
@@ -770,7 +770,7 @@ class MPPTTestbench:
self, self,
voltage: float, voltage: float,
current_limit: float, current_limit: float,
settle_time: float = 2.0, settle_time: float = 5.0,
samples: int = 5, samples: int = 5,
sample_interval: float = 1.0, sample_interval: float = 1.0,
) -> dict[str, float]: ) -> 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(f" {name:<20s} = {val}")
print() print()
# Read telemetry # Read telemetry (100 Hz broadcast, 20 fresh publishes averaged)
t = link.read_telemetry_avg(n=20) t = link.read_telemetry_avg(n=20)
if t: 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("Telemetry (20-sample avg):")
print(f" Vin = {t.vin_V:8.2f} V") print(f" Vin = {t.vin_V:8.2f} V")
print(f" Vout = {t.vout_V:8.2f} V") print(f" Vout = {t.vout_V:8.2f} V")
print(f" Iin = {t.iin_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_A:8.2f} A") 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" Pin = {t.power_in_W:8.2f} W")
print(f" Pout = {t.power_out_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" 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" 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: 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-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("--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("--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-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("--load-value", type=float, help="Set load value before sweep")
p_sweep.add_argument("-o", "--output", help="CSV output file") 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-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-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("--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") p_swl.add_argument("-o", "--output", help="CSV output file")
# efficiency # efficiency
@@ -1212,7 +1221,7 @@ examples:
p_eff.add_argument("--voltage", type=float, required=True, help="Supply voltage (V)") 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("--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("--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("-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-mode", choices=["CC", "CR", "CV", "CP"])
p_eff.add_argument("--load-value", type=float) 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("--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("--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("--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") p_svi.add_argument("-o", "--output", help="CSV output file")
# shade-profile # shade-profile
p_shade = sub.add_parser("shade-profile", help="Run a shade/irradiance profile from CSV") 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("--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") p_shade.add_argument("-o", "--output", help="CSV output file for results")
# supply (direct control) # 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("--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-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("--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("--no-plot", action="store_true", help="Skip plot")
p_tp.add_argument("-o", "--output", help="CSV output file") 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("--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-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("--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)") p_tv.add_argument("-o", "--output", help="Output prefix for CSVs (default: param name)")
# tune-deadtime # 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("--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-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("--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("--apply", action="store_true", help="Apply best deadtimes after sweep")
p_td.add_argument("-o", "--output", help="CSV output file") 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 from __future__ import annotations
import csv
import queue import queue
import threading import threading
import time import time
from collections import deque
from enum import Enum, auto from enum import Enum, auto
import serial
from testbench.stm32_link import (
BroadcastData, BroadcastParser, PARAM_BY_ID, decode_param_bits,
)
class Cmd(Enum): class Cmd(Enum):
"""Commands sent from GUI to worker thread.""" """Commands sent from GUI to worker thread."""
@@ -226,3 +234,214 @@ class InstrumentWorker(threading.Thread):
}) })
except queue.Full: except queue.Full:
pass 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. """Synchronous serial link to the STM32 debug protocol.
Provides blocking read/write of telemetry and parameters, suitable STM32 -> PC is a continuous 114-byte binary broadcast (magic-delimited,
for automated tuning scripts (not a TUI). Mirrors the binary protocol repetition-validated, UART odd parity, ~100 Hz publish rate, each publish
from code64/debug_console/protocol.py (kept in sync with the firmware's repeated ~3-4x at line rate). PC -> STM32 commands remain CRC-16 framed.
debug_protocol.h — CRC-16, 78-byte telemetry, current parameter map). Mirrors code64/debug_console/protocol.py (kept in sync with the firmware's
debug_protocol.h).
""" """
from __future__ import annotations from __future__ import annotations
import dataclasses
import struct import struct
import time import time
from collections import deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Iterator, Optional
import serial import serial
@@ -19,13 +22,13 @@ import serial
SYNC_BYTE = 0xAA SYNC_BYTE = 0xAA
CMD_TELEMETRY = 0x01 CMD_TELEMETRY = 0x01 # legacy; STM32->PC framed telemetry no longer sent
CMD_PARAM_WRITE = 0x02 CMD_PARAM_WRITE = 0x02
CMD_PARAM_WRITE_ACK = 0x03 CMD_PARAM_WRITE_ACK = 0x03 # legacy; acks now come via the broadcast round-robin
CMD_PARAM_READ_ALL = 0x04 CMD_PARAM_READ_ALL = 0x04 # restarts the broadcast param round-robin cursor
CMD_PARAM_VALUE = 0x05 CMD_PARAM_VALUE = 0x05 # legacy; STM32->PC framed replies no longer sent
CMD_PING = 0x10 CMD_PING = 0x10
CMD_PONG = 0x11 CMD_PONG = 0x11 # legacy; pong now increments a broadcast field
CMD_SHUTDOWN = 0x12 # turn off converter CMD_SHUTDOWN = 0x12 # turn off converter
CMD_RESET = 0x13 # system reset CMD_RESET = 0x13 # system reset
CMD_TEST_50 = 0x14 # 50% duty test mode 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_RELAY_OFF = 0x16 # latch input relay open (bench test)
CMD_HOLD_CONVERTER = 0x17 # toggle "hold converter off" (boot guard + disarm trips) CMD_HOLD_CONVERTER = 0x17 # toggle "hold converter off" (boot guard + disarm trips)
CMD_TOGGLE_PRECHARGE = 0x18 # toggle the precharge FET (bench test) 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_FLOAT = 0
PTYPE_UINT16 = 1 PTYPE_UINT16 = 1
@@ -57,36 +61,116 @@ def crc16(data: bytes) -> int:
return crc 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 @dataclass
class Telemetry: class BroadcastData:
"""Decoded telemetry packet from the STM32 (78-byte payload).""" """One decoded broadcast frame — everything the board publishes."""
counter: int = 0
vin: float = 0.0 # mV vin: float = 0.0 # mV
vout: float = 0.0 # mV vout: float = 0.0 # mV
iin: float = 0.0 # mA (negative = into converter) 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 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) 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_integral: float = 0.0
vfly_avg_debug: float = 0.0 vfly_avg_debug: float = 0.0
cc_output_f: float = 0.0 cc_output_f: float = 0.0
mppt_iref: float = 0.0 mppt_iref: float = 0.0
mppt_last_vin: float = 0.0 mppt_last_vin: float = 0.0
mppt_last_iin: float = 0.0 mppt_last_iin: float = 0.0
p_in: float = 0.0 p_in: float = 0.0 # board-computed
p_out: float = 0.0 p_out: float = 0.0 # board-computed
iout_slow: float = 0.0 iout_slow: float = 0.0 # mA (ADC4, PWM-synchronous avg-point)
seq: int = 0 last_tmp: int = 0
cmp_inner: int = 0 # HRTIM Timer E CMP1xR (inner pair, T2/T3) 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 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) 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 @property
def vin_V(self) -> float: def vin_V(self) -> float:
return self.vin / 1000.0 return self.vin / 1000.0
@@ -103,39 +187,91 @@ class Telemetry:
def iout_A(self) -> float: def iout_A(self) -> float:
return self.iout / 1000.0 return self.iout / 1000.0
@property
def vfly_V(self) -> float:
return self.vfly / 1000.0
@property @property
def power_in_W(self) -> float: def power_in_W(self) -> float:
return self.vin * (-self.iin) / 1e6 return self.vin * (-self.iin) / 1e6
@property @property
def power_out_W(self) -> float: 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 @property
def efficiency(self) -> float: def efficiency(self) -> float:
"""Gross efficiency P_out/P_in (%)."""
p_in = self.power_in_W p_in = self.power_in_W
return (self.power_out_W / p_in * 100.0) if p_in > 0.1 else 0.0 return (self.power_out_W / p_in * 100.0) if p_in > 0.1 else 0.0
@property
_TELEM_FMT = "<7f hHhH 6f 3f BxH h" # 78 bytes def efficiency_net(self) -> float:
_TELEM_SIZE = struct.calcsize(_TELEM_FMT) """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]: # Back-compat alias: tuner/cli were written against the old Telemetry class.
if len(payload) < _TELEM_SIZE: Telemetry = BroadcastData
return None
v = struct.unpack(_TELEM_FMT, payload[:_TELEM_SIZE])
return Telemetry( def decode_broadcast(frame: bytes) -> BroadcastData:
vin=v[0], vout=v[1], iin=v[2], iout=v[3], vfly=v[4], etemp=v[5], btemp=v[6], v = struct.unpack(BROADCAST_FMT, frame)
last_tmp=v[7], VREF=v[8], vfly_correction=v[9], cmp_outer=v[10], return BroadcastData(
vfly_integral=v[11], vfly_avg_debug=v[12], counter=v[1],
cc_output_f=v[13], mppt_iref=v[14], vin=v[2], vout=v[3], iin=v[4], iout=v[5], vfly=v[6], etemp=v[7], btemp=v[8],
mppt_last_vin=v[15], mppt_last_iin=v[16], vfly_integral=v[9], vfly_avg_debug=v[10], cc_output_f=v[11],
p_in=v[17], p_out=v[18], iout_slow=v[19], mppt_iref=v[12], mppt_last_vin=v[13], mppt_last_iin=v[14],
seq=v[20], cmp_inner=v[21], vfly_ofs_applied=v[22], 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 ──────────────────────────────────────────── # ── Parameter definitions ────────────────────────────────────────────
@dataclass @dataclass
@@ -196,22 +332,25 @@ PARAMS = [
ParamDef(0x73, "dither_anear", PTYPE_UINT16, "Dither", 716, 6442, ".0f"), ParamDef(0x73, "dither_anear", PTYPE_UINT16, "Dither", 716, 6442, ".0f"),
ParamDef(0x74, "dither_afar", 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"), 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_ID: dict[int, ParamDef] = {p.id: p for p in PARAMS}
PARAM_BY_NAME: dict[str, ParamDef] = {p.name: 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)]) header = bytes([SYNC_BYTE, cmd, len(payload)])
frame = header + payload frame = header + payload
crc = crc16(frame) crc = crc16(frame)
return frame + bytes([(crc >> 8) & 0xFF, crc & 0xFF]) # big-endian: hi, lo 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: if ptype == PTYPE_FLOAT:
val_bytes = struct.pack("<f", float(value)) val_bytes = struct.pack("<f", float(value))
elif ptype == PTYPE_UINT16: elif ptype == PTYPE_UINT16:
@@ -225,90 +364,57 @@ def _build_param_write(param_id: int, ptype: int, value) -> bytes:
else: else:
val_bytes = struct.pack("<I", int(value)) val_bytes = struct.pack("<I", int(value))
payload = struct.pack("<BBxx", param_id, ptype) + val_bytes 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]]: def build_ping() -> bytes:
if len(payload) < 8: return build_frame(CMD_PING)
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)
# ── 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): def build_reset() -> bytes:
self.state = self.WAIT_SYNC return build_frame(CMD_RESET)
self.cmd = 0
self.length = 0
self.buf = bytearray()
self.payload = bytearray()
self.idx = 0
self.crc_hi = 0
def feed(self, data: bytes):
for b in data: def build_test_50() -> bytes:
if self.state == self.WAIT_SYNC: return build_frame(CMD_TEST_50)
if b == SYNC_BYTE:
self.buf = bytearray([b])
self.state = self.WAIT_CMD def build_relay_on() -> bytes:
elif self.state == self.WAIT_CMD: return build_frame(CMD_RELAY_ON)
self.cmd = b
self.buf.append(b)
self.state = self.WAIT_LEN def build_relay_off() -> bytes:
elif self.state == self.WAIT_LEN: return build_frame(CMD_RELAY_OFF)
self.length = b
self.buf.append(b)
self.payload = bytearray() def build_hold_converter() -> bytes:
self.idx = 0 return build_frame(CMD_HOLD_CONVERTER)
if b == 0:
self.state = self.WAIT_CRC_HI
elif b > 128: def build_toggle_precharge() -> bytes:
self.state = self.WAIT_SYNC return build_frame(CMD_TOGGLE_PRECHARGE)
else:
self.state = self.WAIT_PAYLOAD
elif self.state == self.WAIT_PAYLOAD: def build_param_read_all() -> bytes:
self.payload.append(b) return build_frame(CMD_PARAM_READ_ALL)
self.buf.append(b)
self.idx += 1
if self.idx >= self.length: def build_clear_flags() -> bytes:
self.state = self.WAIT_CRC_HI return build_frame(CMD_CLEAR_FLAGS)
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))
# ── STM32Link — synchronous serial interface ───────────────────────── # ── STM32Link — synchronous serial interface ─────────────────────────
class STM32Link: 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:: Usage::
@@ -321,9 +427,17 @@ class STM32Link:
""" """
def __init__(self, port: str, baudrate: int = 460800, timeout: float = 2.0): def __init__(self, port: str, baudrate: int = 460800, timeout: float = 2.0):
self.ser = serial.Serial(port, baudrate, timeout=timeout) self.timeout = timeout
self._parser = _FrameParser() self.ser = serial.Serial(
self._param_cache: dict[int, float] = {} 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): def close(self):
if self.ser and self.ser.is_open: if self.ser and self.ser.is_open:
@@ -340,92 +454,95 @@ class STM32Link:
def _send(self, frame: bytes): def _send(self, frame: bytes):
self.ser.write(frame) self.ser.write(frame)
def _recv_frames(self, timeout: float = 1.0) -> list[tuple[int, bytes]]: def _drain_serial(self) -> None:
"""Read available data and return decoded frames.""" """Read one serial chunk and queue ALL 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 _wait_for(self, target_cmd: int, timeout: float = 2.0) -> Optional[bytes]: Param round-robin echoes are stashed into self._params in passing
"""Wait for a specific command response, processing others.""" (only once counter > 0 — fields are stale during the post-reset window).
deadline = time.monotonic() + timeout """
while time.monotonic() < deadline: data = self.ser.read(4096)
remaining = deadline - time.monotonic() if not data:
if remaining <= 0: return
break for b in self._parser.feed(data):
data = self.ser.read(self.ser.in_waiting or 1) if b.counter > 0 and b.param_id in PARAM_BY_ID:
if data: self._params[b.param_id] = decode_param_bits(b.param_type, b.param_value)
for cmd, payload in self._parser.feed(data): self._pending.append(b)
if cmd == target_cmd:
return payload def _pump(self, deadline: float) -> Iterator[BroadcastData]:
# Cache param values seen in passing """Yield validated frames until deadline.
if cmd in (CMD_PARAM_VALUE, CMD_PARAM_WRITE_ACK):
result = _decode_param_value(payload) Frames are staged through self._pending so nothing is lost when a
if result: caller stops iterating early (generator abandoned mid-chunk).
self._param_cache[result[0]] = result[1] """
# Cache telemetry too while True:
if cmd == CMD_TELEMETRY: while self._pending:
self._last_telemetry = _decode_telemetry(payload) yield self._pending.popleft()
return None if time.monotonic() >= deadline:
return
self._drain_serial()
# ── Commands ───────────────────────────────────────────────────── # ── Commands ─────────────────────────────────────────────────────
def ping(self, timeout: float = 2.0) -> bool: def ping(self, timeout: float = 2.0) -> bool:
"""Send PING, return True if PONG received.""" """Send PING, return True when the broadcast pong counter increments."""
self._send(_build_frame(CMD_PING)) baseline: Optional[int] = None
return self._wait_for(CMD_PONG, timeout) is not 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): def shutdown(self):
"""Command the converter off.""" """Command the converter off."""
self._send(_build_frame(CMD_SHUTDOWN)) self._send(build_shutdown())
def reset(self): def reset(self):
"""Command a system reset.""" """Command a system reset."""
self._send(_build_frame(CMD_RESET)) self._send(build_reset())
def test_50(self): def test_50(self):
"""Enter 50% duty test mode.""" """Enter 50% duty test mode."""
self._send(_build_frame(CMD_TEST_50)) self._send(build_test_50())
def relay_on(self): def relay_on(self):
"""Latch the input relay closed (bench test).""" """Latch the input relay closed (bench test)."""
self._send(_build_frame(CMD_RELAY_ON)) self._send(build_relay_on())
def relay_off(self): def relay_off(self):
"""Latch the input relay open (bench test).""" """Latch the input relay open (bench test)."""
self._send(_build_frame(CMD_RELAY_OFF)) self._send(build_relay_off())
def hold_converter(self): def hold_converter(self):
"""Toggle 'hold converter off' (boot guard + disarm trips).""" """Toggle 'hold converter off' (boot guard + disarm trips)."""
self._send(_build_frame(CMD_HOLD_CONVERTER)) self._send(build_hold_converter())
def toggle_precharge(self): def toggle_precharge(self):
"""Toggle the precharge FET (bench test).""" """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]: def clear_flags(self):
"""Wait for next telemetry packet.""" """Clear latched status flags."""
payload = self._wait_for(CMD_TELEMETRY, timeout) self._send(build_clear_flags())
if payload:
return _decode_telemetry(payload) # ── 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 return None
def read_telemetry_avg(self, n: int = 10, timeout: float = 5.0) -> Optional[Telemetry]: def read_telemetry_avg(self, n: int = 10, timeout: float = 5.0) -> Optional[BroadcastData]:
"""Read n telemetry packets and return the average.""" """Average n fresh publishes (~n/100 s). Float fields are averaged;
samples: list[Telemetry] = [] int/flag fields come from the last sample."""
samples: list[BroadcastData] = []
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
while len(samples) < n and time.monotonic() < deadline: while len(samples) < n and time.monotonic() < deadline:
t = self.read_telemetry(timeout=deadline - time.monotonic()) t = self.read_telemetry(timeout=deadline - time.monotonic())
@@ -433,43 +550,38 @@ class STM32Link:
samples.append(t) samples.append(t)
if not samples: if not samples:
return None return None
# Average all analog float fields avg = dataclasses.replace(samples[-1])
avg = Telemetry()
for attr in ("vin", "vout", "iin", "iout", "vfly", "etemp", "btemp", for attr in ("vin", "vout", "iin", "iout", "vfly", "etemp", "btemp",
"vfly_integral", "vfly_avg_debug", "cc_output_f", "vfly_integral", "vfly_avg_debug", "cc_output_f",
"mppt_iref", "mppt_last_vin", "mppt_last_iin", "mppt_iref", "mppt_last_vin", "mppt_last_iin",
"p_in", "p_out", "iout_slow"): "p_in", "p_out", "iout_slow"):
setattr(avg, attr, sum(getattr(s, attr) for s in samples) / len(samples)) setattr(avg, attr, sum(getattr(s, attr) for s in samples) / len(samples))
avg.seq = samples[-1].seq
return avg return avg
def request_all_params(self): # ── Parameters ───────────────────────────────────────────────────
"""Request all parameter values from the STM32."""
self._send(_build_frame(CMD_PARAM_READ_ALL))
def read_all_params(self, timeout: float = 3.0) -> dict[str, float]: def request_all_params(self):
"""Request and collect all parameter values.""" """Restart the broadcast param round-robin from the first param."""
self._param_cache.clear() 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() self.request_all_params()
deadline = time.monotonic() + timeout for _ in self._pump(time.monotonic() + timeout):
while time.monotonic() < deadline: if len(self._params) >= len(PARAMS):
data = self.ser.read(self.ser.in_waiting or 1) break
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
return { return {
PARAM_BY_ID[pid].name: val 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 if pid in PARAM_BY_ID
} }
def write_param(self, name: str, value: float, wait_ack: bool = True) -> bool: 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) pdef = PARAM_BY_NAME.get(name)
if not pdef: if not pdef:
raise ValueError(f"Unknown parameter: {name!r}") raise ValueError(f"Unknown parameter: {name!r}")
@@ -477,17 +589,22 @@ class STM32Link:
raise ValueError( raise ValueError(
f"{name}: {value} out of range [{pdef.min_val}, {pdef.max_val}]" 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)
self._send(frame) if not wait_ack:
if wait_ack: self._send(frame)
payload = self._wait_for(CMD_PARAM_WRITE_ACK, timeout=2.0) return True
if payload: clamped = max(pdef.min_val, min(pdef.max_val, float(value)))
result = _decode_param_value(payload) expect = f"{clamped:{pdef.fmt}}"
if result: for _ in range(6):
self._param_cache[result[0]] = result[1] self._send(frame)
return True # one full round-robin rotation is ~0.4 s; 0.5 s sees the echo once
return False for b in self._pump(time.monotonic() + 0.5):
return True 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: def write_param_by_id(self, param_id: int, value: float) -> bool:
"""Write a parameter by ID.""" """Write a parameter by ID."""
+1 -1
View File
@@ -61,7 +61,7 @@ class Tuner:
self, self,
bench: MPPTTestbench, bench: MPPTTestbench,
link: STM32Link, link: STM32Link,
settle_time: float = 3.0, settle_time: float = 5.0,
stm_avg_samples: int = 10, stm_avg_samples: int = 10,
): ):
self.bench = bench self.bench = bench