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
+43 -24
View File
@@ -19,7 +19,7 @@ Unified tool for testing and tuning MPPT (Maximum Power Point Tracking) converte
+------------------+ +------------------+
| |
STM32 debug STM32 debug
serial (COM28) serial (COM4)
HIOKI EFF1 = P6 / P5 x 100% (output power / input power) HIOKI EFF1 = P6 / P5 x 100% (output power / input power)
``` ```
@@ -50,7 +50,7 @@ uv sync
uv run bench identify uv run bench identify
# Check STM32 responds (reads params + telemetry) # Check STM32 responds (reads params + telemetry)
uv run bench stm32-read --stm32-port COM28 uv run bench stm32-read --stm32-port COM4
``` ```
### 2. Configure instruments for MPPT testing ### 2. Configure instruments for MPPT testing
@@ -177,21 +177,23 @@ uv run bench tune-param \
--settle 3.0 --settle 3.0
``` ```
#### Auto-optimize all deadtime brackets #### Optimize the global dead-time
Automatically sweeps deadtime for each of the 6 current brackets (0-3A, 3-5A, 5-10A, 10-20A, 20-30A, 30-45A), setting an appropriate load for each bracket. The firmware uses a single global dead-time (`dt_normal`). `tune-deadtime` sweeps
it from `--dt-start` to `--dt-stop`, optionally at several `--load-values`, and
picks the highest-efficiency value.
```bash ```bash
# Sweep and report best values # Sweep at a single load and report the best value
uv run bench tune-deadtime \ uv run bench tune-deadtime \
--voltage 60 --current-limit 20 --load-mode CP \ --voltage 60 --current-limit 20 --load-mode CP \
--dt-start 14 --dt-stop 50 --dt-step 1 \ --dt-start 14 --dt-stop 50 --dt-step 1 \
-o deadtime_results.csv -o deadtime_results.csv
# Sweep, report, and apply best values to STM32 # Sweep across several loads, then apply the best dt_normal to the STM32
uv run bench tune-deadtime \ uv run bench tune-deadtime \
--voltage 60 --current-limit 20 --load-mode CP \ --voltage 60 --current-limit 20 --load-mode CP \
--load-values 20,50,100,250,400,600 \ --load-values 100,300,500 \
--apply --apply
``` ```
@@ -213,10 +215,12 @@ For live monitoring and parameter tuning via the Textual terminal UI:
```bash ```bash
cd code64 cd code64
uv run debug-console COM28 uv run debug-console COM4
``` ```
Keybindings: `p` ping, `r` read params, `f` toggle EMA filter, `l` show log path, `q` quit. Keybindings: `p` ping, `f` toggle EMA filter, `s` shutoff, `x` reset, `t` 50% duty test,
`c` relay on, `d` relay off, `h` hold converter, `g` toggle precharge, `q` quit.
Parameter writes are auto-retried until ACKed (the MCU's RX is EMI-lossy while switching).
### 10. Direct instrument control ### 10. Direct instrument control
@@ -270,34 +274,49 @@ uv run bench [-h] [--supply-address ADDR] [--load-port PORT] [--load-baud BAUD]
| Option | Default | Description | | Option | Default | Description |
|---|---|---| |---|---|---|
| `--supply-address` | auto-detect | IT6500D VISA address | | `--supply-address` | auto-detect | IT6500D VISA address |
| `--load-port` | `COM1` | Prodigit 3366G serial port | | `--load-port` | `COM11` | Prodigit 3366G serial port |
| `--load-baud` | `115200` | Prodigit 3366G baud rate | | `--load-baud` | `115200` | Prodigit 3366G baud rate |
| `--meter-address` | auto-detect | HIOKI 3193-10 VISA address | | `--meter-address` | auto-detect | HIOKI 3193-10 VISA address |
| `--timeout` | `5000` | VISA timeout in milliseconds | | `--timeout` | `5000` | VISA timeout in milliseconds |
| `--stm32-port` | `COM28` | STM32 debug serial port | | `--stm32-port` | `COM4` | STM32 debug serial port |
| `--stm32-baud` | `460800` | STM32 debug baud rate | | `--stm32-baud` | `460800` | STM32 debug baud rate |
## Tunable STM32 Parameters ## Tunable STM32 Parameters
Names, IDs, types and ranges mirror the firmware (`code64/debug_console/protocol.py`).
| Parameter | Type | Range | Description | | Parameter | Type | Range | Description |
|---|---|---|---| |---|---|---|---|
| `VREF` | uint16 | 3100-3700 | ADC reference voltage | | `VREF` | uint16 | 2340-3500 | ADC reference voltage |
| `vfly_kp` | float | -10 to 10 | Vfly proportional gain | | `vfly_kp` | float | -10 to 10 | Vfly proportional gain (mode 1, duty asymmetry) |
| `vfly_ki` | float | -10 to 10 | Vfly integral gain | | `vfly_ki` | float | -10 to 10 | Vfly integral gain (mode 1) |
| `vfly_clamp` | uint16 | 0-10000 | Vfly integrator clamp | | `vfly_kp_phase` | float | -10 to 10 | Vfly P gain (mode 2, master-phase offset) |
| `vfly_active` | uint8 | 0-1 | Vfly loop enable | | `vfly_phase_clamp` | uint16 | 0-10000 | Clamp on the master-phase offset (mode 2) |
| `vfly_clamp` | uint16 | 0-10000 | Vfly integrator clamp (mode 1) |
| `vfly_loop_trig` | uint16 | 1-10000 | Vfly loop counter trigger |
| `vfly_active` | uint8 | 0-3 | Vfly mode: 0 off, 1 duty-asym PI, 2 phase P, 3 manual both |
| `test_corr` | int16 | -3000 to 3000 | Manual duty-asymmetry correction (mode 3) |
| `phase_ofs` | int16 | -3000 to 3000 | Master-phase offset: manual (mode 3), readback (mode 2) |
| `cc_target` | float | 0-60000 | CC target (mA) | | `cc_target` | float | 0-60000 | CC target (mA) |
| `cc_gain` | float | -1 to 1 | CC proportional gain | | `cc_gain` | float | -1 to 1 | CC proportional gain |
| `cc_min_step` / `cc_max_step` | float | -1000-0 / 0-1000 | CC step clamps |
| `cc_loop_trig` | uint16 | 1-10000 | CC loop counter trigger |
| `cc_active` | int32 | 0-1 | CC loop enable | | `cc_active` | int32 | 0-1 | CC loop enable |
| `mppt_step` | float | 0-10000 | MPPT P&O step size (mA) | | `mppt_step` | float | 1-200 | MPPT P&O step size |
| `mppt_deadband` | float | 0-1 | MPPT deadband | | `mppt_duty_min` / `mppt_duty_max` | float | 0-6800 | MPPT duty search bounds (CMP ticks) |
| `mppt_loop_trig` | uint16 | 1-50000 | MPPT loop counter trigger |
| `mppt_active` | int32 | 0-1 | MPPT loop enable | | `mppt_active` | int32 | 0-1 | MPPT loop enable |
| `dt_0_3A` | uint8 | 14-200 | Deadtime 0-3A (HRTIM ticks) | | `cv_threshold` / `cv_hysteresis` | float | 20000-30000 / 0-5000 | CV corner (mV) |
| `dt_3_5A` | uint8 | 14-200 | Deadtime 3-5A | | `cc_threshold` / `cc_hysteresis` | float | 0-55000 / 0-10000 | CC/Iout limit (mA) |
| `dt_5_10A` | uint8 | 14-200 | Deadtime 5-10A | | `dt_normal` | uint16 | 14-200 | Single global dead-time (dt register ticks) |
| `dt_10_20A` | uint8 | 14-200 | Deadtime 10-20A | | `override_duty` | uint16 | 716-6442 | Manual fixed-duty base (CMP ticks, D=10..90%) |
| `dt_20_30A` | uint8 | 14-200 | Deadtime 20-30A | | `manual_duty_en` | uint8 | 0-1 | Enter manual fixed-duty mode |
| `dt_30_45A` | uint8 | 14-200 | Deadtime 30-45A | | `precharge_kp` / `precharge_ki` | float | 0-100 / 0-10 | Closed-loop precharge PI gains |
| `precharge_reg_en` | uint8 | 0-1 | Enable closed-loop precharge |
| `dither_en` | uint8 | 0-1 | Enable duty dithering (de-stack-band avoidance) |
| `dither_band_lo` / `dither_band_hi` | uint16 | 716-6442 | Forbidden duty band edges (CMP ticks) |
| `dither_anear` / `dither_afar` | uint16 | 716-6442 | Out-of-band dither anchors |
| `dither_dzero` | uint16 | 716-6442 | \|e\| fold center (D=0.5) |
## CSV Output Format ## CSV Output Format
+102 -16
View File
@@ -1,12 +1,14 @@
"""Textual TUI application for LVSolarBuck debug console.""" """Textual TUI application for LVSolarBuck debug console."""
import time import time
from collections import deque
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import Header, Footer, RichLog from textual.widgets import Header, Footer, RichLog
from .protocol import ( from .protocol import (
CMD_TELEMETRY, CMD_PARAM_WRITE_ACK, CMD_PARAM_VALUE, CMD_PONG, CMD_ERROR_MSG, CMD_TELEMETRY, CMD_PARAM_WRITE, CMD_PARAM_WRITE_ACK, CMD_PARAM_VALUE, CMD_PONG, CMD_ERROR_MSG,
decode_telemetry, decode_param_value, build_ping, build_param_read_all, decode_telemetry, decode_param_value, build_ping, build_shutdown, build_reset, build_test_50, build_param_read_all,
build_relay_on, build_relay_off, build_hold_converter, build_toggle_precharge,
PARAM_BY_ID, PARAM_BY_ID,
) )
from .serial_worker import SerialWorker from .serial_worker import SerialWorker
@@ -49,9 +51,14 @@ class DebugConsoleApp(App):
BINDINGS = [ BINDINGS = [
("q", "quit", "Quit"), ("q", "quit", "Quit"),
("p", "ping", "Ping"), ("p", "ping", "Ping"),
("r", "read_params", "Read Params"),
("f", "toggle_filter", "Filter On/Off"), ("f", "toggle_filter", "Filter On/Off"),
("l", "show_log_path", "Log Path"), ("s", "shutdown", "SHUTOFF"),
("x", "reset", "RESET"),
("t", "test50", "TEST 50%"),
("c", "relay_on", "Relay ON"),
("d", "relay_off", "Relay OFF"),
("h", "hold_converter", "HOLD CONV"),
("g", "toggle_precharge", "PRECHARGE"),
] ]
def __init__(self, port: str, baudrate: int = 460800): def __init__(self, port: str, baudrate: int = 460800):
@@ -60,9 +67,23 @@ class DebugConsoleApp(App):
self.serial_baudrate = baudrate self.serial_baudrate = baudrate
self.worker: SerialWorker | None = None self.worker: SerialWorker | None = None
self.logger: DataLogger | None = None self.logger: DataLogger | None = None
# Per-tick accumulators (bumped in _on_frame); _sent_count counts the
# frames the MCU generated (incl. lost) from the telemetry seq advance.
self._telem_count = 0 self._telem_count = 0
self._fps_time = time.monotonic() self._sent_count = 0
# Sliding-window link-rate estimator: a deque of (time, recv, sent)
# buckets covering the last _rate_window_s, so the Hz/loss readout is a
# moving average refreshed every status tick — smooth (no ±1-frame
# 8/12 Hz quantization) yet still responsive.
self._rate_window_s = 3.0
self._rate_buckets: deque = deque()
self._rate_window_start = time.monotonic()
self._last_seq = -1 self._last_seq = -1
# Un-ACKed param writes awaiting retry: the MCU's RX side is EMI-lossy
# while the converter is switching (TX/telemetry unaffected), so writes
# are resent until the matching CMD_PARAM_WRITE_ACK arrives.
# param_id -> [frame_bytes, tries, deadline]
self._pending_writes: dict[int, list] = {}
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header()
@@ -76,6 +97,7 @@ class DebugConsoleApp(App):
yield ParamGroup("CC", self._send_data) yield ParamGroup("CC", self._send_data)
yield ParamGroup("MPPT", self._send_data) yield ParamGroup("MPPT", self._send_data)
yield ParamGroup("Deadtime", self._send_data) yield ParamGroup("Deadtime", self._send_data)
yield ParamGroup("Manual", self._send_data)
yield RichLog(id="error-log", markup=True, wrap=True) yield RichLog(id="error-log", markup=True, wrap=True)
yield StatusBar() yield StatusBar()
yield Footer() yield Footer()
@@ -93,6 +115,7 @@ class DebugConsoleApp(App):
self.worker.start() self.worker.start()
self.set_interval(0.5, self._update_status) self.set_interval(0.5, self._update_status)
self.set_interval(2.0, self._request_params) self.set_interval(2.0, self._request_params)
self.set_interval(0.3, self._flush_pending_writes)
def on_unmount(self) -> None: def on_unmount(self) -> None:
if self.worker: if self.worker:
@@ -102,8 +125,33 @@ class DebugConsoleApp(App):
def _send_data(self, data: bytes) -> None: def _send_data(self, data: bytes) -> None:
if self.worker: if self.worker:
# Track param writes for ACK-based auto-retry. Frame layout:
# [SYNC, cmd, len, payload...]; ParamWritePayload starts with
# param_id. A newer write to the same param replaces the pending
# entry, so retries never resurrect a stale value.
if len(data) > 3 and data[1] == CMD_PARAM_WRITE:
self._pending_writes[data[3]] = [data, 0, time.monotonic() + 0.3]
self.worker.send(data) self.worker.send(data)
def _flush_pending_writes(self) -> None:
if not self.worker or not self._pending_writes:
return
now = time.monotonic()
for pid in list(self._pending_writes):
entry = self._pending_writes.get(pid)
if entry is None or now < entry[2]:
continue
if entry[1] >= 30:
self._pending_writes.pop(pid, None)
p = PARAM_BY_ID.get(pid)
name = p.name if p else f"0x{pid:02X}"
self.notify(f"Param write '{name}' got no ACK after 30 tries",
severity="error", timeout=10)
continue
entry[1] += 1
entry[2] = now + 0.3
self.worker.send(entry[0])
def _request_params(self) -> None: def _request_params(self) -> None:
if self.worker and self.worker.connected: if self.worker and self.worker.connected:
self._send_data(build_param_read_all()) self._send_data(build_param_read_all())
@@ -114,11 +162,16 @@ class DebugConsoleApp(App):
if t is not None: if t is not None:
self._telem_count += 1 self._telem_count += 1
if self._last_seq >= 0: if self._last_seq >= 0:
# seq increments per frame the MCU SENT, so the advance
# counts generated frames whether or not they arrived.
self._sent_count += (t.seq - self._last_seq) & 0xFF
expected = (self._last_seq + 1) & 0xFF expected = (self._last_seq + 1) & 0xFF
if t.seq != expected: if t.seq != expected:
diff = (t.seq - expected) & 0xFF diff = (t.seq - expected) & 0xFF
if self.worker: if self.worker:
self.worker.drop_count += diff self.worker.drop_count += diff
else:
self._sent_count += 1
self._last_seq = t.seq self._last_seq = t.seq
if self.logger: if self.logger:
self.logger.log_telemetry(t) self.logger.log_telemetry(t)
@@ -134,6 +187,7 @@ class DebugConsoleApp(App):
result = decode_param_value(payload) result = decode_param_value(payload)
if result: if result:
param_id, value = result param_id, value = result
self._pending_writes.pop(param_id, None)
if self.logger: if self.logger:
self.logger.log_param(param_id, value) self.logger.log_param(param_id, value)
self.call_from_thread(self._update_param, param_id, value) self.call_from_thread(self._update_param, param_id, value)
@@ -180,30 +234,62 @@ class DebugConsoleApp(App):
def _update_status(self) -> None: def _update_status(self) -> None:
now = time.monotonic() now = time.monotonic()
elapsed = now - self._fps_time # Push this tick's counts, then slide the window forward, dropping
fps = self._telem_count / elapsed if elapsed > 0 else 0.0 # buckets older than _rate_window_s. The retained buckets exactly cover
# (_rate_window_start, now], so summing their counts and dividing by
# that span is an unbiased moving-average rate — no short-window
# quantization, and it tracks real changes within the window.
self._rate_buckets.append((now, self._telem_count, self._sent_count))
self._telem_count = 0 self._telem_count = 0
self._fps_time = now self._sent_count = 0
while len(self._rate_buckets) > 1 and now - self._rate_buckets[0][0] > self._rate_window_s:
self._rate_window_start = self._rate_buckets.popleft()[0]
span = now - self._rate_window_start
recv = sum(b[1] for b in self._rate_buckets)
sent = sum(b[2] for b in self._rate_buckets)
fps = recv / span if span > 0 else 0.0
sent_fps = sent / span if span > 0 else 0.0
loss_pct = 100.0 * (1.0 - recv / sent) if sent > 0 else 0.0
status = self.query_one(StatusBar) status = self.query_one(StatusBar)
status.fps = fps status.fps = fps
status.sent_fps = sent_fps
status.loss_pct = loss_pct
if self.worker: if self.worker:
status.connected = self.worker.connected status.connected = self.worker.connected
status.refresh_status() status.refresh_status()
# The StatusBar is hidden behind the Footer on most terminals, so the
# link stats are mirrored into the always-visible telemetry panel.
panel = self.query_one(TelemetryPanel)
panel.link_rx = fps
panel.link_tx = sent_fps
panel.link_loss = loss_pct
def action_ping(self) -> None: def action_ping(self) -> None:
self._send_data(build_ping()) self._send_data(build_ping())
def action_read_params(self) -> None: def action_shutdown(self) -> None:
self._request_params() self._send_data(build_shutdown())
def action_reset(self) -> None:
self._send_data(build_reset())
def action_test50(self) -> None:
self._send_data(build_test_50())
def action_relay_on(self) -> None:
self._send_data(build_relay_on())
def action_relay_off(self) -> None:
self._send_data(build_relay_off())
def action_hold_converter(self) -> None:
self._send_data(build_hold_converter())
def action_toggle_precharge(self) -> None:
self._send_data(build_toggle_precharge())
def action_toggle_filter(self) -> None: def action_toggle_filter(self) -> None:
panel = self.query_one(TelemetryPanel) panel = self.query_one(TelemetryPanel)
panel.filter_enabled = not panel.filter_enabled panel.filter_enabled = not panel.filter_enabled
state = "ON" if panel.filter_enabled else "OFF" state = "ON" if panel.filter_enabled else "OFF"
self.notify(f"Filter {state}") self.notify(f"Filter {state}")
def action_show_log_path(self) -> None:
if self.logger:
self.notify(f"Log: {self.logger.db_path}", timeout=10)
else:
self.notify("Logger not active", severity="warning")
+139
View File
@@ -0,0 +1,139 @@
"""Reconstruct per-cycle duty from a G1-G4 logic capture and FFT it to find
the low-frequency (audible) modulation. G1=T1 (outer/Timer F), G2=T2 (inner/
Timer E). common = main-loop duty, diff = V_fly duty-asymmetry (vfly_correction)."""
import sys
import numpy as np
CSV = sys.argv[1] if len(sys.argv) > 1 else "digital.csv"
# --- load (int8 gates to keep memory down) ---
try:
import pandas as pd
df = pd.read_csv(CSV, dtype={"Time [s]": np.float64, "G1": np.int8,
"G2": np.int8, "G3": np.int8, "G4": np.int8})
t = df["Time [s]"].to_numpy()
G = {c: df[c].to_numpy() for c in ("G1", "G2", "G3", "G4")}
except ImportError:
raw = np.genfromtxt(CSV, delimiter=",", skip_header=1)
t = raw[:, 0]
G = {f"G{i}": raw[:, i].astype(np.int8) for i in range(1, 5)}
print(f"rows={len(t)} span={t[-1]-t[0]:.4f}s")
def duty_series(g):
"""Return (cycle_start_times, duty[0..1]) from rising/falling edges of g."""
dg = np.diff(g.astype(np.int16))
tr = t[np.where(dg == 1)[0] + 1] # rising-edge times (cycle starts)
tf = t[np.where(dg == -1)[0] + 1] # falling-edge times
idx = np.searchsorted(tf, tr, side="right")
valid = idx < len(tf)
tr, hi = tr[valid], tf[np.clip(idx[valid], 0, len(tf) - 1)] - tr[valid]
period = np.diff(tr)
return tr[:-1], hi[:-1] / period
t_o, d_o = duty_series(G["G1"]) # outer
t_i, d_i = duty_series(G["G2"]) # inner
fsw = 1.0 / np.median(np.diff(t_o))
print(f"f_sw ~= {fsw/1e3:.1f} kHz outer cycles={len(d_o)} inner cycles={len(d_i)}")
print(f"D_outer mean={d_o.mean():.4f} std={d_o.std():.4f} | "
f"D_inner mean={d_i.mean():.4f} std={d_i.std():.4f}")
# resample both onto a uniform grid at f_sw, align, build common / diff
t0, t1 = max(t_o[0], t_i[0]), min(t_o[-1], t_i[-1])
fs = fsw
tu = np.arange(t0, t1, 1.0 / fs)
do_u = np.interp(tu, t_o, d_o)
di_u = np.interp(tu, t_i, d_i)
common = 0.5 * (do_u + di_u) # main control loop
diff = 0.5 * (do_u - di_u) # V_fly asymmetry (= corr/MAX_DUTY)
def top_peaks(x, label, n=6, fmin=50.0, fmax=40e3):
x = x - x.mean()
win = np.hanning(len(x))
X = np.abs(np.fft.rfft(x * win)) / (len(x) * 0.5)
f = np.fft.rfftfreq(len(x), d=1.0 / fs)
band = (f >= fmin) & (f <= fmax)
fb, Xb = f[band], X[band]
order = np.argsort(Xb)[::-1]
# de-duplicate neighbouring bins (keep local maxima >= 25 Hz apart)
picks, fseen = [], []
for i in order:
if all(abs(fb[i] - fs0) > 25 for fs0 in fseen):
picks.append(i); fseen.append(fb[i])
if len(picks) >= n:
break
print(f"\n[{label}] rms={x.std()*1e3:.3f}e-3 duty")
for i in picks:
print(f" {fb[i]:9.1f} Hz amp={Xb[i]*1e3:8.4f}e-3 "
f"({Xb[i]/Xb[order[0]]*100:5.1f}% of peak)")
return fb[order[0]], Xb[order[0]]
top_peaks(common, "COMMON (main loop)")
top_peaks(diff, "DIFF (V_fly asymmetry)")
top_peaks(do_u, "OUTER (G1/Timer F)")
# strongest line above the mains band, + harmonic-ladder check
def spectrum(x):
x = x - x.mean()
X = np.abs(np.fft.rfft(x * np.hanning(len(x)))) / (len(x) * 0.5)
f = np.fft.rfftfreq(len(x), d=1.0 / fs)
return f, X
fc, Xc = spectrum(common)
above = fc > 400.0
ipk = np.argmax(Xc[above])
fpk = fc[above][ipk]
print(f"\n[COMMON strongest >400Hz] {fpk:.1f} Hz")
print(f" f_sw/f_pk = {fsw/fpk:.2f} ; 50kHz-loop/f_pk = {50e3/fpk:.3f}")
# --- zoom 300-1500 Hz: where is the 500-600 Hz energy, common or diff? ---
def band_peak(x, lbl, lo=300, hi=1500):
f, X = spectrum(x)
b = (f >= lo) & (f <= hi)
i = np.argmax(X[b])
print(f" [{lbl}] {lo}-{hi}Hz peak: {f[b][i]:7.1f} Hz amp={X[b][i]*1e3:.4f}e-3")
print("\n=== 300-1500 Hz band ===")
band_peak(common, "COMMON")
band_peak(diff, "DIFF ")
# --- detect large cycle-to-cycle duty steps (per-cycle, not resampled) ---
def steps(td, d, lbl, thr=0.02):
jumps = np.abs(np.diff(d))
idx = np.where(jumps > thr)[0]
print(f"\n[{lbl}] cyc-to-cyc |dD|>{thr*100:.0f}%: {len(idx)} events "
f"(of {len(d)} cyc), max step={jumps.max()*100:.2f}%")
if len(idx) > 3:
ev_t = td[idx + 1]
gaps = np.diff(ev_t)
gaps = gaps[gaps > 1e-4] # ignore bursts within one event
if len(gaps):
med = np.median(gaps)
print(f" median spacing {med*1e3:.3f} ms -> {1/med:.1f} Hz "
f"(min {gaps.min()*1e3:.2f}ms max {gaps.max()*1e3:.2f}ms)")
steps(t_o, d_o, "OUTER step rate")
steps(t_i, d_i, "INNER step rate")
# plot
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, ax = plt.subplots(3, 1, figsize=(11, 9), sharex=True)
for a, (x, lbl) in zip(ax, [(common, "COMMON (main loop)"),
(diff, "DIFF (V_fly asymmetry)"),
(do_u, "OUTER (G1)")]):
f, X = spectrum(x)
m = f <= 50e3
a.semilogy(f[m] / 1e3, X[m])
a.set_ylabel(lbl + "\nduty amp"); a.grid(True, which="both", alpha=0.3)
a.axvline(24.77, color="r", ls=":", lw=1)
ax[-1].set_xlabel("kHz")
fig.tight_layout()
fig.savefig("debug_console/duty_fft.png", dpi=110)
print("\nsaved debug_console/duty_fft.png")
except Exception as e:
print("plot skipped:", e)
+113 -50
View File
@@ -13,30 +13,32 @@ CMD_PARAM_READ_ALL = 0x04
CMD_PARAM_VALUE = 0x05 CMD_PARAM_VALUE = 0x05
CMD_PING = 0x10 CMD_PING = 0x10
CMD_PONG = 0x11 CMD_PONG = 0x11
CMD_SHUTDOWN = 0x12
CMD_RESET = 0x13
CMD_TEST_50 = 0x14
CMD_RELAY_ON = 0x15
CMD_RELAY_OFF = 0x16
CMD_HOLD_CONVERTER = 0x17
CMD_TOGGLE_PRECHARGE = 0x18
CMD_ERROR_MSG = 0xE0 CMD_ERROR_MSG = 0xE0
PTYPE_FLOAT = 0 PTYPE_FLOAT = 0
PTYPE_UINT16 = 1 PTYPE_UINT16 = 1
PTYPE_UINT8 = 2 PTYPE_UINT8 = 2
PTYPE_INT32 = 3 PTYPE_INT32 = 3
PTYPE_INT16 = 4 # wire format = sign-extended int32, stored firmware-side as int16_t
# CRC8 table (poly 0x07) # CRC-16/CCITT-FALSE: poly 0x1021, init 0xFFFF, no reflection, no XOR-out.
_CRC8_TABLE = [0] * 256 # Matches STM32 hardware CRC configured in main.c MX_CRC_Init.
def _init_crc8(): def crc16(data: bytes) -> int:
for i in range(256): crc = 0xFFFF
crc = i
for _ in range(8):
if crc & 0x80:
crc = ((crc << 1) ^ 0x07) & 0xFF
else:
crc = (crc << 1) & 0xFF
_CRC8_TABLE[i] = crc
_init_crc8()
def crc8(data: bytes) -> int:
crc = 0x00
for b in data: 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 return crc
@@ -48,9 +50,11 @@ class TelemetryData:
iout: float = 0.0 iout: float = 0.0
vfly: float = 0.0 vfly: float = 0.0
etemp: float = 0.0 etemp: float = 0.0
btemp: float = 0.0
last_tmp: int = 0 last_tmp: int = 0
VREF: int = 0 VREF: int = 0
vfly_correction: 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
@@ -59,9 +63,12 @@ class TelemetryData:
mppt_last_iin: float = 0.0 mppt_last_iin: float = 0.0
p_in: float = 0.0 p_in: float = 0.0
p_out: float = 0.0 p_out: float = 0.0
iout_slow: float = 0.0
seq: int = 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
TELEMETRY_FMT = "<6f hHh h 6f 2f B3x" # 68 bytes TELEMETRY_FMT = "<7f hHhH 6f 3f BxH h" # 78 bytes
TELEMETRY_SIZE = struct.calcsize(TELEMETRY_FMT) TELEMETRY_SIZE = struct.calcsize(TELEMETRY_FMT)
def decode_telemetry(payload: bytes) -> Optional[TelemetryData]: def decode_telemetry(payload: bytes) -> Optional[TelemetryData]:
@@ -70,21 +77,25 @@ def decode_telemetry(payload: bytes) -> Optional[TelemetryData]:
vals = struct.unpack(TELEMETRY_FMT, payload[:TELEMETRY_SIZE]) vals = struct.unpack(TELEMETRY_FMT, payload[:TELEMETRY_SIZE])
return TelemetryData( return TelemetryData(
vin=vals[0], vout=vals[1], iin=vals[2], iout=vals[3], vin=vals[0], vout=vals[1], iin=vals[2], iout=vals[3],
vfly=vals[4], etemp=vals[5], vfly=vals[4], etemp=vals[5], btemp=vals[6],
last_tmp=vals[6], VREF=vals[7], vfly_correction=vals[8], last_tmp=vals[7], VREF=vals[8], vfly_correction=vals[9],
# vals[9] is pad cmp_outer=vals[10],
vfly_integral=vals[10], vfly_avg_debug=vals[11], vfly_integral=vals[11], vfly_avg_debug=vals[12],
cc_output_f=vals[12], mppt_iref=vals[13], cc_output_f=vals[13], mppt_iref=vals[14],
mppt_last_vin=vals[14], mppt_last_iin=vals[15], mppt_last_vin=vals[15], mppt_last_iin=vals[16],
p_in=vals[16], p_out=vals[17], p_in=vals[17], p_out=vals[18],
seq=vals[18], iout_slow=vals[19],
seq=vals[20],
cmp_inner=vals[21],
vfly_ofs_applied=vals[22],
) )
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_no_crc = header + payload frame_no_crc = header + payload
return frame_no_crc + bytes([crc8(frame_no_crc)]) crc = crc16(frame_no_crc)
return frame_no_crc + bytes([(crc >> 8) & 0xFF, crc & 0xFF]) # big-endian
def build_param_write(param_id: int, param_type: int, value) -> bytes: def build_param_write(param_id: int, param_type: int, value) -> bytes:
@@ -96,6 +107,8 @@ def build_param_write(param_id: int, param_type: int, value) -> bytes:
val_bytes = struct.pack("<Bxxx", int(value)) val_bytes = struct.pack("<Bxxx", int(value))
elif param_type == PTYPE_INT32: elif param_type == PTYPE_INT32:
val_bytes = struct.pack("<i", int(value)) val_bytes = struct.pack("<i", int(value))
elif param_type == PTYPE_INT16:
val_bytes = struct.pack("<i", int(value)) # sign-extended 32-bit wire
else: else:
val_bytes = struct.pack("<I", int(value)) val_bytes = struct.pack("<I", int(value))
payload = struct.pack("<BBxx", param_id, param_type) + val_bytes payload = struct.pack("<BBxx", param_id, param_type) + val_bytes
@@ -106,6 +119,34 @@ def build_ping() -> bytes:
return build_frame(CMD_PING) return build_frame(CMD_PING)
def build_shutdown() -> bytes:
return build_frame(CMD_SHUTDOWN)
def build_reset() -> bytes:
return build_frame(CMD_RESET)
def build_test_50() -> bytes:
return build_frame(CMD_TEST_50)
def build_relay_on() -> bytes:
return build_frame(CMD_RELAY_ON)
def build_relay_off() -> bytes:
return build_frame(CMD_RELAY_OFF)
def build_hold_converter() -> bytes:
return build_frame(CMD_HOLD_CONVERTER)
def build_toggle_precharge() -> bytes:
return build_frame(CMD_TOGGLE_PRECHARGE)
def build_param_read_all() -> bytes: def build_param_read_all() -> bytes:
return build_frame(CMD_PARAM_READ_ALL) return build_frame(CMD_PARAM_READ_ALL)
@@ -125,6 +166,8 @@ def decode_param_value(payload: bytes) -> Optional[tuple[int, float]]:
value = float(value_bytes[0]) value = float(value_bytes[0])
elif param_type == PTYPE_INT32: elif param_type == PTYPE_INT32:
value = float(struct.unpack("<i", value_bytes)[0]) value = float(struct.unpack("<i", value_bytes)[0])
elif param_type == PTYPE_INT16:
value = float(struct.unpack("<i", value_bytes)[0]) # sign-extended 32-bit wire
else: else:
value = float(struct.unpack("<I", value_bytes)[0]) value = float(struct.unpack("<I", value_bytes)[0])
return (param_id, value) return (param_id, value)
@@ -137,7 +180,8 @@ class FrameParser:
WAIT_CMD = 1 WAIT_CMD = 1
WAIT_LEN = 2 WAIT_LEN = 2
WAIT_PAYLOAD = 3 WAIT_PAYLOAD = 3
WAIT_CRC = 4 WAIT_CRC_HI = 4
WAIT_CRC_LO = 5
def __init__(self): def __init__(self):
self.state = self.WAIT_SYNC self.state = self.WAIT_SYNC
@@ -146,6 +190,7 @@ class FrameParser:
self.buf = bytearray() self.buf = bytearray()
self.payload = bytearray() self.payload = bytearray()
self.idx = 0 self.idx = 0
self.crc_hi = 0
def feed(self, data: bytes): def feed(self, data: bytes):
"""Feed bytes, yield (cmd, payload) tuples for complete frames.""" """Feed bytes, yield (cmd, payload) tuples for complete frames."""
@@ -164,7 +209,7 @@ class FrameParser:
self.payload = bytearray() self.payload = bytearray()
self.idx = 0 self.idx = 0
if b == 0: if b == 0:
self.state = self.WAIT_CRC self.state = self.WAIT_CRC_HI
elif b > 128: elif b > 128:
self.state = self.WAIT_SYNC self.state = self.WAIT_SYNC
else: else:
@@ -174,11 +219,15 @@ class FrameParser:
self.buf.append(b) self.buf.append(b)
self.idx += 1 self.idx += 1
if self.idx >= self.length: if self.idx >= self.length:
self.state = self.WAIT_CRC self.state = self.WAIT_CRC_HI
elif self.state == self.WAIT_CRC: elif self.state == self.WAIT_CRC_HI:
expected = crc8(bytes(self.buf)) 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 self.state = self.WAIT_SYNC
if b == expected: if received == expected:
yield (self.cmd, bytes(self.payload)) yield (self.cmd, bytes(self.payload))
@@ -195,13 +244,17 @@ class ParamDef:
PARAMS = [ PARAMS = [
# Compensator # Compensator
ParamDef(0x25, "VREF", PTYPE_UINT16, "Compensator", 3100, 3700, ".0f"), ParamDef(0x25, "VREF", PTYPE_UINT16, "Compensator", 2340, 3500, ".0f"),
# Vfly # Vfly
ParamDef(0x20, "vfly_kp", PTYPE_FLOAT, "Vfly", -10, 10, ".4f"), ParamDef(0x20, "vfly_kp", PTYPE_FLOAT, "Vfly", -10, 10, ".4f"),
ParamDef(0x21, "vfly_ki", PTYPE_FLOAT, "Vfly", -10, 10, ".6f"), 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(0x22, "vfly_clamp", PTYPE_UINT16, "Vfly", 0, 10000, ".0f"),
ParamDef(0x23, "vfly_loop_trig", PTYPE_UINT16, "Vfly", 1, 10000, ".0f"), ParamDef(0x23, "vfly_loop_trig", PTYPE_UINT16, "Vfly", 1, 10000, ".0f"),
ParamDef(0x24, "vfly_active", PTYPE_UINT8, "Vfly", 0, 1, ".0f"), ParamDef(0x24, "vfly_active", PTYPE_UINT8, "Vfly", 0, 3, ".0f"), # 0=off 1=auto duty-asymmetry 2=auto phase offset 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 P-driven (readback)
# CC # CC
ParamDef(0x30, "cc_target", PTYPE_FLOAT, "CC", 0, 60000, ".0f"), ParamDef(0x30, "cc_target", PTYPE_FLOAT, "CC", 0, 60000, ".0f"),
ParamDef(0x31, "cc_gain", PTYPE_FLOAT, "CC", -1, 1, ".4f"), ParamDef(0x31, "cc_gain", PTYPE_FLOAT, "CC", -1, 1, ".4f"),
@@ -210,23 +263,33 @@ PARAMS = [
ParamDef(0x34, "cc_loop_trig", PTYPE_UINT16, "CC", 1, 10000, ".0f"), ParamDef(0x34, "cc_loop_trig", PTYPE_UINT16, "CC", 1, 10000, ".0f"),
ParamDef(0x35, "cc_active", PTYPE_INT32, "CC", 0, 1, ".0f"), ParamDef(0x35, "cc_active", PTYPE_INT32, "CC", 0, 1, ".0f"),
# MPPT # MPPT
ParamDef(0x40, "mppt_step", PTYPE_FLOAT, "MPPT", 0, 10000, ".1f"), ParamDef(0x40, "mppt_step", PTYPE_FLOAT, "MPPT", 1, 200, ".0f"),
ParamDef(0x41, "mppt_iref_min", PTYPE_FLOAT, "MPPT", 0, 60000, ".0f"), ParamDef(0x41, "mppt_duty_min", PTYPE_FLOAT, "MPPT", 0, 6800, ".0f"),
ParamDef(0x42, "mppt_iref_max", PTYPE_FLOAT, "MPPT", 0, 60000, ".0f"), ParamDef(0x42, "mppt_duty_max", PTYPE_FLOAT, "MPPT", 0, 6800, ".0f"),
ParamDef(0x43, "mppt_dv_thresh", PTYPE_FLOAT, "MPPT", 0, 10000, ".1f"), ParamDef(0x44, "mppt_loop_trig", PTYPE_UINT16, "MPPT", 1, 50000, ".0f"),
ParamDef(0x44, "mppt_loop_trig", PTYPE_UINT16, "MPPT", 1, 10000, ".0f"),
ParamDef(0x45, "mppt_active", PTYPE_INT32, "MPPT", 0, 1, ".0f"), ParamDef(0x45, "mppt_active", PTYPE_INT32, "MPPT", 0, 1, ".0f"),
ParamDef(0x46, "mppt_init_iref", PTYPE_FLOAT, "MPPT", 0, 60000, ".0f"), ParamDef(0x46, "cv_threshold", PTYPE_FLOAT, "MPPT", 20000, 30000, ".0f"),
ParamDef(0x47, "mppt_deadband", PTYPE_FLOAT, "MPPT", 0, 1, ".4f"), ParamDef(0x47, "cv_hysteresis", PTYPE_FLOAT, "MPPT", 0, 5000, ".0f"),
# Global ParamDef(0x48, "cc_threshold", PTYPE_FLOAT, "MPPT", 0, 55000, ".0f"),
ParamDef(0x50, "vin_min_ctrl", PTYPE_FLOAT, "Global", 0, 90000, ".0f"), ParamDef(0x50, "cc_hysteresis", PTYPE_FLOAT, "MPPT", 0, 10000, ".0f"),
# Deadtime # Deadtime (single static value, dt register units)
ParamDef(0x60, "dt 0-3A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"), ParamDef(0x60, "dt_normal", PTYPE_UINT16, "Deadtime", 14, 200, ".0f"),
ParamDef(0x61, "dt 3-5A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"), # Manual fixed-duty mode (base duty in CMP ticks; D = override_duty/7158, 716..6442 = 10..90%)
ParamDef(0x62, "dt 5-10A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"), ParamDef(0x64, "override_duty", PTYPE_UINT16, "Manual", 716, 6442, ".0f"), # enable manual_duty_en first (seeds at current duty), then sweep
ParamDef(0x63, "dt 10-20A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"), ParamDef(0x65, "manual_duty_en", PTYPE_UINT8, "Manual", 0, 1, ".0f"), # 1 = fixed duty, supervisor parked; FMAC balancer still runs per vfly_active
ParamDef(0x64, "dt 20-30A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"), # Duty dither: avoid the V_fly de-stack band by delta-sigma modulating the commanded
ParamDef(0x65, "dt 30-45A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"), # duty between two out-of-band anchors in the FMAC ISR (all CMP ticks). Defaults bracket
# the Vin=75 collapse band 3686..3801 (D 0.515..0.531) with anchors 3643/3844 (D 0.509/0.537).
# Closed-loop precharge: PI controller drives the precharge FET PWM (TIM3_CH1) to Vin/2.
ParamDef(0x76, "precharge_kp", PTYPE_FLOAT, "Precharge", 0, 100, ".3f"), # P: CCR ticks per mV of (Vin/2 - Vfly)
ParamDef(0x78, "precharge_ki", PTYPE_FLOAT, "Precharge", 0, 10, ".4f"), # I: CCR ticks per mV per ~1 kHz tick
ParamDef(0x77, "precharge_reg_en", PTYPE_UINT8, "Precharge", 0, 1, ".0f"), # 1 = closed-loop precharge on
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"), # |e| fold center (D=0.5); covers both lobes
] ]
PARAM_BY_ID = {p.id: p for p in PARAMS} PARAM_BY_ID = {p.id: p for p in PARAMS}
+9 -2
View File
@@ -21,8 +21,15 @@ class StatusBar(Static):
self.pkt_count = 0 self.pkt_count = 0
self.drop_count = 0 self.drop_count = 0
self.last_seq = 0 self.last_seq = 0
self.fps = 0.0 self.fps = 0.0 # telemetry frames RECEIVED per second
self.sent_fps = 0.0 # frames the MCU GENERATED per second (from seq advance)
self.loss_pct = 0.0 # 100 * (1 - received/generated)
def refresh_status(self): def refresh_status(self):
conn = "[green]CONN:OK[/green]" if self.connected else "[red]CONN:LOST[/red]" conn = "[green]CONN:OK[/green]" if self.connected else "[red]CONN:LOST[/red]"
self.update(f" {conn} PKTS:{self.pkt_count} DROPS:{self.drop_count} SEQ:{self.last_seq} FPS:{self.fps:.1f}") if self.loss_pct >= 5.0:
loss = f"[red]LOSS:{self.loss_pct:.0f}%[/red]"
else:
loss = f"LOSS:{self.loss_pct:.0f}%"
self.update(f" {conn} PKTS:{self.pkt_count} DROPS:{self.drop_count} SEQ:{self.last_seq}"
f" RX:{self.fps:.1f}Hz TX:{self.sent_fps:.1f}Hz {loss}")
+37 -17
View File
@@ -18,9 +18,10 @@ class TelemetryPanel(Static):
ALPHA = 0.05 # EMA filter coefficient ALPHA = 0.05 # EMA filter coefficient
DT_BREAKPOINTS = [0, 3000, 5000, 10000, 20000, 30000, 45000] MASTER_TICKS_MID = 7158 # s = 2*last_tmp - MASTER_TICKS_MID (edge-separation abscissa)
DT_DEFAULTS = [25, 20, 20, 20, 15, 15] # dead-time is a single static value now (dt_normal, 0x60); track it for display
DT_PARAM_IDS = [0x60, 0x61, 0x62, 0x63, 0x64, 0x65] DT_IDS = {0x60}
DT_DEFAULTS = {0x60: 20}
def __init__(self): def __init__(self):
super().__init__("Waiting for telemetry...") super().__init__("Waiting for telemetry...")
@@ -32,7 +33,11 @@ class TelemetryPanel(Static):
self._vfly_f: float = 0.0 self._vfly_f: float = 0.0
self._seeded: bool = False self._seeded: bool = False
self.filter_enabled: bool = True self.filter_enabled: bool = True
self._dt_values: list[int] = list(self.DT_DEFAULTS) self._dt_params: dict[int, float] = dict(self.DT_DEFAULTS)
# Link stats, set by the app's 0.5 s status timer
self.link_rx: float = 0.0
self.link_tx: float | None = None
self.link_loss: float = 0.0
def update_telemetry(self, t: TelemetryData): def update_telemetry(self, t: TelemetryData):
self._data = t self._data = t
@@ -65,15 +70,25 @@ class TelemetryPanel(Static):
else: else:
eta = 0.0 eta = 0.0
# Compute active deadtime from iout using same lookup as ISR # Dead-time is a single static value (dt_normal), applied to both edges/timers.
active_dt = self._dt_values[0] active_dt = self._dt_params.get(0x60, 20)
for i in range(len(self.DT_BREAKPOINTS) - 1, -1, -1):
if i_out >= self.DT_BREAKPOINTS[i]: if self.link_tx is not None:
active_dt = self._dt_values[min(i, len(self._dt_values) - 1)] if self.link_loss >= 5.0:
break loss_s = f"[red]{self.link_loss:.0f}%[/red]"
else:
loss_s = f"{self.link_loss:.0f}%"
link_lines = [
f" link rx : {self.link_rx:8.1f} Hz",
f" link tx : {self.link_tx:8.1f} Hz",
f" link loss : {loss_s}",
]
else:
link_lines = [" link : measuring..."]
lines = [ lines = [
"[bold]MEASUREMENTS[/bold]", "[bold]MEASUREMENTS[/bold]",
*link_lines,
"", "",
f" vin : {self._vin_f if self.filter_enabled else t.vin:8.0f} mV", f" vin : {self._vin_f if self.filter_enabled else t.vin:8.0f} mV",
f" vout : {self._vout_f if self.filter_enabled else t.vout:8.0f} mV", f" vout : {self._vout_f if self.filter_enabled else t.vout:8.0f} mV",
@@ -84,23 +99,28 @@ class TelemetryPanel(Static):
f" EFF : {eta:8.1f} %" if p_in > 0.1 else " EFF : --- %", f" EFF : {eta:8.1f} %" if p_in > 0.1 else " EFF : --- %",
f" vfly : {self._vfly_f if self.filter_enabled else t.vfly:8.0f} mV", f" vfly : {self._vfly_f if self.filter_enabled else t.vfly:8.0f} mV",
f" etemp : {t.etemp:8.1f} C", f" etemp : {t.etemp:8.1f} C",
f" deadtime : {active_dt:8d} ticks", f" btemp : {t.btemp:8.1f} C",
f" deadtime : {active_dt:8.1f} ticks",
f" : {active_dt / 1.36:8.1f} ns", f" : {active_dt / 1.36:8.1f} ns",
"", "",
f" last_tmp : {t.last_tmp:8d}", f" last_tmp : {t.last_tmp:8d}",
f" s : {2 * t.last_tmp - self.MASTER_TICKS_MID:+8d} ({'D>0.5' if 2 * t.last_tmp >= self.MASTER_TICKS_MID else 'D<0.5'})",
f" VREF : {t.VREF:8d}", f" VREF : {t.VREF:8d}",
f" vfly_corr : {t.vfly_correction:8d}", f" vfly_corr : {t.vfly_correction:8d}",
f" cmp_outer : {t.cmp_outer:8d} (F: T1/T4)",
f" cmp_inner : {t.cmp_inner:8d} (E: T2/T3)",
f" cmp_diff : {t.cmp_outer - t.cmp_inner:+8d}",
f" phase_ofs : {t.vfly_ofs_applied:+8d} (applied)",
"", "",
f" vfly_int : {t.vfly_integral:10.3f}", f" vfly_int : {t.vfly_integral:10.3f}",
f" vfly_avg : {t.vfly_avg_debug:10.1f}", f" vfly_avg : {t.vfly_avg_debug:10.1f}",
f" cc.out_f : {t.cc_output_f:10.1f}", f" cc.out_f : {t.cc_output_f:10.1f}",
f" mppt.iref : {t.mppt_iref:8.0f} mA", f" mppt.duty : {t.mppt_iref:8.0f}",
f" mppt.vin : {t.mppt_last_vin:8.0f}", f" mppt.power: {t.mppt_last_vin:12.0f}",
f" mppt.iin : {t.mppt_last_iin:8.0f}", f" mppt.dir : {t.mppt_last_iin:4.0f}",
] ]
self.update("\n".join(lines)) self.update("\n".join(lines))
def update_dt_param(self, param_id: int, value: float): def update_dt_param(self, param_id: int, value: float):
if param_id in self.DT_PARAM_IDS: if param_id in self.DT_IDS:
idx = self.DT_PARAM_IDS.index(param_id) self._dt_params[param_id] = value
self._dt_values[idx] = int(value)
+18 -14
View File
@@ -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" Pout = {t.power_out_W:8.2f} W")
print(f" EFF = {t.efficiency:8.1f} %") print(f" EFF = {t.efficiency:8.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") 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: 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: 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.stm32_link import STM32Link
from testbench.tuner import Tuner from testbench.tuner import Tuner
@@ -740,8 +741,11 @@ def cmd_tune_deadtime(bench: MPPTTestbench, args: argparse.Namespace) -> None:
settle_time=args.settle, settle_time=args.settle,
) )
if args.output:
tuner.write_csv(results, args.output)
if args.apply: if args.apply:
tuner.apply_best_deadtimes(results) tuner.apply_best_deadtime(results)
if args.output: if args.output:
all_pts = [] all_pts = []
@@ -1123,9 +1127,9 @@ examples:
%(prog)s safe-off %(prog)s safe-off
%(prog)s plot-sweep sweep_vi_20260312_151212.csv %(prog)s plot-sweep sweep_vi_20260312_151212.csv
%(prog)s plot-sweep sweep_vi_20260312_151212.csv --no-show -o plots/ %(prog)s plot-sweep sweep_vi_20260312_151212.csv --no-show -o plots/
%(prog)s stm32-read --stm32-port COM28 %(prog)s stm32-read --stm32-port COM4
%(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-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 COM28 --voltage 60 --current-limit 20 --load-mode CP --apply %(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)", help="IT6500D VISA address (auto-detect if omitted)",
) )
parser.add_argument( parser.add_argument(
"--load-port", default="COM1", "--load-port", default="COM11",
help="Prodigit 3366G serial port (default: COM1)", help="Prodigit 3366G serial port (default: COM11)",
) )
parser.add_argument( parser.add_argument(
"--load-baud", type=int, default=115200, "--load-baud", type=int, default=115200,
@@ -1151,8 +1155,8 @@ examples:
help="VISA timeout in ms (default: 5000)", help="VISA timeout in ms (default: 5000)",
) )
parser.add_argument( parser.add_argument(
"--stm32-port", default="COM28", "--stm32-port", default="COM4",
help="STM32 debug serial port (default: COM28)", help="STM32 debug serial port (default: COM4)",
) )
parser.add_argument( parser.add_argument(
"--stm32-baud", type=int, default=460800, "--stm32-baud", type=int, default=460800,
@@ -1263,7 +1267,7 @@ examples:
# tune-param # tune-param
p_tp = sub.add_parser("tune-param", help="Sweep an STM32 parameter while measuring efficiency") 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("--start", type=float, required=True, help="Start value")
p_tp.add_argument("--stop", type=float, required=True, help="Stop 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") p_tp.add_argument("--step", type=float, required=True, help="Step size")
@@ -1277,7 +1281,7 @@ examples:
# tune-param-vsweep # tune-param-vsweep
p_tv = sub.add_parser("tune-param-vsweep", help="Sweep an STM32 parameter across a voltage range") 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("--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("--stop", type=float, required=True, help="Param stop value")
p_tv.add_argument("--step", type=float, required=True, help="Param step size") 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)") p_tv.add_argument("-o", "--output", help="Output prefix for CSVs (default: param name)")
# tune-deadtime # 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-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-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("--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("--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("--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 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("--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("--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")
+144 -87
View File
@@ -1,8 +1,9 @@
"""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 Provides blocking read/write of telemetry and parameters, suitable
for automated tuning scripts (not a TUI). Reuses the binary protocol for automated tuning scripts (not a TUI). Mirrors the binary protocol
from code64/debug_console/protocol.py. 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 from __future__ import annotations
@@ -25,32 +26,34 @@ CMD_PARAM_READ_ALL = 0x04
CMD_PARAM_VALUE = 0x05 CMD_PARAM_VALUE = 0x05
CMD_PING = 0x10 CMD_PING = 0x10
CMD_PONG = 0x11 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 CMD_ERROR_MSG = 0xE0
PTYPE_FLOAT = 0 PTYPE_FLOAT = 0
PTYPE_UINT16 = 1 PTYPE_UINT16 = 1
PTYPE_UINT8 = 2 PTYPE_UINT8 = 2
PTYPE_INT32 = 3 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 crc16(data: bytes) -> int:
crc = 0xFFFF
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
for b in data: 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 return crc
@@ -58,16 +61,18 @@ def crc8(data: bytes) -> int:
@dataclass @dataclass
class Telemetry: class Telemetry:
"""Decoded telemetry packet from the STM32.""" """Decoded telemetry packet from the STM32 (78-byte payload)."""
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
vfly: float = 0.0 # mV 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 last_tmp: int = 0
VREF: int = 0 VREF: int = 0
vfly_correction: 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
@@ -76,7 +81,10 @@ class Telemetry:
mppt_last_iin: float = 0.0 mppt_last_iin: float = 0.0
p_in: float = 0.0 p_in: float = 0.0
p_out: float = 0.0 p_out: float = 0.0
iout_slow: float = 0.0
seq: int = 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) timestamp: float = field(default_factory=time.time)
@property @property
@@ -109,7 +117,7 @@ class Telemetry:
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
_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) _TELEM_SIZE = struct.calcsize(_TELEM_FMT)
@@ -118,12 +126,13 @@ def _decode_telemetry(payload: bytes) -> Optional[Telemetry]:
return None return None
v = struct.unpack(_TELEM_FMT, payload[:_TELEM_SIZE]) v = struct.unpack(_TELEM_FMT, payload[:_TELEM_SIZE])
return Telemetry( return Telemetry(
vin=v[0], vout=v[1], iin=v[2], iout=v[3], vfly=v[4], etemp=v[5], vin=v[0], vout=v[1], iin=v[2], iout=v[3], vfly=v[4], etemp=v[5], btemp=v[6],
last_tmp=v[6], VREF=v[7], vfly_correction=v[8], last_tmp=v[7], VREF=v[8], vfly_correction=v[9], cmp_outer=v[10],
vfly_integral=v[10], vfly_avg_debug=v[11], vfly_integral=v[11], vfly_avg_debug=v[12],
cc_output_f=v[12], mppt_iref=v[13], cc_output_f=v[13], mppt_iref=v[14],
mppt_last_vin=v[14], mppt_last_iin=v[15], mppt_last_vin=v[15], mppt_last_iin=v[16],
p_in=v[16], p_out=v[17], seq=v[18], 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" fmt: str = ".4f"
# Mirrors code64/debug_console/protocol.py PARAMS (firmware debug_protocol.c).
PARAMS = [ PARAMS = [
# Compensator # Compensator
ParamDef(0x25, "VREF", PTYPE_UINT16, "Compensator", 3100, 3700, ".0f"), ParamDef(0x25, "VREF", PTYPE_UINT16, "Compensator", 2340, 3500, ".0f"),
# Vfly # Vfly
ParamDef(0x20, "vfly_kp", PTYPE_FLOAT, "Vfly", -10, 10, ".4f"), ParamDef(0x20, "vfly_kp", PTYPE_FLOAT, "Vfly", -10, 10, ".4f"),
ParamDef(0x21, "vfly_ki", PTYPE_FLOAT, "Vfly", -10, 10, ".6f"), ParamDef(0x21, "vfly_ki", PTYPE_FLOAT, "Vfly", -10, 10, ".6f"),
ParamDef(0x22, "vfly_clamp", PTYPE_UINT16, "Vfly", 0, 10000, ".0f"), ParamDef(0x62, "vfly_kp_phase", PTYPE_FLOAT, "Vfly", -10, 10, ".4f"), # mode 2: P gain, error -> phase
ParamDef(0x23, "vfly_loop_trig", PTYPE_UINT16, "Vfly", 1, 10000, ".0f"), ParamDef(0x63, "vfly_phase_clamp", PTYPE_UINT16, "Vfly", 0, 10000, ".0f"), # mode 2: phase offset clamp
ParamDef(0x24, "vfly_active", PTYPE_UINT8, "Vfly", 0, 1, ".0f"), 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 # CC
ParamDef(0x30, "cc_target", PTYPE_FLOAT, "CC", 0, 60000, ".0f"), ParamDef(0x30, "cc_target", PTYPE_FLOAT, "CC", 0, 60000, ".0f"),
ParamDef(0x31, "cc_gain", PTYPE_FLOAT, "CC", -1, 1, ".4f"), ParamDef(0x31, "cc_gain", PTYPE_FLOAT, "CC", -1, 1, ".4f"),
ParamDef(0x32, "cc_min_step", PTYPE_FLOAT, "CC", -1000, 0, ".1f"), ParamDef(0x32, "cc_min_step", PTYPE_FLOAT, "CC", -1000, 0, ".1f"),
ParamDef(0x33, "cc_max_step", PTYPE_FLOAT, "CC", 0, 1000, ".1f"), ParamDef(0x33, "cc_max_step", PTYPE_FLOAT, "CC", 0, 1000, ".1f"),
ParamDef(0x34, "cc_loop_trig", PTYPE_UINT16, "CC", 1, 10000, ".0f"), ParamDef(0x34, "cc_loop_trig", PTYPE_UINT16, "CC", 1, 10000, ".0f"),
ParamDef(0x35, "cc_active", PTYPE_INT32, "CC", 0, 1, ".0f"), ParamDef(0x35, "cc_active", PTYPE_INT32, "CC", 0, 1, ".0f"),
# MPPT # MPPT
ParamDef(0x40, "mppt_step", PTYPE_FLOAT, "MPPT", 0, 10000, ".1f"), ParamDef(0x40, "mppt_step", PTYPE_FLOAT, "MPPT", 1, 200, ".0f"),
ParamDef(0x41, "mppt_iref_min", PTYPE_FLOAT, "MPPT", 0, 60000, ".0f"), ParamDef(0x41, "mppt_duty_min", PTYPE_FLOAT, "MPPT", 0, 6800, ".0f"),
ParamDef(0x42, "mppt_iref_max", PTYPE_FLOAT, "MPPT", 0, 60000, ".0f"), ParamDef(0x42, "mppt_duty_max", PTYPE_FLOAT, "MPPT", 0, 6800, ".0f"),
ParamDef(0x43, "mppt_dv_thresh", PTYPE_FLOAT, "MPPT", 0, 10000, ".1f"), ParamDef(0x44, "mppt_loop_trig", PTYPE_UINT16, "MPPT", 1, 50000, ".0f"),
ParamDef(0x44, "mppt_loop_trig", PTYPE_UINT16, "MPPT", 1, 10000, ".0f"), ParamDef(0x45, "mppt_active", PTYPE_INT32, "MPPT", 0, 1, ".0f"),
ParamDef(0x45, "mppt_active", PTYPE_INT32, "MPPT", 0, 1, ".0f"), ParamDef(0x46, "cv_threshold", PTYPE_FLOAT, "MPPT", 20000, 30000, ".0f"),
ParamDef(0x46, "mppt_init_iref", PTYPE_FLOAT, "MPPT", 0, 60000, ".0f"), ParamDef(0x47, "cv_hysteresis", PTYPE_FLOAT, "MPPT", 0, 5000, ".0f"),
ParamDef(0x47, "mppt_deadband", PTYPE_FLOAT, "MPPT", 0, 1, ".4f"), ParamDef(0x48, "cc_threshold", PTYPE_FLOAT, "MPPT", 0, 55000, ".0f"),
# Global ParamDef(0x50, "cc_hysteresis", PTYPE_FLOAT, "MPPT", 0, 10000, ".0f"),
ParamDef(0x50, "vin_min_ctrl", PTYPE_FLOAT, "Global", 0, 90000, ".0f"), # Deadtime (single static value, dt register units)
# Deadtime ParamDef(0x60, "dt_normal", PTYPE_UINT16, "Deadtime", 14, 200, ".0f"),
ParamDef(0x60, "dt_0_3A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"), # Manual fixed-duty mode (base duty in CMP ticks; D = override_duty/7158, 716..6442 = 10..90%)
ParamDef(0x61, "dt_3_5A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"), ParamDef(0x64, "override_duty", PTYPE_UINT16, "Manual", 716, 6442, ".0f"),
ParamDef(0x62, "dt_5_10A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"), ParamDef(0x65, "manual_duty_en", PTYPE_UINT8, "Manual", 0, 1, ".0f"),
ParamDef(0x63, "dt_10_20A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"), # Closed-loop precharge PI (drives precharge FET PWM TIM3_CH1 to Vin/2)
ParamDef(0x64, "dt_20_30A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"), ParamDef(0x76, "precharge_kp", PTYPE_FLOAT, "Precharge", 0, 100, ".3f"),
ParamDef(0x65, "dt_30_45A", PTYPE_UINT8, "Deadtime", 14, 200, ".0f"), 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_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}
# 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 ─────────────────────────────────────────────────── # ── Frame building ───────────────────────────────────────────────────
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
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: 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)) val_bytes = struct.pack("<Bxxx", int(value))
elif ptype == PTYPE_INT32: elif ptype == PTYPE_INT32:
val_bytes = struct.pack("<i", int(value)) val_bytes = struct.pack("<i", int(value))
elif ptype == PTYPE_INT16:
val_bytes = struct.pack("<i", int(value)) # sign-extended 32-bit wire
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
@@ -226,6 +241,8 @@ def _decode_param_value(payload: bytes) -> Optional[tuple[int, float]]:
value = float(vb[0]) value = float(vb[0])
elif ptype == PTYPE_INT32: elif ptype == PTYPE_INT32:
value = float(struct.unpack("<i", vb)[0]) value = float(struct.unpack("<i", vb)[0])
elif ptype == PTYPE_INT16:
value = float(struct.unpack("<i", vb)[0]) # sign-extended 32-bit wire
else: else:
value = float(struct.unpack("<I", vb)[0]) value = float(struct.unpack("<I", vb)[0])
return (param_id, value) return (param_id, value)
@@ -234,45 +251,57 @@ def _decode_param_value(payload: bytes) -> Optional[tuple[int, float]]:
# ── Frame parser state machine ─────────────────────────────────────── # ── Frame parser state machine ───────────────────────────────────────
class _FrameParser: 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 __init__(self):
self.state = 0 # WAIT_SYNC self.state = self.WAIT_SYNC
self.cmd = 0 self.cmd = 0
self.length = 0 self.length = 0
self.buf = bytearray() self.buf = bytearray()
self.payload = bytearray() self.payload = bytearray()
self.idx = 0 self.idx = 0
self.crc_hi = 0
def feed(self, data: bytes): def feed(self, data: bytes):
for b in data: for b in data:
if self.state == 0: # WAIT_SYNC if self.state == self.WAIT_SYNC:
if b == SYNC_BYTE: if b == SYNC_BYTE:
self.buf = bytearray([b]) self.buf = bytearray([b])
self.state = 1 self.state = self.WAIT_CMD
elif self.state == 1: # WAIT_CMD elif self.state == self.WAIT_CMD:
self.cmd = b self.cmd = b
self.buf.append(b) self.buf.append(b)
self.state = 2 self.state = self.WAIT_LEN
elif self.state == 2: # WAIT_LEN elif self.state == self.WAIT_LEN:
self.length = b self.length = b
self.buf.append(b) self.buf.append(b)
self.payload = bytearray() self.payload = bytearray()
self.idx = 0 self.idx = 0
if b == 0: if b == 0:
self.state = 4 self.state = self.WAIT_CRC_HI
elif b > 128: elif b > 128:
self.state = 0 self.state = self.WAIT_SYNC
else: else:
self.state = 3 self.state = self.WAIT_PAYLOAD
elif self.state == 3: # WAIT_PAYLOAD elif self.state == self.WAIT_PAYLOAD:
self.payload.append(b) self.payload.append(b)
self.buf.append(b) self.buf.append(b)
self.idx += 1 self.idx += 1
if self.idx >= self.length: if self.idx >= self.length:
self.state = 4 self.state = self.WAIT_CRC_HI
elif self.state == 4: # WAIT_CRC elif self.state == self.WAIT_CRC_HI:
expected = crc8(bytes(self.buf)) self.crc_hi = b
self.state = 0 self.state = self.WAIT_CRC_LO
if b == expected: 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)) yield (self.cmd, bytes(self.payload))
@@ -283,11 +312,11 @@ class STM32Link:
Usage:: Usage::
link = STM32Link("COM28") link = STM32Link("COM4")
link.ping() link.ping()
t = link.read_telemetry() t = link.read_telemetry()
print(f"Vin={t.vin_V:.1f}V Iout={t.iout_A:.1f}A EFF={t.efficiency:.1f}%") 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() link.close()
""" """
@@ -359,6 +388,34 @@ class STM32Link:
self._send(_build_frame(CMD_PING)) self._send(_build_frame(CMD_PING))
return self._wait_for(CMD_PONG, timeout) is not None 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]: def read_telemetry(self, timeout: float = 2.0) -> Optional[Telemetry]:
"""Wait for next telemetry packet.""" """Wait for next telemetry packet."""
payload = self._wait_for(CMD_TELEMETRY, timeout) payload = self._wait_for(CMD_TELEMETRY, timeout)
@@ -376,12 +433,12 @@ class STM32Link:
samples.append(t) samples.append(t)
if not samples: if not samples:
return None return None
# Average all float fields # Average all analog float fields
avg = Telemetry() 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", "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"): "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 avg.seq = samples[-1].seq
return avg return avg
+51 -56
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from testbench.bench import MPPTTestbench, IDLE_VOLTAGE from testbench.bench import MPPTTestbench, IDLE_VOLTAGE
from testbench.stm32_link import ( 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 ──────────────────────────────────────── # ── 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( def tune_deadtime(
self, self,
dt_start: int = 14, dt_start: int = 14,
@@ -209,45 +215,37 @@ class Tuner:
load_mode: str = "CP", load_mode: str = "CP",
load_values: list[float] | None = None, load_values: list[float] | None = None,
settle_time: float | None = None, settle_time: float | None = None,
) -> dict[str, list[TunePoint]]: ) -> list[TunePoint]:
"""Optimize deadtime for each current bracket. """Optimize the single global dead-time (`dt_normal`).
For each deadtime bracket, sets a load that puts the converter Sweeps `dt_normal` from `dt_start` to `dt_stop` at each requested load
in that current range, then sweeps deadtime values to find the point and returns a flat list of measurements. The best value can be
optimum. applied with :meth:`apply_best_deadtime`.
Args: Args:
load_values: Load setpoints to test (one per DT bracket). load_values: Load setpoints to test. If None, a single mid-range
If None, auto-selects based on bracket midpoints. load is used.
""" """
settle = settle_time or self.settle_time settle = settle_time or self.settle_time
if load_values is None: if not load_values:
# Auto-select load values targeting the middle of each bracket load_values = [200.0 if load_mode == "CP" else 5.0]
# 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))
unit = "A" if load_mode == "CC" else "W"
print("=" * 80) print("=" * 80)
print("DEADTIME OPTIMIZATION") print("DEAD-TIME OPTIMIZATION (dt_normal)")
print(f" DT range: {dt_start}{dt_stop} (step {dt_step})") 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" 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) print("=" * 80)
all_results: dict[str, list[TunePoint]] = {} all_results: list[TunePoint] = []
best_per_load: list[tuple[float, 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} ──")
for load_val in load_values:
print(f"\n── {self.DT_PARAM} @ {load_mode}={load_val:.0f}{unit} ──")
results = self.sweep_param( results = self.sweep_param(
param_name=param_name, param_name=self.DT_PARAM,
start=dt_start, start=dt_start,
stop=dt_stop, stop=dt_stop,
step=dt_step, step=dt_step,
@@ -257,44 +255,41 @@ class Tuner:
load_value=load_val, load_value=load_val,
settle_time=settle, 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] valid = [p for p in results if 0 < p.meter_eff < 110]
if valid: if valid:
best = max(valid, key=lambda p: p.meter_eff) best = max(valid, key=lambda p: p.meter_eff)
print(f"{param_name:<15} {best.param_value:>8.0f} " best_per_load.append((load_val, best))
f"{best.meter_eff:>11.2f}% {best.stm_etemp:>7.0f}°C") print(f" ★ Best: {self.DT_PARAM}={best.param_value:.0f}"
else: f"EFF={best.meter_eff:.2f}%")
print(f"{param_name:<15} {'N/A':>8} {'N/A':>12} {'N/A':>8}")
# 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) print("=" * 80)
return all_results return all_results
def apply_best_deadtimes(self, results: dict[str, list[TunePoint]]): def apply_best_deadtime(self, results: list[TunePoint]):
"""Apply the best deadtime from each bracket to the STM32.""" """Apply the single best dead-time (highest efficiency) to the STM32."""
print("\nApplying optimal deadtimes:") valid = [p for p in results if 0 < p.meter_eff < 110]
for param_name, points in results.items(): if not valid:
valid = [p for p in points if 0 < p.meter_eff < 110] print("\nNo valid points — dead-time not applied.")
if valid: return
best = max(valid, key=lambda p: p.meter_eff) best = max(valid, key=lambda p: p.meter_eff)
val = int(best.param_value) val = int(best.param_value)
ack = self.link.write_param(param_name, val) ack = self.link.write_param(self.DT_PARAM, val)
status = "OK" if ack else "NO ACK" status = "OK" if ack else "NO ACK"
print(f" {param_name} = {val} ({status})") print(f"\nApplying best dead-time: {self.DT_PARAM} = {val} "
f"(EFF={best.meter_eff:.2f}%) ({status})")
# ── Multi-point sweep ──────────────────────────────────────────── # ── Multi-point sweep ────────────────────────────────────────────