Files
mppt-testbench/testbench/tuner.py
T
janikandClaude Fable 5 7f8672d7b9 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>
2026-07-03 18:06:02 +07:00

439 lines
15 KiB
Python

"""Automated tuning routines combining testbench instruments + STM32 link.
Uses the power analyzer (HIOKI) as ground truth for efficiency while
adjusting converter parameters via the STM32 debug protocol.
"""
from __future__ import annotations
import csv
import time
from dataclasses import dataclass, field
from pathlib import Path
from testbench.bench import MPPTTestbench, IDLE_VOLTAGE
from testbench.stm32_link import (
STM32Link, Telemetry, PARAM_BY_NAME,
)
@dataclass
class TunePoint:
"""One measurement during a tuning sweep."""
param_name: str
param_value: float
voltage_set: float
load_setpoint: float
load_mode: str
# HIOKI measurements (ground truth)
meter_pin: float = 0.0
meter_pout: float = 0.0
meter_eff: float = 0.0
# STM32 telemetry
stm_vin: float = 0.0
stm_vout: float = 0.0
stm_iin: float = 0.0
stm_iout: float = 0.0
stm_eff: float = 0.0
stm_vfly: float = 0.0
stm_etemp: float = 0.0
timestamp: float = field(default_factory=time.time)
class Tuner:
"""Combines MPPTTestbench + STM32Link for automated tuning.
Usage::
tuner = Tuner(bench, link)
results = tuner.sweep_param(
"dt_10_20A", start=14, stop=40, step=1,
voltage=60.0, current_limit=20.0,
load_mode="CP", load_value=300.0,
)
tuner.print_results(results)
"""
def __init__(
self,
bench: MPPTTestbench,
link: STM32Link,
settle_time: float = 5.0,
stm_avg_samples: int = 10,
):
self.bench = bench
self.link = link
self.settle_time = settle_time
self.stm_avg_samples = stm_avg_samples
def _measure(
self,
param_name: str,
param_value: float,
voltage: float,
load_value: float,
load_mode: str,
) -> TunePoint:
"""Take one combined measurement from HIOKI + STM32."""
point = TunePoint(
param_name=param_name,
param_value=param_value,
voltage_set=voltage,
load_setpoint=load_value,
load_mode=load_mode,
)
# HIOKI measurement
meter_vals = self.bench._wait_meter_ready(max_retries=10, retry_delay=1.0)
point.meter_pin = meter_vals.get("P5", 0.0)
point.meter_pout = meter_vals.get("P6", 0.0)
point.meter_eff = meter_vals.get("EFF1", 0.0)
# STM32 telemetry (averaged)
t = self.link.read_telemetry_avg(n=self.stm_avg_samples)
if t:
point.stm_vin = t.vin_V
point.stm_vout = t.vout_V
point.stm_iin = t.iin_A
point.stm_iout = t.iout_A
point.stm_eff = t.efficiency
point.stm_vfly = t.vfly / 1000.0
point.stm_etemp = t.etemp
return point
# ── Parameter sweep ──────────────────────────────────────────────
def sweep_param(
self,
param_name: str,
start: float,
stop: float,
step: float,
voltage: float,
current_limit: float,
load_mode: str = "CC",
load_value: float = 5.0,
settle_time: float | None = None,
) -> list[TunePoint]:
"""Sweep a single STM32 parameter while measuring efficiency.
Sets up the testbench at the given operating point, then steps
the parameter from start to stop, measuring at each step.
Returns list of TunePoints with both HIOKI and STM32 data.
"""
if param_name not in PARAM_BY_NAME:
raise ValueError(f"Unknown parameter: {param_name!r}")
settle = settle_time or self.settle_time
if step == 0:
raise ValueError("step cannot be zero")
if start > stop and step > 0:
step = -step
elif start < stop and step < 0:
step = -step
# Count steps
n_steps = int(abs(stop - start) / abs(step)) + 1
unit = "A" if load_mode == "CC" else "W"
print(f"Parameter sweep: {param_name} = {start}{stop} (step {step})")
print(f" Operating point: V={voltage:.1f}V, {load_mode}={load_value:.1f}{unit}")
print(f" {n_steps} points, settle={settle:.1f}s")
print()
# Set up testbench
self.bench.supply.set_current(current_limit)
self.bench.supply.set_voltage(voltage)
self.bench.supply.output_on()
self.bench.load.set_mode(load_mode)
self.bench._apply_load_value(load_mode, load_value)
self.bench.load.load_on()
# Initial settle
print(" Settling...")
time.sleep(settle * 2)
results: list[TunePoint] = []
val = start
n = 0
try:
while True:
if step > 0 and val > stop + step / 2:
break
if step < 0 and val < stop + step / 2:
break
# Write parameter
ack = self.link.write_param(param_name, val)
if not ack:
print(f" WARNING: No ACK for {param_name}={val}")
time.sleep(settle)
# Measure
point = self._measure(param_name, val, voltage, load_value, load_mode)
results.append(point)
n += 1
print(
f" [{n:>3d}/{n_steps}] {param_name}={val:>6.1f} "
f"HIOKI: Pin={point.meter_pin:7.1f}W Pout={point.meter_pout:7.1f}W "
f"EFF={point.meter_eff:5.2f}% "
f"STM32: EFF={point.stm_eff:5.1f}% T={point.stm_etemp:.0f}°C"
)
val += step
finally:
self.bench.load.load_off()
self.bench.supply.set_voltage(IDLE_VOLTAGE)
print(f"\n Load OFF. Supply at {IDLE_VOLTAGE:.0f}V.")
return results
# ── 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,
dt_stop: int = 50,
dt_step: int = 1,
voltage: float = 60.0,
current_limit: float = 20.0,
load_mode: str = "CP",
load_values: list[float] | None = None,
settle_time: float | None = None,
) -> list[TunePoint]:
"""Optimize the single global dead-time (`dt_normal`).
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. If None, a single mid-range
load is used.
"""
settle = settle_time or self.settle_time
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("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: 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=self.DT_PARAM,
start=dt_start,
stop=dt_stop,
step=dt_step,
voltage=voltage,
current_limit=current_limit,
load_mode=load_mode,
load_value=load_val,
settle_time=settle,
)
all_results.extend(results)
valid = [p for p in results if 0 < p.meter_eff < 110]
if valid:
best = max(valid, key=lambda p: p.meter_eff)
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_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 ────────────────────────────────────────────
def sweep_param_multi(
self,
param_name: str,
start: float,
stop: float,
step: float,
voltages: list[float],
current_limit: float,
load_mode: str = "CP",
load_values: list[float] | None = None,
settle_time: float | None = None,
) -> list[TunePoint]:
"""Sweep a parameter across multiple voltage/load combinations.
Produces a comprehensive dataset showing how the parameter
affects efficiency across the full operating range.
"""
if load_values is None:
load_values = [200.0] # default: 200W
all_results: list[TunePoint] = []
for v in voltages:
for lv in load_values:
unit = "A" if load_mode == "CC" else "W"
print(f"\n── V={v:.0f}V, {load_mode}={lv:.0f}{unit} ──")
results = self.sweep_param(
param_name=param_name,
start=start, stop=stop, step=step,
voltage=v, current_limit=current_limit,
load_mode=load_mode, load_value=lv,
settle_time=settle_time,
)
all_results.extend(results)
return all_results
# ── Output ───────────────────────────────────────────────────────
@staticmethod
def print_results(results: list[TunePoint]):
"""Print a summary table of tuning results."""
if not results:
print("No results.")
return
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"\nBest: {best.param_name}={best.param_value:.1f} → "
f"EFF={best.meter_eff:.2f}% "
f"(Pin={best.meter_pin:.1f}W Pout={best.meter_pout:.1f}W)")
@staticmethod
def write_csv(results: list[TunePoint], path: str):
"""Write tuning results to CSV."""
if not results:
return
with open(path, "w", newline="") as f:
w = csv.writer(f)
w.writerow([
"param_name", "param_value",
"voltage_set", "load_setpoint", "load_mode",
"meter_pin", "meter_pout", "meter_eff",
"stm_vin", "stm_vout", "stm_iin", "stm_iout",
"stm_eff", "stm_vfly", "stm_etemp",
])
for p in results:
w.writerow([
p.param_name, f"{p.param_value:.4f}",
f"{p.voltage_set:.4f}", f"{p.load_setpoint:.4f}", p.load_mode,
f"{p.meter_pin:.4f}", f"{p.meter_pout:.4f}", f"{p.meter_eff:.4f}",
f"{p.stm_vin:.4f}", f"{p.stm_vout:.4f}",
f"{p.stm_iin:.4f}", f"{p.stm_iout:.4f}",
f"{p.stm_eff:.4f}", f"{p.stm_vfly:.4f}", f"{p.stm_etemp:.4f}",
])
print(f"Results saved to {path}")
@staticmethod
def plot_sweep(results: list[TunePoint], show: bool = True):
"""Plot parameter sweep results."""
import numpy as np
import matplotlib.pyplot as plt
if not results:
return
param_name = results[0].param_name
vals = np.array([p.param_value for p in results])
eff_hioki = np.array([p.meter_eff for p in results])
eff_stm = np.array([p.stm_eff for p in results])
temp = np.array([p.stm_etemp for p in results])
# Filter valid
valid = (eff_hioki > 0) & (eff_hioki < 110)
# Group by operating point
ops = sorted(set((p.voltage_set, p.load_setpoint) for p in results))
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8), sharex=True)
cmap = plt.cm.viridis
for i, (v, l) in enumerate(ops):
color = cmap(i / max(len(ops) - 1, 1))
mask = np.array([
(p.voltage_set == v and p.load_setpoint == l and
0 < p.meter_eff < 110)
for p in results
])
if not np.any(mask):
continue
x = vals[mask]
order = np.argsort(x)
unit = results[0].load_mode
ax1.plot(x[order], eff_hioki[mask][order], "o-", color=color,
markersize=4, label=f"{v:.0f}V/{l:.0f}{unit}")
ax2.plot(x[order], temp[mask][order], "o-", color=color,
markersize=4, label=f"{v:.0f}V/{l:.0f}{unit}")
# Mark best
valid_pts = [p for p in results if 0 < p.meter_eff < 110]
if valid_pts:
best = max(valid_pts, key=lambda p: p.meter_eff)
ax1.axvline(best.param_value, color="red", linestyle="--", alpha=0.5)
ax1.plot(best.param_value, best.meter_eff, "*", color="red",
markersize=15, zorder=10,
label=f"Best: {best.param_value:.0f}{best.meter_eff:.2f}%")
ax1.set_ylabel("Efficiency (%)", fontsize=12)
ax1.set_title(f"Parameter Sweep: {param_name}", fontsize=14)
ax1.legend(fontsize=8)
ax1.grid(True, alpha=0.3)
ax2.set_xlabel(param_name, fontsize=12)
ax2.set_ylabel("Temperature (°C)", fontsize=12)
ax2.legend(fontsize=8)
ax2.grid(True, alpha=0.3)
fig.tight_layout()
if show:
plt.show()
return fig