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:
+18
-14
@@ -584,7 +584,8 @@ def cmd_stm32_read(bench: MPPTTestbench, args: argparse.Namespace) -> None:
|
||||
print(f" Pout = {t.power_out_W:8.2f} W")
|
||||
print(f" EFF = {t.efficiency:8.1f} %")
|
||||
print(f" Vfly = {t.vfly/1000:8.2f} V")
|
||||
print(f" Temp = {t.etemp:8.1f} °C")
|
||||
print(f" Temp = {t.etemp:8.1f} °C (FET)")
|
||||
print(f" Tbrd = {t.btemp:8.1f} °C (board)")
|
||||
|
||||
|
||||
def cmd_stm32_write(bench: MPPTTestbench, args: argparse.Namespace) -> None:
|
||||
@@ -714,7 +715,7 @@ def cmd_tune_param_vsweep(bench: MPPTTestbench, args: argparse.Namespace) -> Non
|
||||
|
||||
|
||||
def cmd_tune_deadtime(bench: MPPTTestbench, args: argparse.Namespace) -> None:
|
||||
"""Optimize deadtime for each current bracket."""
|
||||
"""Optimize the single global dead-time (dt_normal)."""
|
||||
from testbench.stm32_link import STM32Link
|
||||
from testbench.tuner import Tuner
|
||||
|
||||
@@ -740,8 +741,11 @@ def cmd_tune_deadtime(bench: MPPTTestbench, args: argparse.Namespace) -> None:
|
||||
settle_time=args.settle,
|
||||
)
|
||||
|
||||
if args.output:
|
||||
tuner.write_csv(results, args.output)
|
||||
|
||||
if args.apply:
|
||||
tuner.apply_best_deadtimes(results)
|
||||
tuner.apply_best_deadtime(results)
|
||||
|
||||
if args.output:
|
||||
all_pts = []
|
||||
@@ -1123,9 +1127,9 @@ examples:
|
||||
%(prog)s safe-off
|
||||
%(prog)s plot-sweep sweep_vi_20260312_151212.csv
|
||||
%(prog)s plot-sweep sweep_vi_20260312_151212.csv --no-show -o plots/
|
||||
%(prog)s stm32-read --stm32-port COM28
|
||||
%(prog)s tune-param --stm32-port COM28 --param dt_10_20A --start 14 --stop 40 --step 1 --voltage 60 --current-limit 20 --load-mode CP --load-value 300
|
||||
%(prog)s tune-deadtime --stm32-port COM28 --voltage 60 --current-limit 20 --load-mode CP --apply
|
||||
%(prog)s stm32-read --stm32-port COM4
|
||||
%(prog)s tune-param --stm32-port COM4 --param dt_normal --start 14 --stop 40 --step 1 --voltage 60 --current-limit 20 --load-mode CP --load-value 300
|
||||
%(prog)s tune-deadtime --stm32-port COM4 --voltage 60 --current-limit 20 --load-mode CP --apply
|
||||
""",
|
||||
)
|
||||
|
||||
@@ -1135,8 +1139,8 @@ examples:
|
||||
help="IT6500D VISA address (auto-detect if omitted)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--load-port", default="COM1",
|
||||
help="Prodigit 3366G serial port (default: COM1)",
|
||||
"--load-port", default="COM11",
|
||||
help="Prodigit 3366G serial port (default: COM11)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--load-baud", type=int, default=115200,
|
||||
@@ -1151,8 +1155,8 @@ examples:
|
||||
help="VISA timeout in ms (default: 5000)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stm32-port", default="COM28",
|
||||
help="STM32 debug serial port (default: COM28)",
|
||||
"--stm32-port", default="COM4",
|
||||
help="STM32 debug serial port (default: COM4)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stm32-baud", type=int, default=460800,
|
||||
@@ -1263,7 +1267,7 @@ examples:
|
||||
|
||||
# tune-param
|
||||
p_tp = sub.add_parser("tune-param", help="Sweep an STM32 parameter while measuring efficiency")
|
||||
p_tp.add_argument("--param", required=True, help="Parameter name (e.g. dt_10_20A, vfly_kp)")
|
||||
p_tp.add_argument("--param", required=True, help="Parameter name (e.g. dt_normal, vfly_kp)")
|
||||
p_tp.add_argument("--start", type=float, required=True, help="Start value")
|
||||
p_tp.add_argument("--stop", type=float, required=True, help="Stop value")
|
||||
p_tp.add_argument("--step", type=float, required=True, help="Step size")
|
||||
@@ -1277,7 +1281,7 @@ examples:
|
||||
|
||||
# tune-param-vsweep
|
||||
p_tv = sub.add_parser("tune-param-vsweep", help="Sweep an STM32 parameter across a voltage range")
|
||||
p_tv.add_argument("--param", required=True, help="Parameter name (e.g. dt_10_20A, vfly_kp)")
|
||||
p_tv.add_argument("--param", required=True, help="Parameter name (e.g. dt_normal, vfly_kp)")
|
||||
p_tv.add_argument("--start", type=float, required=True, help="Param start value")
|
||||
p_tv.add_argument("--stop", type=float, required=True, help="Param stop value")
|
||||
p_tv.add_argument("--step", type=float, required=True, help="Param step size")
|
||||
@@ -1291,14 +1295,14 @@ examples:
|
||||
p_tv.add_argument("-o", "--output", help="Output prefix for CSVs (default: param name)")
|
||||
|
||||
# tune-deadtime
|
||||
p_td = sub.add_parser("tune-deadtime", help="Optimize deadtime for each current bracket")
|
||||
p_td = sub.add_parser("tune-deadtime", help="Optimize the single global dead-time (dt_normal)")
|
||||
p_td.add_argument("--dt-start", type=int, default=14, help="Min deadtime ticks (default: 14)")
|
||||
p_td.add_argument("--dt-stop", type=int, default=50, help="Max deadtime ticks (default: 50)")
|
||||
p_td.add_argument("--dt-step", type=int, default=1, help="Deadtime step (default: 1)")
|
||||
p_td.add_argument("--voltage", type=float, default=60.0, help="Supply voltage (V)")
|
||||
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 per bracket (e.g. 20,50,100,250,400,600)")
|
||||
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("--apply", action="store_true", help="Apply best deadtimes after sweep")
|
||||
p_td.add_argument("-o", "--output", help="CSV output file")
|
||||
|
||||
+144
-87
@@ -1,8 +1,9 @@
|
||||
"""Synchronous serial link to the STM32 debug protocol.
|
||||
|
||||
Provides blocking read/write of telemetry and parameters, suitable
|
||||
for automated tuning scripts (not a TUI). Reuses the binary protocol
|
||||
from code64/debug_console/protocol.py.
|
||||
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).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,32 +26,34 @@ CMD_PARAM_READ_ALL = 0x04
|
||||
CMD_PARAM_VALUE = 0x05
|
||||
CMD_PING = 0x10
|
||||
CMD_PONG = 0x11
|
||||
CMD_SHUTDOWN = 0x12 # turn off converter
|
||||
CMD_RESET = 0x13 # system reset
|
||||
CMD_TEST_50 = 0x14 # 50% duty test mode
|
||||
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
|
||||
|
||||
PTYPE_FLOAT = 0
|
||||
PTYPE_UINT16 = 1
|
||||
PTYPE_UINT8 = 2
|
||||
PTYPE_INT32 = 3
|
||||
PTYPE_INT16 = 4 # wire format = sign-extended int32, stored firmware-side as int16_t
|
||||
|
||||
|
||||
# ── CRC8 (poly 0x07) ────────────────────────────────────────────────
|
||||
# ── CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflection) ──────
|
||||
# Matches the STM32 hardware CRC unit configured in main.c MX_CRC_Init.
|
||||
|
||||
_CRC8_TABLE = [0] * 256
|
||||
|
||||
def _init_crc8():
|
||||
for i in range(256):
|
||||
crc = i
|
||||
for _ in range(8):
|
||||
crc = ((crc << 1) ^ 0x07) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF
|
||||
_CRC8_TABLE[i] = crc
|
||||
|
||||
_init_crc8()
|
||||
|
||||
|
||||
def crc8(data: bytes) -> int:
|
||||
crc = 0x00
|
||||
def crc16(data: bytes) -> int:
|
||||
crc = 0xFFFF
|
||||
for b in data:
|
||||
crc = _CRC8_TABLE[crc ^ b]
|
||||
crc ^= b << 8
|
||||
for _ in range(8):
|
||||
if crc & 0x8000:
|
||||
crc = ((crc << 1) ^ 0x1021) & 0xFFFF
|
||||
else:
|
||||
crc = (crc << 1) & 0xFFFF
|
||||
return crc
|
||||
|
||||
|
||||
@@ -58,16 +61,18 @@ def crc8(data: bytes) -> int:
|
||||
|
||||
@dataclass
|
||||
class Telemetry:
|
||||
"""Decoded telemetry packet from the STM32."""
|
||||
"""Decoded telemetry packet from the STM32 (78-byte payload)."""
|
||||
vin: float = 0.0 # mV
|
||||
vout: float = 0.0 # mV
|
||||
iin: float = 0.0 # mA (negative = into converter)
|
||||
iout: float = 0.0 # mA
|
||||
vfly: float = 0.0 # mV
|
||||
etemp: float = 0.0 # °C
|
||||
etemp: float = 0.0 # °C (FET / 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
|
||||
@@ -76,7 +81,10 @@ class Telemetry:
|
||||
mppt_last_iin: float = 0.0
|
||||
p_in: float = 0.0
|
||||
p_out: float = 0.0
|
||||
iout_slow: float = 0.0
|
||||
seq: int = 0
|
||||
cmp_inner: int = 0 # HRTIM Timer E CMP1xR (inner pair, T2/T3)
|
||||
vfly_ofs_applied: int = 0 # master-phase offset last written, signed ticks
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
@property
|
||||
@@ -109,7 +117,7 @@ class Telemetry:
|
||||
return (self.power_out_W / p_in * 100.0) if p_in > 0.1 else 0.0
|
||||
|
||||
|
||||
_TELEM_FMT = "<6f hHh h 6f 2f B3x" # 68 bytes
|
||||
_TELEM_FMT = "<7f hHhH 6f 3f BxH h" # 78 bytes
|
||||
_TELEM_SIZE = struct.calcsize(_TELEM_FMT)
|
||||
|
||||
|
||||
@@ -118,12 +126,13 @@ def _decode_telemetry(payload: bytes) -> Optional[Telemetry]:
|
||||
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],
|
||||
last_tmp=v[6], VREF=v[7], vfly_correction=v[8],
|
||||
vfly_integral=v[10], vfly_avg_debug=v[11],
|
||||
cc_output_f=v[12], mppt_iref=v[13],
|
||||
mppt_last_vin=v[14], mppt_last_iin=v[15],
|
||||
p_in=v[16], p_out=v[17], seq=v[18],
|
||||
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],
|
||||
)
|
||||
|
||||
|
||||
@@ -140,62 +149,66 @@ class ParamDef:
|
||||
fmt: str = ".4f"
|
||||
|
||||
|
||||
# Mirrors code64/debug_console/protocol.py PARAMS (firmware debug_protocol.c).
|
||||
PARAMS = [
|
||||
# Compensator
|
||||
ParamDef(0x25, "VREF", PTYPE_UINT16, "Compensator", 3100, 3700, ".0f"),
|
||||
ParamDef(0x25, "VREF", PTYPE_UINT16, "Compensator", 2340, 3500, ".0f"),
|
||||
# Vfly
|
||||
ParamDef(0x20, "vfly_kp", PTYPE_FLOAT, "Vfly", -10, 10, ".4f"),
|
||||
ParamDef(0x21, "vfly_ki", PTYPE_FLOAT, "Vfly", -10, 10, ".6f"),
|
||||
ParamDef(0x22, "vfly_clamp", PTYPE_UINT16, "Vfly", 0, 10000, ".0f"),
|
||||
ParamDef(0x23, "vfly_loop_trig", PTYPE_UINT16, "Vfly", 1, 10000, ".0f"),
|
||||
ParamDef(0x24, "vfly_active", PTYPE_UINT8, "Vfly", 0, 1, ".0f"),
|
||||
ParamDef(0x20, "vfly_kp", PTYPE_FLOAT, "Vfly", -10, 10, ".4f"),
|
||||
ParamDef(0x21, "vfly_ki", PTYPE_FLOAT, "Vfly", -10, 10, ".6f"),
|
||||
ParamDef(0x62, "vfly_kp_phase", PTYPE_FLOAT, "Vfly", -10, 10, ".4f"), # mode 2: P gain, error -> phase
|
||||
ParamDef(0x63, "vfly_phase_clamp", PTYPE_UINT16, "Vfly", 0, 10000, ".0f"), # mode 2: phase offset clamp
|
||||
ParamDef(0x22, "vfly_clamp", PTYPE_UINT16, "Vfly", 0, 10000, ".0f"),
|
||||
ParamDef(0x23, "vfly_loop_trig", PTYPE_UINT16, "Vfly", 1, 10000, ".0f"),
|
||||
ParamDef(0x24, "vfly_active", PTYPE_UINT8, "Vfly", 0, 3, ".0f"), # 0=off 1=duty-asym PI 2=phase P 3=manual both
|
||||
ParamDef(0x26, "test_corr", PTYPE_INT16, "Vfly", -3000, 3000, ".0f"), # mode-3 manual duty asymmetry
|
||||
ParamDef(0x27, "phase_ofs", PTYPE_INT16, "Vfly", -3000, 3000, ".0f"), # master-phase: mode-3 manual, mode-2 readback
|
||||
# CC
|
||||
ParamDef(0x30, "cc_target", PTYPE_FLOAT, "CC", 0, 60000, ".0f"),
|
||||
ParamDef(0x31, "cc_gain", PTYPE_FLOAT, "CC", -1, 1, ".4f"),
|
||||
ParamDef(0x32, "cc_min_step", PTYPE_FLOAT, "CC", -1000, 0, ".1f"),
|
||||
ParamDef(0x33, "cc_max_step", PTYPE_FLOAT, "CC", 0, 1000, ".1f"),
|
||||
ParamDef(0x34, "cc_loop_trig", PTYPE_UINT16, "CC", 1, 10000, ".0f"),
|
||||
ParamDef(0x35, "cc_active", PTYPE_INT32, "CC", 0, 1, ".0f"),
|
||||
ParamDef(0x30, "cc_target", PTYPE_FLOAT, "CC", 0, 60000, ".0f"),
|
||||
ParamDef(0x31, "cc_gain", PTYPE_FLOAT, "CC", -1, 1, ".4f"),
|
||||
ParamDef(0x32, "cc_min_step", PTYPE_FLOAT, "CC", -1000, 0, ".1f"),
|
||||
ParamDef(0x33, "cc_max_step", PTYPE_FLOAT, "CC", 0, 1000, ".1f"),
|
||||
ParamDef(0x34, "cc_loop_trig", PTYPE_UINT16, "CC", 1, 10000, ".0f"),
|
||||
ParamDef(0x35, "cc_active", PTYPE_INT32, "CC", 0, 1, ".0f"),
|
||||
# MPPT
|
||||
ParamDef(0x40, "mppt_step", PTYPE_FLOAT, "MPPT", 0, 10000, ".1f"),
|
||||
ParamDef(0x41, "mppt_iref_min", PTYPE_FLOAT, "MPPT", 0, 60000, ".0f"),
|
||||
ParamDef(0x42, "mppt_iref_max", PTYPE_FLOAT, "MPPT", 0, 60000, ".0f"),
|
||||
ParamDef(0x43, "mppt_dv_thresh", PTYPE_FLOAT, "MPPT", 0, 10000, ".1f"),
|
||||
ParamDef(0x44, "mppt_loop_trig", PTYPE_UINT16, "MPPT", 1, 10000, ".0f"),
|
||||
ParamDef(0x45, "mppt_active", PTYPE_INT32, "MPPT", 0, 1, ".0f"),
|
||||
ParamDef(0x46, "mppt_init_iref", PTYPE_FLOAT, "MPPT", 0, 60000, ".0f"),
|
||||
ParamDef(0x47, "mppt_deadband", PTYPE_FLOAT, "MPPT", 0, 1, ".4f"),
|
||||
# Global
|
||||
ParamDef(0x50, "vin_min_ctrl", PTYPE_FLOAT, "Global", 0, 90000, ".0f"),
|
||||
# Deadtime
|
||||
ParamDef(0x60, "dt_0_3A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"),
|
||||
ParamDef(0x61, "dt_3_5A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"),
|
||||
ParamDef(0x62, "dt_5_10A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"),
|
||||
ParamDef(0x63, "dt_10_20A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"),
|
||||
ParamDef(0x64, "dt_20_30A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"),
|
||||
ParamDef(0x65, "dt_30_45A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"),
|
||||
ParamDef(0x40, "mppt_step", PTYPE_FLOAT, "MPPT", 1, 200, ".0f"),
|
||||
ParamDef(0x41, "mppt_duty_min", PTYPE_FLOAT, "MPPT", 0, 6800, ".0f"),
|
||||
ParamDef(0x42, "mppt_duty_max", PTYPE_FLOAT, "MPPT", 0, 6800, ".0f"),
|
||||
ParamDef(0x44, "mppt_loop_trig", PTYPE_UINT16, "MPPT", 1, 50000, ".0f"),
|
||||
ParamDef(0x45, "mppt_active", PTYPE_INT32, "MPPT", 0, 1, ".0f"),
|
||||
ParamDef(0x46, "cv_threshold", PTYPE_FLOAT, "MPPT", 20000, 30000, ".0f"),
|
||||
ParamDef(0x47, "cv_hysteresis", PTYPE_FLOAT, "MPPT", 0, 5000, ".0f"),
|
||||
ParamDef(0x48, "cc_threshold", PTYPE_FLOAT, "MPPT", 0, 55000, ".0f"),
|
||||
ParamDef(0x50, "cc_hysteresis", PTYPE_FLOAT, "MPPT", 0, 10000, ".0f"),
|
||||
# Deadtime (single static value, dt register units)
|
||||
ParamDef(0x60, "dt_normal", PTYPE_UINT16, "Deadtime", 14, 200, ".0f"),
|
||||
# Manual fixed-duty mode (base duty in CMP ticks; D = override_duty/7158, 716..6442 = 10..90%)
|
||||
ParamDef(0x64, "override_duty", PTYPE_UINT16, "Manual", 716, 6442, ".0f"),
|
||||
ParamDef(0x65, "manual_duty_en", PTYPE_UINT8, "Manual", 0, 1, ".0f"),
|
||||
# Closed-loop precharge PI (drives precharge FET PWM TIM3_CH1 to Vin/2)
|
||||
ParamDef(0x76, "precharge_kp", PTYPE_FLOAT, "Precharge", 0, 100, ".3f"),
|
||||
ParamDef(0x78, "precharge_ki", PTYPE_FLOAT, "Precharge", 0, 10, ".4f"),
|
||||
ParamDef(0x77, "precharge_reg_en", PTYPE_UINT8, "Precharge", 0, 1, ".0f"),
|
||||
# Duty dither: delta-sigma the commanded duty between two out-of-band anchors (CMP ticks)
|
||||
ParamDef(0x70, "dither_en", PTYPE_UINT8, "Dither", 0, 1, ".0f"),
|
||||
ParamDef(0x71, "dither_band_lo", PTYPE_UINT16, "Dither", 716, 6442, ".0f"),
|
||||
ParamDef(0x72, "dither_band_hi", 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(0x75, "dither_dzero", PTYPE_UINT16, "Dither", 716, 6442, ".0f"),
|
||||
]
|
||||
|
||||
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}
|
||||
|
||||
# Deadtime brackets — current thresholds in mA matching firmware
|
||||
DT_BRACKETS = [
|
||||
(0x60, "dt_0_3A", 0, 3000),
|
||||
(0x61, "dt_3_5A", 3000, 5000),
|
||||
(0x62, "dt_5_10A", 5000, 10000),
|
||||
(0x63, "dt_10_20A", 10000, 20000),
|
||||
(0x64, "dt_20_30A", 20000, 30000),
|
||||
(0x65, "dt_30_45A", 30000, 45000),
|
||||
]
|
||||
|
||||
|
||||
# ── Frame building ───────────────────────────────────────────────────
|
||||
|
||||
def _build_frame(cmd: int, payload: bytes = b"") -> bytes:
|
||||
header = bytes([SYNC_BYTE, cmd, len(payload)])
|
||||
frame = header + payload
|
||||
return frame + bytes([crc8(frame)])
|
||||
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:
|
||||
@@ -207,6 +220,8 @@ def _build_param_write(param_id: int, ptype: int, value) -> bytes:
|
||||
val_bytes = struct.pack("<Bxxx", int(value))
|
||||
elif ptype == PTYPE_INT32:
|
||||
val_bytes = struct.pack("<i", int(value))
|
||||
elif ptype == PTYPE_INT16:
|
||||
val_bytes = struct.pack("<i", int(value)) # sign-extended 32-bit wire
|
||||
else:
|
||||
val_bytes = struct.pack("<I", int(value))
|
||||
payload = struct.pack("<BBxx", param_id, ptype) + val_bytes
|
||||
@@ -226,6 +241,8 @@ def _decode_param_value(payload: bytes) -> Optional[tuple[int, float]]:
|
||||
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)
|
||||
@@ -234,45 +251,57 @@ def _decode_param_value(payload: bytes) -> Optional[tuple[int, float]]:
|
||||
# ── Frame parser state machine ───────────────────────────────────────
|
||||
|
||||
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 = 0 # WAIT_SYNC
|
||||
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 feed(self, data: bytes):
|
||||
for b in data:
|
||||
if self.state == 0: # WAIT_SYNC
|
||||
if self.state == self.WAIT_SYNC:
|
||||
if b == SYNC_BYTE:
|
||||
self.buf = bytearray([b])
|
||||
self.state = 1
|
||||
elif self.state == 1: # WAIT_CMD
|
||||
self.state = self.WAIT_CMD
|
||||
elif self.state == self.WAIT_CMD:
|
||||
self.cmd = b
|
||||
self.buf.append(b)
|
||||
self.state = 2
|
||||
elif self.state == 2: # WAIT_LEN
|
||||
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 = 4
|
||||
self.state = self.WAIT_CRC_HI
|
||||
elif b > 128:
|
||||
self.state = 0
|
||||
self.state = self.WAIT_SYNC
|
||||
else:
|
||||
self.state = 3
|
||||
elif self.state == 3: # WAIT_PAYLOAD
|
||||
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 = 4
|
||||
elif self.state == 4: # WAIT_CRC
|
||||
expected = crc8(bytes(self.buf))
|
||||
self.state = 0
|
||||
if b == expected:
|
||||
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))
|
||||
|
||||
|
||||
@@ -283,11 +312,11 @@ class STM32Link:
|
||||
|
||||
Usage::
|
||||
|
||||
link = STM32Link("COM28")
|
||||
link = STM32Link("COM4")
|
||||
link.ping()
|
||||
t = link.read_telemetry()
|
||||
print(f"Vin={t.vin_V:.1f}V Iout={t.iout_A:.1f}A EFF={t.efficiency:.1f}%")
|
||||
link.write_param("dt_10_20A", 18)
|
||||
link.write_param("dt_normal", 20)
|
||||
link.close()
|
||||
"""
|
||||
|
||||
@@ -359,6 +388,34 @@ class STM32Link:
|
||||
self._send(_build_frame(CMD_PING))
|
||||
return self._wait_for(CMD_PONG, timeout) is not None
|
||||
|
||||
def shutdown(self):
|
||||
"""Command the converter off."""
|
||||
self._send(_build_frame(CMD_SHUTDOWN))
|
||||
|
||||
def reset(self):
|
||||
"""Command a system reset."""
|
||||
self._send(_build_frame(CMD_RESET))
|
||||
|
||||
def test_50(self):
|
||||
"""Enter 50% duty test mode."""
|
||||
self._send(_build_frame(CMD_TEST_50))
|
||||
|
||||
def relay_on(self):
|
||||
"""Latch the input relay closed (bench test)."""
|
||||
self._send(_build_frame(CMD_RELAY_ON))
|
||||
|
||||
def relay_off(self):
|
||||
"""Latch the input relay open (bench test)."""
|
||||
self._send(_build_frame(CMD_RELAY_OFF))
|
||||
|
||||
def hold_converter(self):
|
||||
"""Toggle 'hold converter off' (boot guard + disarm trips)."""
|
||||
self._send(_build_frame(CMD_HOLD_CONVERTER))
|
||||
|
||||
def toggle_precharge(self):
|
||||
"""Toggle the precharge FET (bench test)."""
|
||||
self._send(_build_frame(CMD_TOGGLE_PRECHARGE))
|
||||
|
||||
def read_telemetry(self, timeout: float = 2.0) -> Optional[Telemetry]:
|
||||
"""Wait for next telemetry packet."""
|
||||
payload = self._wait_for(CMD_TELEMETRY, timeout)
|
||||
@@ -376,12 +433,12 @@ class STM32Link:
|
||||
samples.append(t)
|
||||
if not samples:
|
||||
return None
|
||||
# Average all float fields
|
||||
# Average all analog float fields
|
||||
avg = Telemetry()
|
||||
for attr in ("vin", "vout", "iin", "iout", "vfly", "etemp",
|
||||
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"):
|
||||
"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
|
||||
|
||||
+51
-56
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user