tooling: sync bench + debug console to live STM32 protocol

The mppt-testbench Python tooling had drifted from the flashed firmware
(bundled fw e7a23a3 vs live 1b85532) and could no longer communicate:

- stm32_link.py: CRC8 -> CRC-16/CCITT-FALSE; telemetry 68B -> 78B
  (btemp, cmp_outer/inner, iout_slow, vfly_ofs_applied); add PTYPE_INT16
  and commands 0x12-0x18; replace PARAMS with the current 37-param map
  (single dt_normal, no dt brackets; test_corr/phase_ofs, phase PI,
  precharge PI, duty dither; vfly_active 0-3).
- tuner.py: retire per-bracket deadtime; sweep the single dt_normal.
- cli.py: update tune-deadtime, help/examples, btemp readout;
  default ports COM11 (load) / COM4 (stm32).
- debug console TUI: sync protocol.py/app.py/status_bar/telemetry_panel
  from live (new command keys, link RX/TX/loss stats, single dead-time,
  new telemetry fields, param-write auto-retry); add duty_fft.py.
- README: rewrite parameter table, deadtime section, ports, keybindings.

Verified: protocol round-trip self-tests + live `bench stm32-read`
reading all 37 params over COM4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
janik
2026-07-01 12:03:51 +07:00
co-authored by Claude Opus 4.8
parent 903fa78585
commit d2dfc73f9e
9 changed files with 656 additions and 266 deletions
+51 -56
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from testbench.bench import MPPTTestbench, IDLE_VOLTAGE
from testbench.stm32_link import (
STM32Link, Telemetry, PARAM_BY_NAME, DT_BRACKETS,
STM32Link, Telemetry, PARAM_BY_NAME,
)
@@ -199,6 +199,12 @@ class Tuner:
# ── Deadtime optimization ────────────────────────────────────────
# Firmware now uses a single global dead-time (`dt_normal`, 0x60) rather
# than per-current-bracket values, so we sweep the one parameter — optionally
# at several load points to expose any load dependence — and pick the best.
DT_PARAM = "dt_normal"
def tune_deadtime(
self,
dt_start: int = 14,
@@ -209,45 +215,37 @@ class Tuner:
load_mode: str = "CP",
load_values: list[float] | None = None,
settle_time: float | None = None,
) -> dict[str, list[TunePoint]]:
"""Optimize deadtime for each current bracket.
) -> list[TunePoint]:
"""Optimize the single global dead-time (`dt_normal`).
For each deadtime bracket, sets a load that puts the converter
in that current range, then sweeps deadtime values to find the
optimum.
Sweeps `dt_normal` from `dt_start` to `dt_stop` at each requested load
point and returns a flat list of measurements. The best value can be
applied with :meth:`apply_best_deadtime`.
Args:
load_values: Load setpoints to test (one per DT bracket).
If None, auto-selects based on bracket midpoints.
load_values: Load setpoints to test. If None, a single mid-range
load is used.
"""
settle = settle_time or self.settle_time
if load_values is None:
# Auto-select load values targeting the middle of each bracket
# Using CP mode: power ≈ voltage × current
load_values = []
for _, _, i_lo, i_hi in DT_BRACKETS:
mid_i_A = (i_lo + i_hi) / 2 / 1000.0 # mA → A
target_power = voltage * mid_i_A * 0.4 # rough vout/vin ratio
load_values.append(max(10.0, target_power))
if not load_values:
load_values = [200.0 if load_mode == "CP" else 5.0]
unit = "A" if load_mode == "CC" else "W"
print("=" * 80)
print("DEADTIME OPTIMIZATION")
print("DEAD-TIME OPTIMIZATION (dt_normal)")
print(f" DT range: {dt_start}{dt_stop} (step {dt_step})")
print(f" V={voltage:.0f}V, I_limit={current_limit:.0f}A, mode={load_mode}")
print(f" Loads: {', '.join(f'{lv:.0f}{unit}' for lv in load_values)}")
print("=" * 80)
all_results: dict[str, list[TunePoint]] = {}
for i, (param_id, param_name, i_lo, i_hi) in enumerate(DT_BRACKETS):
load_val = load_values[i] if i < len(load_values) else load_values[-1]
unit = "A" if load_mode == "CC" else "W"
print(f"\n── Bracket: {param_name} ({i_lo/1000:.0f}-{i_hi/1000:.0f}A) "
f"@ {load_mode}={load_val:.0f}{unit} ──")
all_results: list[TunePoint] = []
best_per_load: list[tuple[float, TunePoint]] = []
for load_val in load_values:
print(f"\n── {self.DT_PARAM} @ {load_mode}={load_val:.0f}{unit} ──")
results = self.sweep_param(
param_name=param_name,
param_name=self.DT_PARAM,
start=dt_start,
stop=dt_stop,
step=dt_step,
@@ -257,44 +255,41 @@ class Tuner:
load_value=load_val,
settle_time=settle,
)
all_results[param_name] = results
all_results.extend(results)
# Find and report best
if results:
valid = [p for p in results if 0 < p.meter_eff < 110]
if valid:
best = max(valid, key=lambda p: p.meter_eff)
print(f" ★ Best: {param_name}={best.param_value:.0f}"
f"EFF={best.meter_eff:.2f}%")
# Summary
print("\n" + "=" * 80)
print("DEADTIME OPTIMIZATION SUMMARY")
print(f"{'Bracket':<15} {'Best DT':>8} {'Efficiency':>12} {'Temp':>8}")
print("-" * 45)
for param_name, results in all_results.items():
valid = [p for p in results if 0 < p.meter_eff < 110]
if valid:
best = max(valid, key=lambda p: p.meter_eff)
print(f"{param_name:<15} {best.param_value:>8.0f} "
f"{best.meter_eff:>11.2f}% {best.stm_etemp:>7.0f}°C")
else:
print(f"{param_name:<15} {'N/A':>8} {'N/A':>12} {'N/A':>8}")
best_per_load.append((load_val, best))
print(f" ★ Best: {self.DT_PARAM}={best.param_value:.0f}"
f"EFF={best.meter_eff:.2f}%")
# Summary
print("\n" + "=" * 80)
print("DEAD-TIME OPTIMIZATION SUMMARY")
print(f"{'Load':<12} {'Best DT':>8} {'Efficiency':>12} {'Temp':>8}")
print("-" * 42)
for load_val, best in best_per_load:
print(f"{load_val:<11.0f}{unit} {best.param_value:>8.0f} "
f"{best.meter_eff:>11.2f}% {best.stm_etemp:>7.0f}°C")
if not best_per_load:
print(" (no valid points)")
print("=" * 80)
return all_results
def apply_best_deadtimes(self, results: dict[str, list[TunePoint]]):
"""Apply the best deadtime from each bracket to the STM32."""
print("\nApplying optimal deadtimes:")
for param_name, points in results.items():
valid = [p for p in points if 0 < p.meter_eff < 110]
if valid:
best = max(valid, key=lambda p: p.meter_eff)
val = int(best.param_value)
ack = self.link.write_param(param_name, val)
status = "OK" if ack else "NO ACK"
print(f" {param_name} = {val} ({status})")
def apply_best_deadtime(self, results: list[TunePoint]):
"""Apply the single best dead-time (highest efficiency) to the STM32."""
valid = [p for p in results if 0 < p.meter_eff < 110]
if not valid:
print("\nNo valid points — dead-time not applied.")
return
best = max(valid, key=lambda p: p.meter_eff)
val = int(best.param_value)
ack = self.link.write_param(self.DT_PARAM, val)
status = "OK" if ack else "NO ACK"
print(f"\nApplying best dead-time: {self.DT_PARAM} = {val} "
f"(EFF={best.meter_eff:.2f}%) ({status})")
# ── Multi-point sweep ────────────────────────────────────────────