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:
@@ -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
@@ -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")
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user