From d2dfc73f9e16cc94ca272dda353e21d336d3a9fe Mon Sep 17 00:00:00 2001 From: janik Date: Wed, 1 Jul 2026 12:03:51 +0700 Subject: [PATCH] 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 --- README.md | 67 +++-- code64/debug_console/app.py | 118 +++++++-- code64/debug_console/duty_fft.py | 139 +++++++++++ code64/debug_console/protocol.py | 163 ++++++++---- code64/debug_console/widgets/status_bar.py | 11 +- .../debug_console/widgets/telemetry_panel.py | 54 ++-- testbench/cli.py | 32 +-- testbench/stm32_link.py | 231 +++++++++++------- testbench/tuner.py | 107 ++++---- 9 files changed, 656 insertions(+), 266 deletions(-) create mode 100644 code64/debug_console/duty_fft.py diff --git a/README.md b/README.md index 674eb7c..4cb7c9b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Unified tool for testing and tuning MPPT (Maximum Power Point Tracking) converte +------------------+ | STM32 debug - serial (COM28) + serial (COM4) HIOKI EFF1 = P6 / P5 x 100% (output power / input power) ``` @@ -50,7 +50,7 @@ uv sync uv run bench identify # 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 @@ -177,21 +177,23 @@ uv run bench tune-param \ --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 -# Sweep and report best values +# Sweep at a single load and report the best value uv run bench tune-deadtime \ --voltage 60 --current-limit 20 --load-mode CP \ --dt-start 14 --dt-stop 50 --dt-step 1 \ -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 \ --voltage 60 --current-limit 20 --load-mode CP \ - --load-values 20,50,100,250,400,600 \ + --load-values 100,300,500 \ --apply ``` @@ -213,10 +215,12 @@ For live monitoring and parameter tuning via the Textual terminal UI: ```bash 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 @@ -270,34 +274,49 @@ uv run bench [-h] [--supply-address ADDR] [--load-port PORT] [--load-baud BAUD] | Option | Default | Description | |---|---|---| | `--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 | | `--meter-address` | auto-detect | HIOKI 3193-10 VISA address | | `--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 | ## Tunable STM32 Parameters +Names, IDs, types and ranges mirror the firmware (`code64/debug_console/protocol.py`). + | Parameter | Type | Range | Description | |---|---|---|---| -| `VREF` | uint16 | 3100-3700 | ADC reference voltage | -| `vfly_kp` | float | -10 to 10 | Vfly proportional gain | -| `vfly_ki` | float | -10 to 10 | Vfly integral gain | -| `vfly_clamp` | uint16 | 0-10000 | Vfly integrator clamp | -| `vfly_active` | uint8 | 0-1 | Vfly loop enable | +| `VREF` | uint16 | 2340-3500 | ADC reference voltage | +| `vfly_kp` | float | -10 to 10 | Vfly proportional gain (mode 1, duty asymmetry) | +| `vfly_ki` | float | -10 to 10 | Vfly integral gain (mode 1) | +| `vfly_kp_phase` | float | -10 to 10 | Vfly P gain (mode 2, master-phase offset) | +| `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_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 | -| `mppt_step` | float | 0-10000 | MPPT P&O step size (mA) | -| `mppt_deadband` | float | 0-1 | MPPT deadband | +| `mppt_step` | float | 1-200 | MPPT P&O step size | +| `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 | -| `dt_0_3A` | uint8 | 14-200 | Deadtime 0-3A (HRTIM ticks) | -| `dt_3_5A` | uint8 | 14-200 | Deadtime 3-5A | -| `dt_5_10A` | uint8 | 14-200 | Deadtime 5-10A | -| `dt_10_20A` | uint8 | 14-200 | Deadtime 10-20A | -| `dt_20_30A` | uint8 | 14-200 | Deadtime 20-30A | -| `dt_30_45A` | uint8 | 14-200 | Deadtime 30-45A | +| `cv_threshold` / `cv_hysteresis` | float | 20000-30000 / 0-5000 | CV corner (mV) | +| `cc_threshold` / `cc_hysteresis` | float | 0-55000 / 0-10000 | CC/Iout limit (mA) | +| `dt_normal` | uint16 | 14-200 | Single global dead-time (dt register ticks) | +| `override_duty` | uint16 | 716-6442 | Manual fixed-duty base (CMP ticks, D=10..90%) | +| `manual_duty_en` | uint8 | 0-1 | Enter manual fixed-duty mode | +| `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 diff --git a/code64/debug_console/app.py b/code64/debug_console/app.py index ead8054..88e4c11 100644 --- a/code64/debug_console/app.py +++ b/code64/debug_console/app.py @@ -1,12 +1,14 @@ """Textual TUI application for LVSolarBuck debug console.""" import time +from collections import deque from textual.app import App, ComposeResult from textual.containers import Horizontal, Vertical, VerticalScroll from textual.widgets import Header, Footer, RichLog from .protocol import ( - CMD_TELEMETRY, CMD_PARAM_WRITE_ACK, CMD_PARAM_VALUE, CMD_PONG, CMD_ERROR_MSG, - decode_telemetry, decode_param_value, build_ping, build_param_read_all, + 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_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, ) from .serial_worker import SerialWorker @@ -49,9 +51,14 @@ class DebugConsoleApp(App): BINDINGS = [ ("q", "quit", "Quit"), ("p", "ping", "Ping"), - ("r", "read_params", "Read Params"), ("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): @@ -60,9 +67,23 @@ class DebugConsoleApp(App): self.serial_baudrate = baudrate self.worker: SerialWorker | 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._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 + # 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: yield Header() @@ -76,6 +97,7 @@ class DebugConsoleApp(App): yield ParamGroup("CC", self._send_data) yield ParamGroup("MPPT", 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 StatusBar() yield Footer() @@ -93,6 +115,7 @@ class DebugConsoleApp(App): self.worker.start() self.set_interval(0.5, self._update_status) self.set_interval(2.0, self._request_params) + self.set_interval(0.3, self._flush_pending_writes) def on_unmount(self) -> None: if self.worker: @@ -102,8 +125,33 @@ class DebugConsoleApp(App): def _send_data(self, data: bytes) -> None: 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) + 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: if self.worker and self.worker.connected: self._send_data(build_param_read_all()) @@ -114,11 +162,16 @@ class DebugConsoleApp(App): if t is not None: self._telem_count += 1 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 if t.seq != expected: diff = (t.seq - expected) & 0xFF if self.worker: self.worker.drop_count += diff + else: + self._sent_count += 1 self._last_seq = t.seq if self.logger: self.logger.log_telemetry(t) @@ -134,6 +187,7 @@ class DebugConsoleApp(App): result = decode_param_value(payload) if result: param_id, value = result + self._pending_writes.pop(param_id, None) if self.logger: self.logger.log_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: now = time.monotonic() - elapsed = now - self._fps_time - fps = self._telem_count / elapsed if elapsed > 0 else 0.0 + # Push this tick's counts, then slide the window forward, dropping + # 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._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.fps = fps + status.sent_fps = sent_fps + status.loss_pct = loss_pct if self.worker: status.connected = self.worker.connected 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: self._send_data(build_ping()) - def action_read_params(self) -> None: - self._request_params() + def action_shutdown(self) -> None: + 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: panel = self.query_one(TelemetryPanel) panel.filter_enabled = not panel.filter_enabled state = "ON" if panel.filter_enabled else "OFF" 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") diff --git a/code64/debug_console/duty_fft.py b/code64/debug_console/duty_fft.py new file mode 100644 index 0000000..0599f3d --- /dev/null +++ b/code64/debug_console/duty_fft.py @@ -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) diff --git a/code64/debug_console/protocol.py b/code64/debug_console/protocol.py index 7994b86..a4863f2 100644 --- a/code64/debug_console/protocol.py +++ b/code64/debug_console/protocol.py @@ -13,30 +13,32 @@ CMD_PARAM_READ_ALL = 0x04 CMD_PARAM_VALUE = 0x05 CMD_PING = 0x10 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 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 table (poly 0x07) -_CRC8_TABLE = [0] * 256 -def _init_crc8(): - for i in range(256): - 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 +# CRC-16/CCITT-FALSE: poly 0x1021, init 0xFFFF, no reflection, no XOR-out. +# Matches STM32 hardware CRC configured in main.c MX_CRC_Init. +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 @@ -48,9 +50,11 @@ class TelemetryData: iout: float = 0.0 vfly: float = 0.0 etemp: float = 0.0 + btemp: float = 0.0 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 @@ -59,9 +63,12 @@ class TelemetryData: 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 -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) 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]) return TelemetryData( vin=vals[0], vout=vals[1], iin=vals[2], iout=vals[3], - vfly=vals[4], etemp=vals[5], - last_tmp=vals[6], VREF=vals[7], vfly_correction=vals[8], - # vals[9] is pad - vfly_integral=vals[10], vfly_avg_debug=vals[11], - cc_output_f=vals[12], mppt_iref=vals[13], - mppt_last_vin=vals[14], mppt_last_iin=vals[15], - p_in=vals[16], p_out=vals[17], - seq=vals[18], + vfly=vals[4], etemp=vals[5], btemp=vals[6], + last_tmp=vals[7], VREF=vals[8], vfly_correction=vals[9], + cmp_outer=vals[10], + vfly_integral=vals[11], vfly_avg_debug=vals[12], + cc_output_f=vals[13], mppt_iref=vals[14], + mppt_last_vin=vals[15], mppt_last_iin=vals[16], + p_in=vals[17], p_out=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: header = bytes([SYNC_BYTE, cmd, len(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: @@ -96,6 +107,8 @@ def build_param_write(param_id: int, param_type: int, value) -> bytes: val_bytes = struct.pack(" bytes: 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: 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]) elif param_type == PTYPE_INT32: value = float(struct.unpack(" 128: self.state = self.WAIT_SYNC else: @@ -174,11 +219,15 @@ class FrameParser: self.buf.append(b) self.idx += 1 if self.idx >= self.length: - self.state = self.WAIT_CRC - elif self.state == self.WAIT_CRC: - expected = crc8(bytes(self.buf)) + 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 b == expected: + if received == expected: yield (self.cmd, bytes(self.payload)) @@ -195,13 +244,17 @@ class ParamDef: 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(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, 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 ParamDef(0x30, "cc_target", PTYPE_FLOAT, "CC", 0, 60000, ".0f"), 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(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(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, "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(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"), # enable manual_duty_en first (seeds at current duty), then sweep + ParamDef(0x65, "manual_duty_en", PTYPE_UINT8, "Manual", 0, 1, ".0f"), # 1 = fixed duty, supervisor parked; FMAC balancer still runs per vfly_active + # Duty dither: avoid the V_fly de-stack band by delta-sigma modulating the commanded + # 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} diff --git a/code64/debug_console/widgets/status_bar.py b/code64/debug_console/widgets/status_bar.py index bbe5ac5..a7f666c 100644 --- a/code64/debug_console/widgets/status_bar.py +++ b/code64/debug_console/widgets/status_bar.py @@ -21,8 +21,15 @@ class StatusBar(Static): self.pkt_count = 0 self.drop_count = 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): 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}") diff --git a/code64/debug_console/widgets/telemetry_panel.py b/code64/debug_console/widgets/telemetry_panel.py index b4c7587..d524ecb 100644 --- a/code64/debug_console/widgets/telemetry_panel.py +++ b/code64/debug_console/widgets/telemetry_panel.py @@ -18,9 +18,10 @@ class TelemetryPanel(Static): ALPHA = 0.05 # EMA filter coefficient - DT_BREAKPOINTS = [0, 3000, 5000, 10000, 20000, 30000, 45000] - DT_DEFAULTS = [25, 20, 20, 20, 15, 15] - DT_PARAM_IDS = [0x60, 0x61, 0x62, 0x63, 0x64, 0x65] + MASTER_TICKS_MID = 7158 # s = 2*last_tmp - MASTER_TICKS_MID (edge-separation abscissa) + # dead-time is a single static value now (dt_normal, 0x60); track it for display + DT_IDS = {0x60} + DT_DEFAULTS = {0x60: 20} def __init__(self): super().__init__("Waiting for telemetry...") @@ -32,7 +33,11 @@ class TelemetryPanel(Static): self._vfly_f: float = 0.0 self._seeded: bool = False 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): self._data = t @@ -65,15 +70,25 @@ class TelemetryPanel(Static): else: eta = 0.0 - # Compute active deadtime from iout using same lookup as ISR - active_dt = self._dt_values[0] - for i in range(len(self.DT_BREAKPOINTS) - 1, -1, -1): - if i_out >= self.DT_BREAKPOINTS[i]: - active_dt = self._dt_values[min(i, len(self._dt_values) - 1)] - break + # Dead-time is a single static value (dt_normal), applied to both edges/timers. + active_dt = self._dt_params.get(0x60, 20) + + if self.link_tx is not None: + if self.link_loss >= 5.0: + 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 = [ "[bold]MEASUREMENTS[/bold]", + *link_lines, "", 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", @@ -84,23 +99,28 @@ class TelemetryPanel(Static): 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" 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" 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" 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_avg : {t.vfly_avg_debug:10.1f}", f" cc.out_f : {t.cc_output_f:10.1f}", - f" mppt.iref : {t.mppt_iref:8.0f} mA", - f" mppt.vin : {t.mppt_last_vin:8.0f}", - f" mppt.iin : {t.mppt_last_iin:8.0f}", + f" mppt.duty : {t.mppt_iref:8.0f}", + f" mppt.power: {t.mppt_last_vin:12.0f}", + f" mppt.dir : {t.mppt_last_iin:4.0f}", ] self.update("\n".join(lines)) def update_dt_param(self, param_id: int, value: float): - if param_id in self.DT_PARAM_IDS: - idx = self.DT_PARAM_IDS.index(param_id) - self._dt_values[idx] = int(value) + if param_id in self.DT_IDS: + self._dt_params[param_id] = value diff --git a/testbench/cli.py b/testbench/cli.py index 3d6abb6..1745ac1 100644 --- a/testbench/cli.py +++ b/testbench/cli.py @@ -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") diff --git a/testbench/stm32_link.py b/testbench/stm32_link.py index e4b65e7..c76a4df 100644 --- a/testbench/stm32_link.py +++ b/testbench/stm32_link.py @@ -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(" Optional[tuple[int, float]]: value = float(vb[0]) elif ptype == PTYPE_INT32: value = float(struct.unpack(" 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 diff --git a/testbench/tuner.py b/testbench/tuner.py index a0cb2ba..883ee7a 100644 --- a/testbench/tuner.py +++ b/testbench/tuner.py @@ -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 ────────────────────────────────────────────