GUI: live STM32 telemetry + sweep guards + auto-logging + bench-plot
- stm32_link.py: port to the live 114-byte broadcast protocol (magic 0xAA55AA55, odd parity 8-O-1, 100 Hz publish, repetition-validated, no CRC); 39 params incl. adc4_trig_phase/iin_zero_sum, CLEAR_FLAGS, 30-bit flag table; commands stay CRC-16 framed; Telemetry aliases BroadcastData, efficiency uses iout_slow and eff_net subtracts P_sys - gui_workers.py: STM32Worker reader thread with counter dedup, rate/ loss counters, 20 s graph history, full-rate telemetry CSV writer - gui.py: right-side telemetry panel (link state, power + EFF net, heatsink/board temps, Vfly group, control, HRTIM, status-flag checkboxes, fault registers), Vfly + selectable corr/phase-ofs graphs, 20 s rolling window on all plots, dual CSV logging (merged stm_* columns + <stem>_telem.csv), logging on by default into logs/data_<timestamp>.csv, Plot Eff button - sweep guards: PSU 20 A input-current gate (conservative estimate + measured backstop + I-limit clamp), thermal pause at 57/77 C holding the load at 1 A until cooled 5 C below threshold, CC range pinned to R2 for the whole run with empirical range-max readback rejection - plot_eff.py + bench-plot entry point: efficiency vs Vin vs current maps from any logged CSV (sweep / data log / telem autodetect), file dialog when launched without args - bench.py: HIOKI FAST response speed, 5 s settle defaults; cli.py stm32-read prints the full broadcast; README + .gitignore updates Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,3 +6,4 @@ build/
|
|||||||
.venv/
|
.venv/
|
||||||
*.csv
|
*.csv
|
||||||
!samples/*.csv
|
!samples/*.csv
|
||||||
|
logs/
|
||||||
|
|||||||
@@ -84,8 +84,42 @@ The GUI provides:
|
|||||||
- HIOKI channel range selectors + degauss buttons
|
- HIOKI channel range selectors + degauss buttons
|
||||||
- Meter format selector (scientific/normal)
|
- Meter format selector (scientific/normal)
|
||||||
- 2D sweep panel with time estimate
|
- 2D sweep panel with time estimate
|
||||||
- Live-updating power, efficiency, voltage, and current plots
|
- Live-updating power, efficiency, voltage, and current plots plus STM32
|
||||||
- Console log panel
|
Vfly and vfly_correction/vfly_ofs_applied plots (selectable) — all graphs
|
||||||
|
show the last 20 seconds
|
||||||
|
- STM32 telemetry panel ("Link STM32", COM4 @ 460800 8-O-1): every field of
|
||||||
|
the 100 Hz broadcast (V/I/P + net efficiency, temps, Vfly group, control
|
||||||
|
mode, HRTIM compare registers, status flags, fault registers, param echo),
|
||||||
|
with Ping and Clear Flags buttons; auto-reconnects on serial errors
|
||||||
|
- Data logging: instrument rows (with the latest STM32 snapshot merged in as
|
||||||
|
`stm_*` columns) to the chosen CSV, plus the full-rate 100 Hz telemetry
|
||||||
|
stream to `<name>_telem.csv` alongside. Logging is ON by default: every
|
||||||
|
launch auto-starts a log at `logs/data_<timestamp>.csv` (relative to the
|
||||||
|
working directory); use Stop Log / Start Log to switch to a custom path
|
||||||
|
- Console log panel (STM32 fault flags are reported here as they latch)
|
||||||
|
- PSU capability guard: the HV supply can source at most 20 A
|
||||||
|
(`PSU_MAX_CURRENT_A` in `gui.py`). Sweep steps whose estimated input draw
|
||||||
|
`I_in = P_out / (0.90 * V_in)` exceeds that are rejected (skipped and
|
||||||
|
reported per voltage), regardless of the requested step range; a measured
|
||||||
|
backstop additionally drops any point where the supply actually exceeded
|
||||||
|
the limit and backs the load off. The programmed supply current limit
|
||||||
|
(sweep "I limit" field and manual supply controls) is clamped to 20 A, and
|
||||||
|
manual CC/CP load setpoints are checked against live Vin/Vout readings.
|
||||||
|
- Load range pinning: a mid-sweep auto-range transition on the Prodigit
|
||||||
|
momentarily unloads the converter, so at sweep start the CC range is
|
||||||
|
pinned to Range II for the whole run (auto-ranging restored after, with
|
||||||
|
the load off). The reachable maximum of the selected range is verified
|
||||||
|
empirically -- the sweep max is programmed with the load off and read
|
||||||
|
back; if the readback comes back clamped, steps above it are rejected
|
||||||
|
instead of silently clamped, and a sweep that fits nothing aborts up
|
||||||
|
front.
|
||||||
|
- Thermal sweep guard (needs the STM32 link): before every sweep step the
|
||||||
|
heatsink/board temperatures are checked against the firmware trip limits
|
||||||
|
(60 C / 80 C). At 57 C / 77 C the sweep pauses, holds the load at 1 A
|
||||||
|
(CC; ~1 A worth of W in CP), and waits until both temps drop 5 C below
|
||||||
|
the pause thresholds, then resumes at the same step -- no points are lost.
|
||||||
|
If the STM32 link is down the sweep still runs, with a console warning
|
||||||
|
that the guard is inactive.
|
||||||
|
|
||||||
### 5. Run efficiency sweeps
|
### 5. Run efficiency sweeps
|
||||||
|
|
||||||
@@ -141,6 +175,32 @@ Produces three PNG files:
|
|||||||
- `*_heatmap.png` -- 2D efficiency surface (voltage x load)
|
- `*_heatmap.png` -- 2D efficiency surface (voltage x load)
|
||||||
- `*_loss.png` -- power loss vs load, all voltages overlaid
|
- `*_loss.png` -- power loss vs load, all voltages overlaid
|
||||||
|
|
||||||
|
#### Efficiency vs Vin vs current from any logged CSV
|
||||||
|
|
||||||
|
`bench-plot` auto-detects all three CSV formats the tooling produces -- sweep
|
||||||
|
CSVs, GUI data logs (`data_*.csv`), and full-rate telemetry logs
|
||||||
|
(`*_telem.csv`) -- and draws an operating-point map (x = Vin, y = current,
|
||||||
|
color = efficiency) plus efficiency-vs-current curves grouped by Vin bin:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run bench-plot # no args -> file-picker dialog
|
||||||
|
uv run bench-plot data_20260703_140000.csv
|
||||||
|
uv run bench-plot run1_telem.csv run2_telem.csv --vin-bin 2 --save eff.png
|
||||||
|
|
||||||
|
# options: --current iout|iin, --source auto|hioki|instr|stm (data logs),
|
||||||
|
# --min-pout W (default 5), --vin-bin V (default 1), --save PNG
|
||||||
|
```
|
||||||
|
|
||||||
|
Also reachable via the GUI's "Plot Eff..." button (Logging section, opens the
|
||||||
|
same dialog preselecting the last log) and `plot_eff.bat` one level up
|
||||||
|
(double-click for the dialog, or drag && drop CSV files onto it).
|
||||||
|
|
||||||
|
For GUI data logs the efficiency source defaults to `auto`: HIOKI EFF1 if the
|
||||||
|
meter was connected, else supply/load power ratio, else the board's own
|
||||||
|
`stm_eff_net_pct`. Telemetry logs always use the board's net efficiency
|
||||||
|
(`(P_out - P_sys) / P_in`, iout_slow). Points below `--min-pout` (default
|
||||||
|
5 W, same as the GUI display gate) are dropped.
|
||||||
|
|
||||||
### 7. Tune converter parameters
|
### 7. Tune converter parameters
|
||||||
|
|
||||||
The tuning commands combine the testbench instruments (ground truth efficiency from HIOKI) with direct STM32 parameter writes to find optimal settings.
|
The tuning commands combine the testbench instruments (ground truth efficiency from HIOKI) with direct STM32 parameter writes to find optimal settings.
|
||||||
@@ -317,6 +377,8 @@ Names, IDs, types and ranges mirror the firmware (`code64/debug_console/protocol
|
|||||||
| `dither_band_lo` / `dither_band_hi` | uint16 | 716-6442 | Forbidden duty band edges (CMP ticks) |
|
| `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_anear` / `dither_afar` | uint16 | 716-6442 | Out-of-band dither anchors |
|
||||||
| `dither_dzero` | uint16 | 716-6442 | \|e\| fold center (D=0.5) |
|
| `dither_dzero` | uint16 | 716-6442 | \|e\| fold center (D=0.5) |
|
||||||
|
| `adc4_trig_phase` | uint16 | 3-14313 | HRTIM master CMP3: iout_slow sample instant |
|
||||||
|
| `iin_zero_sum` | uint16 | 0-32760 | IIN software zero offset (sum-of-8 counts) |
|
||||||
|
|
||||||
## CSV Output Format
|
## CSV Output Format
|
||||||
|
|
||||||
@@ -335,6 +397,16 @@ Sweep CSV files contain:
|
|||||||
|
|
||||||
Tuning CSV files additionally contain `param_name`, `param_value`, and STM32 telemetry columns (`stm_vin`, `stm_vout`, `stm_iin`, `stm_iout`, `stm_eff`, `stm_vfly`, `stm_etemp`).
|
Tuning CSV files additionally contain `param_name`, `param_value`, and STM32 telemetry columns (`stm_vin`, `stm_vout`, `stm_iin`, `stm_iout`, `stm_eff`, `stm_vfly`, `stm_etemp`).
|
||||||
|
|
||||||
|
GUI data-log CSVs contain the instrument columns plus the latest STM32
|
||||||
|
broadcast snapshot per row (`stm_counter` … `stm_age_s`; flag/fault registers
|
||||||
|
as hex). While logging, the full-rate 100 Hz telemetry stream is additionally
|
||||||
|
written to `<name>_telem.csv` with every broadcast field (one row per fresh
|
||||||
|
publish, `pc_time`/`t_mono` timestamps, computed `p_in_W`/`p_out_W`).
|
||||||
|
|
||||||
|
Note: STM32-derived `power_out_W`/`efficiency` now use `iout_slow` (the
|
||||||
|
PWM-synchronous ADC4 output current) instead of the fast protection-path
|
||||||
|
`iout` — tuner numbers shift slightly vs. old logs.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -348,7 +420,8 @@ mppt-testbench/
|
|||||||
| +-- cli.py unified CLI entry point
|
| +-- cli.py unified CLI entry point
|
||||||
| +-- gui.py tkinter GUI with live plots
|
| +-- gui.py tkinter GUI with live plots
|
||||||
| +-- gui_workers.py background instrument I/O thread
|
| +-- gui_workers.py background instrument I/O thread
|
||||||
| +-- stm32_link.py synchronous STM32 debug protocol interface
|
| +-- stm32_link.py STM32 debug protocol: 114B broadcast RX + CRC-framed TX commands (8-O-1)
|
||||||
|
| +-- plot_eff.py efficiency vs Vin vs current plots from any logged CSV
|
||||||
| +-- tuner.py automated tuning routines (param sweep, deadtime opt)
|
| +-- tuner.py automated tuning routines (param sweep, deadtime opt)
|
||||||
+-- code64/
|
+-- code64/
|
||||||
| +-- Core/ STM32G474 firmware (C)
|
| +-- Core/ STM32G474 firmware (C)
|
||||||
@@ -356,7 +429,7 @@ mppt-testbench/
|
|||||||
| +-- debug_console/ Textual TUI for live debugging
|
| +-- debug_console/ Textual TUI for live debugging
|
||||||
| +-- pyproject.toml uv-compatible package config
|
| +-- pyproject.toml uv-compatible package config
|
||||||
+-- samples/ shade profile CSV examples
|
+-- samples/ shade profile CSV examples
|
||||||
+-- pyproject.toml package config, entry points: bench, bench-gui
|
+-- pyproject.toml package config, entry points: bench, bench-gui, bench-plot
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|||||||
@@ -26,3 +26,4 @@ packages = ["testbench"]
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
bench = "testbench.cli:main"
|
bench = "testbench.cli:main"
|
||||||
bench-gui = "testbench.gui:main"
|
bench-gui = "testbench.gui:main"
|
||||||
|
bench-plot = "testbench.plot_eff:main"
|
||||||
|
|||||||
+6
-6
@@ -138,7 +138,7 @@ class MPPTTestbench:
|
|||||||
self.meter.set_current_auto(5, True)
|
self.meter.set_current_auto(5, True)
|
||||||
self.meter.set_voltage_auto(6, True)
|
self.meter.set_voltage_auto(6, True)
|
||||||
self.meter.set_current_auto(6, True)
|
self.meter.set_current_auto(6, True)
|
||||||
self.meter.set_response_speed("SLOW")
|
self.meter.set_response_speed("FAST")
|
||||||
self.meter.set_efficiency(1, "P6", "P5")
|
self.meter.set_efficiency(1, "P6", "P5")
|
||||||
|
|
||||||
# Display: 16-item SELECT view
|
# Display: 16-item SELECT view
|
||||||
@@ -300,7 +300,7 @@ class MPPTTestbench:
|
|||||||
v_stop: float,
|
v_stop: float,
|
||||||
v_step: float,
|
v_step: float,
|
||||||
current_limit: float,
|
current_limit: float,
|
||||||
settle_time: float = 1.0,
|
settle_time: float = 5.0,
|
||||||
load_setpoint: float = 0.0,
|
load_setpoint: float = 0.0,
|
||||||
) -> list[SweepPoint]:
|
) -> list[SweepPoint]:
|
||||||
"""Sweep supply voltage and record measurements at each point.
|
"""Sweep supply voltage and record measurements at each point.
|
||||||
@@ -366,7 +366,7 @@ class MPPTTestbench:
|
|||||||
i_start: float,
|
i_start: float,
|
||||||
i_stop: float,
|
i_stop: float,
|
||||||
i_step: float,
|
i_step: float,
|
||||||
settle_time: float = 1.0,
|
settle_time: float = 5.0,
|
||||||
) -> list[SweepPoint]:
|
) -> list[SweepPoint]:
|
||||||
"""Sweep load current (CC mode) at a fixed supply voltage.
|
"""Sweep load current (CC mode) at a fixed supply voltage.
|
||||||
|
|
||||||
@@ -473,7 +473,7 @@ class MPPTTestbench:
|
|||||||
def run_shade_profile(
|
def run_shade_profile(
|
||||||
self,
|
self,
|
||||||
steps: list[dict],
|
steps: list[dict],
|
||||||
settle_time: float = 2.0,
|
settle_time: float = 5.0,
|
||||||
) -> list[SweepPoint]:
|
) -> list[SweepPoint]:
|
||||||
"""Run a shade / irradiance profile sequence.
|
"""Run a shade / irradiance profile sequence.
|
||||||
|
|
||||||
@@ -629,7 +629,7 @@ class MPPTTestbench:
|
|||||||
l_stop: float,
|
l_stop: float,
|
||||||
l_step: float,
|
l_step: float,
|
||||||
current_limit: float,
|
current_limit: float,
|
||||||
settle_time: float = 2.0,
|
settle_time: float = 5.0,
|
||||||
load_mode: str = "CC",
|
load_mode: str = "CC",
|
||||||
) -> list[SweepPoint]:
|
) -> list[SweepPoint]:
|
||||||
"""2D sweep: voltage (outer) × load setpoint (inner).
|
"""2D sweep: voltage (outer) × load setpoint (inner).
|
||||||
@@ -770,7 +770,7 @@ class MPPTTestbench:
|
|||||||
self,
|
self,
|
||||||
voltage: float,
|
voltage: float,
|
||||||
current_limit: float,
|
current_limit: float,
|
||||||
settle_time: float = 2.0,
|
settle_time: float = 5.0,
|
||||||
samples: int = 5,
|
samples: int = 5,
|
||||||
sample_interval: float = 1.0,
|
sample_interval: float = 1.0,
|
||||||
) -> dict[str, float]:
|
) -> dict[str, float]:
|
||||||
|
|||||||
+22
-13
@@ -572,20 +572,29 @@ def cmd_stm32_read(bench: MPPTTestbench, args: argparse.Namespace) -> None:
|
|||||||
print(f" {name:<20s} = {val}")
|
print(f" {name:<20s} = {val}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Read telemetry
|
# Read telemetry (100 Hz broadcast, 20 fresh publishes averaged)
|
||||||
t = link.read_telemetry_avg(n=20)
|
t = link.read_telemetry_avg(n=20)
|
||||||
if t:
|
if t:
|
||||||
|
from testbench.stm32_link import flags_to_names
|
||||||
|
modes = ("OFF", "MPPT", "CV", "CC")
|
||||||
|
mode = modes[t.ctrl_mode] if 0 <= t.ctrl_mode < len(modes) else str(t.ctrl_mode)
|
||||||
print("Telemetry (20-sample avg):")
|
print("Telemetry (20-sample avg):")
|
||||||
print(f" Vin = {t.vin_V:8.2f} V")
|
print(f" Vin = {t.vin_V:8.2f} V")
|
||||||
print(f" Vout = {t.vout_V:8.2f} V")
|
print(f" Vout = {t.vout_V:8.2f} V")
|
||||||
print(f" Iin = {t.iin_A:8.2f} A")
|
print(f" Iin = {t.iin_A:8.2f} A (avg {t.iin_avg_ma:+d} mA)")
|
||||||
print(f" Iout = {t.iout_A:8.2f} A")
|
print(f" Iout = {t.iout_slow/1000:8.2f} A (fast {t.iout_A:.2f} A)")
|
||||||
|
print(f" Isys = {t.sys_current_ma:8d} mA")
|
||||||
print(f" Pin = {t.power_in_W:8.2f} W")
|
print(f" Pin = {t.power_in_W:8.2f} W")
|
||||||
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} % (net {t.efficiency_net:.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 (FET)")
|
print(f" Temp = {t.etemp:8.1f} °C (heatsink)")
|
||||||
print(f" Tbrd = {t.btemp:8.1f} °C (board)")
|
print(f" Tbrd = {t.btemp:8.1f} °C (board)")
|
||||||
|
print(f" Mode = {mode}")
|
||||||
|
if t.status_flags:
|
||||||
|
print(f" Flags= 0x{t.status_flags:08X}")
|
||||||
|
for name in flags_to_names(t.status_flags):
|
||||||
|
print(f" {name}")
|
||||||
|
|
||||||
|
|
||||||
def cmd_stm32_write(bench: MPPTTestbench, args: argparse.Namespace) -> None:
|
def cmd_stm32_write(bench: MPPTTestbench, args: argparse.Namespace) -> None:
|
||||||
@@ -1192,7 +1201,7 @@ examples:
|
|||||||
p_sweep.add_argument("--v-stop", type=float, required=True, help="Stop voltage (V)")
|
p_sweep.add_argument("--v-stop", type=float, required=True, help="Stop voltage (V)")
|
||||||
p_sweep.add_argument("--v-step", type=float, required=True, help="Voltage step (V)")
|
p_sweep.add_argument("--v-step", type=float, required=True, help="Voltage step (V)")
|
||||||
p_sweep.add_argument("--current-limit", type=float, required=True, help="Current limit (A)")
|
p_sweep.add_argument("--current-limit", type=float, required=True, help="Current limit (A)")
|
||||||
p_sweep.add_argument("--settle", type=float, default=1.0, help="Settle time per step (s)")
|
p_sweep.add_argument("--settle", type=float, default=5.0, help="Settle time per step (s)")
|
||||||
p_sweep.add_argument("--load-mode", choices=["CC", "CR", "CV", "CP"], help="Set load mode before sweep")
|
p_sweep.add_argument("--load-mode", choices=["CC", "CR", "CV", "CP"], help="Set load mode before sweep")
|
||||||
p_sweep.add_argument("--load-value", type=float, help="Set load value before sweep")
|
p_sweep.add_argument("--load-value", type=float, help="Set load value before sweep")
|
||||||
p_sweep.add_argument("-o", "--output", help="CSV output file")
|
p_sweep.add_argument("-o", "--output", help="CSV output file")
|
||||||
@@ -1204,7 +1213,7 @@ examples:
|
|||||||
p_swl.add_argument("--i-start", type=float, required=True, help="Start load current (A)")
|
p_swl.add_argument("--i-start", type=float, required=True, help="Start load current (A)")
|
||||||
p_swl.add_argument("--i-stop", type=float, required=True, help="Stop load current (A)")
|
p_swl.add_argument("--i-stop", type=float, required=True, help="Stop load current (A)")
|
||||||
p_swl.add_argument("--i-step", type=float, required=True, help="Current step (A)")
|
p_swl.add_argument("--i-step", type=float, required=True, help="Current step (A)")
|
||||||
p_swl.add_argument("--settle", type=float, default=1.0, help="Settle time per step (s)")
|
p_swl.add_argument("--settle", type=float, default=5.0, help="Settle time per step (s)")
|
||||||
p_swl.add_argument("-o", "--output", help="CSV output file")
|
p_swl.add_argument("-o", "--output", help="CSV output file")
|
||||||
|
|
||||||
# efficiency
|
# efficiency
|
||||||
@@ -1212,7 +1221,7 @@ examples:
|
|||||||
p_eff.add_argument("--voltage", type=float, required=True, help="Supply voltage (V)")
|
p_eff.add_argument("--voltage", type=float, required=True, help="Supply voltage (V)")
|
||||||
p_eff.add_argument("--current-limit", type=float, required=True, help="Current limit (A)")
|
p_eff.add_argument("--current-limit", type=float, required=True, help="Current limit (A)")
|
||||||
p_eff.add_argument("--samples", type=int, default=5, help="Number of readings to average")
|
p_eff.add_argument("--samples", type=int, default=5, help="Number of readings to average")
|
||||||
p_eff.add_argument("--settle", type=float, default=2.0, help="Initial settle time (s)")
|
p_eff.add_argument("--settle", type=float, default=5.0, help="Initial settle time (s)")
|
||||||
p_eff.add_argument("-i", "--interval", type=float, default=1.0, help="Interval between samples")
|
p_eff.add_argument("-i", "--interval", type=float, default=1.0, help="Interval between samples")
|
||||||
p_eff.add_argument("--load-mode", choices=["CC", "CR", "CV", "CP"])
|
p_eff.add_argument("--load-mode", choices=["CC", "CR", "CV", "CP"])
|
||||||
p_eff.add_argument("--load-value", type=float)
|
p_eff.add_argument("--load-value", type=float)
|
||||||
@@ -1227,13 +1236,13 @@ examples:
|
|||||||
p_svi.add_argument("--l-step", type=float, required=True, help="Load step size")
|
p_svi.add_argument("--l-step", type=float, required=True, help="Load step size")
|
||||||
p_svi.add_argument("--load-mode", choices=["CC", "CP"], default="CC", help="Load mode: CC (current) or CP (power)")
|
p_svi.add_argument("--load-mode", choices=["CC", "CP"], default="CC", help="Load mode: CC (current) or CP (power)")
|
||||||
p_svi.add_argument("--current-limit", type=float, required=True, help="Supply current limit (A)")
|
p_svi.add_argument("--current-limit", type=float, required=True, help="Supply current limit (A)")
|
||||||
p_svi.add_argument("--settle", type=float, default=2.0, help="Settle time per step (s)")
|
p_svi.add_argument("--settle", type=float, default=5.0, help="Settle time per step (s)")
|
||||||
p_svi.add_argument("-o", "--output", help="CSV output file")
|
p_svi.add_argument("-o", "--output", help="CSV output file")
|
||||||
|
|
||||||
# shade-profile
|
# shade-profile
|
||||||
p_shade = sub.add_parser("shade-profile", help="Run a shade/irradiance profile from CSV")
|
p_shade = sub.add_parser("shade-profile", help="Run a shade/irradiance profile from CSV")
|
||||||
p_shade.add_argument("--profile", required=True, help="Profile CSV file (time,voltage,current_limit,...)")
|
p_shade.add_argument("--profile", required=True, help="Profile CSV file (time,voltage,current_limit,...)")
|
||||||
p_shade.add_argument("--settle", type=float, default=2.0, help="Settle time per step (s)")
|
p_shade.add_argument("--settle", type=float, default=5.0, help="Settle time per step (s)")
|
||||||
p_shade.add_argument("-o", "--output", help="CSV output file for results")
|
p_shade.add_argument("-o", "--output", help="CSV output file for results")
|
||||||
|
|
||||||
# supply (direct control)
|
# supply (direct control)
|
||||||
@@ -1275,7 +1284,7 @@ examples:
|
|||||||
p_tp.add_argument("--current-limit", type=float, required=True, help="Supply current limit (A)")
|
p_tp.add_argument("--current-limit", type=float, required=True, help="Supply current limit (A)")
|
||||||
p_tp.add_argument("--load-mode", choices=["CC", "CP"], default="CP", help="Load mode")
|
p_tp.add_argument("--load-mode", choices=["CC", "CP"], default="CP", help="Load mode")
|
||||||
p_tp.add_argument("--load-value", type=float, default=200.0, help="Load setpoint (A or W)")
|
p_tp.add_argument("--load-value", type=float, default=200.0, help="Load setpoint (A or W)")
|
||||||
p_tp.add_argument("--settle", type=float, default=3.0, help="Settle time per step (s)")
|
p_tp.add_argument("--settle", type=float, default=5.0, help="Settle time per step (s)")
|
||||||
p_tp.add_argument("--no-plot", action="store_true", help="Skip plot")
|
p_tp.add_argument("--no-plot", action="store_true", help="Skip plot")
|
||||||
p_tp.add_argument("-o", "--output", help="CSV output file")
|
p_tp.add_argument("-o", "--output", help="CSV output file")
|
||||||
|
|
||||||
@@ -1291,7 +1300,7 @@ examples:
|
|||||||
p_tv.add_argument("--current-limit", type=float, required=True, help="Supply current limit (A)")
|
p_tv.add_argument("--current-limit", type=float, required=True, help="Supply current limit (A)")
|
||||||
p_tv.add_argument("--load-mode", choices=["CC", "CP"], default="CP", help="Load mode")
|
p_tv.add_argument("--load-mode", choices=["CC", "CP"], default="CP", help="Load mode")
|
||||||
p_tv.add_argument("--load-value", type=float, default=200.0, help="Load setpoint (A or W)")
|
p_tv.add_argument("--load-value", type=float, default=200.0, help="Load setpoint (A or W)")
|
||||||
p_tv.add_argument("--settle", type=float, default=3.0, help="Settle time per step (s)")
|
p_tv.add_argument("--settle", type=float, default=5.0, help="Settle time per step (s)")
|
||||||
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
|
||||||
@@ -1303,7 +1312,7 @@ examples:
|
|||||||
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 to test (e.g. 100,300,500)")
|
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=5.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")
|
||||||
|
|
||||||
|
|||||||
+762
-38
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,19 @@ never freezes during instrument queries.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections import deque
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
|
|
||||||
|
import serial
|
||||||
|
|
||||||
|
from testbench.stm32_link import (
|
||||||
|
BroadcastData, BroadcastParser, PARAM_BY_ID, decode_param_bits,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Cmd(Enum):
|
class Cmd(Enum):
|
||||||
"""Commands sent from GUI to worker thread."""
|
"""Commands sent from GUI to worker thread."""
|
||||||
@@ -226,3 +234,214 @@ class InstrumentWorker(threading.Thread):
|
|||||||
})
|
})
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── STM32 broadcast reader ───────────────────────────────────────────
|
||||||
|
|
||||||
|
# All BroadcastData wire fields, in dataclass order (full-rate CSV columns).
|
||||||
|
TELEM_CSV_FIELDS = (
|
||||||
|
"counter", "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", "iout_slow",
|
||||||
|
"last_tmp", "VREF", "vfly_correction", "cmp_outer", "cmp_inner",
|
||||||
|
"vfly_ofs_applied", "ctrl_mode", "vfly_active", "status_flags",
|
||||||
|
"fmac_sr", "fault_pc", "cfsr", "param_id", "param_type",
|
||||||
|
"param_value", "pong", "sys_current_ma", "iin_avg_ma",
|
||||||
|
)
|
||||||
|
_TELEM_HEX_FIELDS = frozenset({"status_flags", "fmac_sr", "fault_pc", "cfsr"})
|
||||||
|
|
||||||
|
|
||||||
|
class STM32Worker(threading.Thread):
|
||||||
|
"""Daemon thread reading the STM32 broadcast stream.
|
||||||
|
|
||||||
|
The board streams 114-byte frames continuously (100 Hz publishes, each
|
||||||
|
repeated ~3-4x at line rate); this thread validates them, keeps the
|
||||||
|
latest sample plus 20 s graph history, and owns the full-rate telemetry
|
||||||
|
CSV (single writer). Auto-reconnects on serial errors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
HISTORY = 2200 # 100 Hz x 20 s + margin
|
||||||
|
CSV_FLUSH_S = 1.0 # never flush per-row at 100 rows/s
|
||||||
|
|
||||||
|
def __init__(self, port: str, baudrate: int = 460800) -> None:
|
||||||
|
super().__init__(daemon=True)
|
||||||
|
self.port = port
|
||||||
|
self.baudrate = baudrate
|
||||||
|
self.connected = False
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._tx_queue: queue.Queue = queue.Queue()
|
||||||
|
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.latest: BroadcastData | None = None
|
||||||
|
self.latest_wall: float = 0.0
|
||||||
|
self._last_counter = -1
|
||||||
|
self._t: deque = deque(maxlen=self.HISTORY) # time.monotonic per fresh publish
|
||||||
|
self._vfly: deque = deque(maxlen=self.HISTORY) # mV
|
||||||
|
self._corr: deque = deque(maxlen=self.HISTORY) # vfly_correction, ticks
|
||||||
|
self._ofs: deque = deque(maxlen=self.HISTORY) # vfly_ofs_applied, ticks
|
||||||
|
self._fresh_count = 0 # validated fresh publishes since last get_rates()
|
||||||
|
self._sent_count = 0 # publishes the board sent (counter deltas)
|
||||||
|
self.params: dict[int, float] = {}
|
||||||
|
|
||||||
|
self._csv_lock = threading.Lock()
|
||||||
|
self._csv_file = None
|
||||||
|
self._csv_writer = None
|
||||||
|
self._csv_last_flush = 0.0
|
||||||
|
|
||||||
|
# ── Thread-safe API for the GUI ──────────────────────────────────
|
||||||
|
|
||||||
|
def get_latest(self) -> tuple[BroadcastData | None, float]:
|
||||||
|
"""Latest validated sample and its wall-clock arrival time."""
|
||||||
|
with self._lock:
|
||||||
|
return self.latest, self.latest_wall
|
||||||
|
|
||||||
|
def get_graph_snapshot(self, decimate: int = 4):
|
||||||
|
"""(t_mono, vfly_mV, corr, ofs) lists, decimated for display.
|
||||||
|
|
||||||
|
Decimation is anchored at the end so the newest sample always shows.
|
||||||
|
Full rate is still recorded to the CSV.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
t, v = list(self._t), list(self._vfly)
|
||||||
|
c, o = list(self._corr), list(self._ofs)
|
||||||
|
if decimate > 1 and t:
|
||||||
|
k = (len(t) - 1) % decimate
|
||||||
|
t, v, c, o = t[k::decimate], v[k::decimate], c[k::decimate], o[k::decimate]
|
||||||
|
return t, v, c, o
|
||||||
|
|
||||||
|
def get_rates(self) -> tuple[int, int]:
|
||||||
|
"""(fresh publishes received, publishes sent) since the last call."""
|
||||||
|
with self._lock:
|
||||||
|
f, s = self._fresh_count, self._sent_count
|
||||||
|
self._fresh_count = 0
|
||||||
|
self._sent_count = 0
|
||||||
|
return f, s
|
||||||
|
|
||||||
|
def send_frame(self, data: bytes) -> None:
|
||||||
|
"""Queue a pre-built command frame for TX on the worker thread."""
|
||||||
|
self._tx_queue.put(data)
|
||||||
|
|
||||||
|
def start_csv(self, path: str) -> None:
|
||||||
|
"""Open the full-rate telemetry CSV (one row per fresh publish)."""
|
||||||
|
with self._csv_lock:
|
||||||
|
self._close_csv_locked()
|
||||||
|
f = open(path, "w", newline="", encoding="utf-8")
|
||||||
|
w = csv.writer(f)
|
||||||
|
w.writerow(("pc_time", "t_mono") + TELEM_CSV_FIELDS + ("p_in_W", "p_out_W"))
|
||||||
|
self._csv_file, self._csv_writer = f, w
|
||||||
|
self._csv_last_flush = time.monotonic()
|
||||||
|
|
||||||
|
def stop_csv(self) -> None:
|
||||||
|
with self._csv_lock:
|
||||||
|
self._close_csv_locked()
|
||||||
|
|
||||||
|
def _close_csv_locked(self) -> None:
|
||||||
|
if self._csv_file is not None:
|
||||||
|
try:
|
||||||
|
self._csv_file.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self._csv_file = None
|
||||||
|
self._csv_writer = None
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop the thread and close the CSV (blocks up to 2 s)."""
|
||||||
|
self._stop_event.set()
|
||||||
|
if self.is_alive():
|
||||||
|
self.join(timeout=2.0)
|
||||||
|
self.stop_csv()
|
||||||
|
|
||||||
|
# ── Worker loop ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
ser = None
|
||||||
|
parser = None
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
if ser is None:
|
||||||
|
try:
|
||||||
|
ser = serial.Serial(
|
||||||
|
self.port, self.baudrate, timeout=0.05,
|
||||||
|
bytesize=serial.EIGHTBITS,
|
||||||
|
parity=serial.PARITY_ODD,
|
||||||
|
stopbits=serial.STOPBITS_ONE,
|
||||||
|
)
|
||||||
|
parser = BroadcastParser()
|
||||||
|
self.connected = True
|
||||||
|
except (serial.SerialException, OSError):
|
||||||
|
self.connected = False
|
||||||
|
self._stop_event.wait(1.0)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
ser.write(self._tx_queue.get_nowait())
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
data = ser.read(4096)
|
||||||
|
if data:
|
||||||
|
for b in parser.feed(data):
|
||||||
|
self._on_frame(b)
|
||||||
|
except (serial.SerialException, OSError):
|
||||||
|
self.connected = False
|
||||||
|
try:
|
||||||
|
ser.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ser = None
|
||||||
|
self._stop_event.wait(1.0)
|
||||||
|
if ser is not None:
|
||||||
|
try:
|
||||||
|
ser.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.connected = False
|
||||||
|
self.stop_csv()
|
||||||
|
|
||||||
|
# NB: name must not collide with threading.Thread instance attributes
|
||||||
|
# (Thread.__init__ sets self._handle on Python 3.13+).
|
||||||
|
def _on_frame(self, b: BroadcastData) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
# Flags/pong are valid even at counter == 0 (post-reset window);
|
||||||
|
# telemetry/param fields are only valid once counter > 0.
|
||||||
|
self.latest = b
|
||||||
|
self.latest_wall = time.time()
|
||||||
|
fresh = b.counter > 0 and b.counter != self._last_counter
|
||||||
|
if fresh:
|
||||||
|
if 0 < self._last_counter < b.counter:
|
||||||
|
self._sent_count += b.counter - self._last_counter
|
||||||
|
else:
|
||||||
|
self._sent_count += 1 # first valid frame, or a reboot
|
||||||
|
self._fresh_count += 1
|
||||||
|
self._last_counter = b.counter
|
||||||
|
self._t.append(now)
|
||||||
|
self._vfly.append(b.vfly)
|
||||||
|
self._corr.append(b.vfly_correction)
|
||||||
|
self._ofs.append(b.vfly_ofs_applied)
|
||||||
|
if b.param_id in PARAM_BY_ID:
|
||||||
|
self.params[b.param_id] = decode_param_bits(b.param_type, b.param_value)
|
||||||
|
if fresh:
|
||||||
|
self._csv_row(b, now)
|
||||||
|
|
||||||
|
def _csv_row(self, b: BroadcastData, t_mono: float) -> None:
|
||||||
|
with self._csv_lock:
|
||||||
|
if self._csv_writer is None:
|
||||||
|
return
|
||||||
|
row = [f"{b.timestamp:.3f}", f"{t_mono:.3f}"]
|
||||||
|
for name in TELEM_CSV_FIELDS:
|
||||||
|
v = getattr(b, name)
|
||||||
|
if name in _TELEM_HEX_FIELDS:
|
||||||
|
row.append(f"0x{v:08X}")
|
||||||
|
elif isinstance(v, float):
|
||||||
|
row.append(f"{v:.6g}")
|
||||||
|
else:
|
||||||
|
row.append(v)
|
||||||
|
row.append(f"{b.power_in_W:.4f}")
|
||||||
|
row.append(f"{b.power_out_W:.4f}")
|
||||||
|
try:
|
||||||
|
self._csv_writer.writerow(row)
|
||||||
|
if t_mono - self._csv_last_flush >= self.CSV_FLUSH_S:
|
||||||
|
self._csv_file.flush()
|
||||||
|
self._csv_last_flush = t_mono
|
||||||
|
except OSError:
|
||||||
|
self._close_csv_locked()
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Plot efficiency vs input voltage vs current from bench CSV logs.
|
||||||
|
|
||||||
|
Auto-detects the three CSV formats produced by the tooling:
|
||||||
|
- GUI data log (data_*.csv: instrument columns + merged stm_* snapshot)
|
||||||
|
- GUI telemetry log (*_telem.csv: full-rate 100 Hz board broadcast)
|
||||||
|
- CLI sweep (sweep_vi_*.csv: voltage_set/load_setpoint grid)
|
||||||
|
|
||||||
|
Left panel: operating-point scatter (x = Vin, y = current, color = efficiency).
|
||||||
|
Right panel: efficiency vs current, one curve per Vin bin.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
bench-plot data_20260703_120000.csv
|
||||||
|
bench-plot run_telem.csv another_telem.csv --current iin --save eff.png
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
MIN_P_IN_W = 0.1 # same gate as the firmware/GUI efficiency calc
|
||||||
|
|
||||||
|
|
||||||
|
def _f(row: dict, key: str) -> float:
|
||||||
|
"""Float cell value; blank/missing/garbage -> NaN."""
|
||||||
|
v = row.get(key, "")
|
||||||
|
if v is None or v == "":
|
||||||
|
return math.nan
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except ValueError:
|
||||||
|
return math.nan
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_format(header: list[str]) -> str:
|
||||||
|
cols = set(header)
|
||||||
|
if "stm_eff_net_pct" in cols:
|
||||||
|
return "datalog"
|
||||||
|
if "iout_slow" in cols and "p_in_W" in cols:
|
||||||
|
return "telem"
|
||||||
|
if "voltage_set" in cols and "efficiency" in cols:
|
||||||
|
return "sweep"
|
||||||
|
raise ValueError(f"unrecognized CSV header: {header[:6]}...")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract(path: str, source: str) -> tuple[dict, str]:
|
||||||
|
"""Read one CSV -> dict of float lists (vin_V, iin_A, iout_A, eff_pct,
|
||||||
|
p_out_W) plus the efficiency-source label actually used."""
|
||||||
|
with open(path, newline="") as fh:
|
||||||
|
reader = csv.DictReader(fh)
|
||||||
|
header = reader.fieldnames or []
|
||||||
|
rows = list(reader)
|
||||||
|
fmt = _detect_format(header)
|
||||||
|
|
||||||
|
out: dict[str, list] = {k: [] for k in ("vin_V", "iin_A", "iout_A", "eff_pct", "p_out_W")}
|
||||||
|
|
||||||
|
if fmt == "sweep":
|
||||||
|
for r in rows:
|
||||||
|
out["vin_V"].append(_f(r, "supply_V"))
|
||||||
|
out["iin_A"].append(_f(r, "supply_I"))
|
||||||
|
out["iout_A"].append(_f(r, "load_I"))
|
||||||
|
out["eff_pct"].append(_f(r, "efficiency"))
|
||||||
|
out["p_out_W"].append(_f(r, "output_power"))
|
||||||
|
return out, "sweep efficiency (HIOKI)"
|
||||||
|
|
||||||
|
if fmt == "telem":
|
||||||
|
# Wire units: vin/vout in mV, currents in mA (iin negative into the
|
||||||
|
# converter); eff net = (P_out - P_sys) / P_in, same as the GUI panel.
|
||||||
|
for r in rows:
|
||||||
|
p_in = _f(r, "p_in_W")
|
||||||
|
p_out = _f(r, "p_out_W")
|
||||||
|
p_sys = _f(r, "vout") * _f(r, "sys_current_ma") / 1e6
|
||||||
|
eff = (p_out - p_sys) / p_in * 100.0 if p_in > MIN_P_IN_W else math.nan
|
||||||
|
out["vin_V"].append(_f(r, "vin") / 1000.0)
|
||||||
|
out["iin_A"].append(-_f(r, "iin") / 1000.0)
|
||||||
|
out["iout_A"].append(_f(r, "iout_slow") / 1000.0)
|
||||||
|
out["eff_pct"].append(eff)
|
||||||
|
out["p_out_W"].append(p_out)
|
||||||
|
return out, "board eff net (iout_slow, -P_sys)"
|
||||||
|
|
||||||
|
# datalog: three consistent (vin, current, eff) triples to choose from
|
||||||
|
if source == "auto":
|
||||||
|
med_eff1 = np.nanmedian([_f(r, "meter_EFF1") for r in rows]) if rows else math.nan
|
||||||
|
med_psup = np.nanmedian([_f(r, "supply_P") for r in rows]) if rows else math.nan
|
||||||
|
if med_eff1 > 1.0:
|
||||||
|
source = "hioki"
|
||||||
|
elif med_psup > MIN_P_IN_W:
|
||||||
|
source = "instr"
|
||||||
|
else:
|
||||||
|
source = "stm"
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
if source == "hioki":
|
||||||
|
out["vin_V"].append(_f(r, "meter_U5"))
|
||||||
|
out["iin_A"].append(_f(r, "meter_I5"))
|
||||||
|
out["iout_A"].append(_f(r, "meter_I6"))
|
||||||
|
out["eff_pct"].append(_f(r, "meter_EFF1"))
|
||||||
|
out["p_out_W"].append(_f(r, "meter_P6"))
|
||||||
|
elif source == "instr":
|
||||||
|
p_sup = _f(r, "supply_P")
|
||||||
|
eff = _f(r, "load_P") / p_sup * 100.0 if p_sup > MIN_P_IN_W else math.nan
|
||||||
|
out["vin_V"].append(_f(r, "supply_V"))
|
||||||
|
out["iin_A"].append(_f(r, "supply_I"))
|
||||||
|
out["iout_A"].append(_f(r, "load_I"))
|
||||||
|
out["eff_pct"].append(eff)
|
||||||
|
out["p_out_W"].append(_f(r, "load_P"))
|
||||||
|
else: # stm
|
||||||
|
out["vin_V"].append(_f(r, "stm_vin_mV") / 1000.0)
|
||||||
|
out["iin_A"].append(-_f(r, "stm_iin_mA") / 1000.0)
|
||||||
|
out["iout_A"].append(_f(r, "stm_iout_slow_mA") / 1000.0)
|
||||||
|
out["eff_pct"].append(_f(r, "stm_eff_net_pct"))
|
||||||
|
out["p_out_W"].append(_f(r, "stm_p_out_W"))
|
||||||
|
labels = {"hioki": "HIOKI EFF1", "instr": "supply/load power",
|
||||||
|
"stm": "board eff net"}
|
||||||
|
return out, labels[source]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="Plot efficiency vs input voltage vs current from bench CSVs.")
|
||||||
|
ap.add_argument("csv", nargs="*",
|
||||||
|
help="logged CSV file(s); a file dialog opens if omitted")
|
||||||
|
ap.add_argument("--current", choices=("iout", "iin"), default="iout",
|
||||||
|
help="current axis: output (default) or input current")
|
||||||
|
ap.add_argument("--source", choices=("auto", "hioki", "instr", "stm"),
|
||||||
|
default="auto",
|
||||||
|
help="efficiency source for GUI data logs (default auto: "
|
||||||
|
"HIOKI if present, else supply/load, else board)")
|
||||||
|
ap.add_argument("--vin-bin", type=float, default=1.0, metavar="V",
|
||||||
|
help="Vin bin width for the per-voltage curves (default 1.0)")
|
||||||
|
ap.add_argument("--min-pout", type=float, default=5.0, metavar="W",
|
||||||
|
help="drop points below this output power (default 5.0)")
|
||||||
|
ap.add_argument("--save", metavar="PNG", help="write the figure instead of showing it")
|
||||||
|
ap.add_argument("--title", default=None, help="figure title override")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not args.csv:
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import filedialog
|
||||||
|
root = tk.Tk()
|
||||||
|
root.withdraw()
|
||||||
|
args.csv = list(filedialog.askopenfilenames(
|
||||||
|
title="Select logged CSV(s) to plot",
|
||||||
|
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")]))
|
||||||
|
root.destroy()
|
||||||
|
if not args.csv:
|
||||||
|
sys.exit("no file selected")
|
||||||
|
|
||||||
|
data: dict[str, list] = {k: [] for k in ("vin_V", "iin_A", "iout_A", "eff_pct", "p_out_W")}
|
||||||
|
labels = set()
|
||||||
|
for path in args.csv:
|
||||||
|
part, label = _extract(path, args.source)
|
||||||
|
for k in data:
|
||||||
|
data[k].extend(part[k])
|
||||||
|
labels.add(label)
|
||||||
|
|
||||||
|
vin = np.asarray(data["vin_V"])
|
||||||
|
cur = np.asarray(data["iin_A" if args.current == "iin" else "iout_A"])
|
||||||
|
eff = np.asarray(data["eff_pct"])
|
||||||
|
pout = np.asarray(data["p_out_W"])
|
||||||
|
|
||||||
|
keep = (np.isfinite(vin) & np.isfinite(cur) & np.isfinite(eff)
|
||||||
|
& (eff > 0.0) & (eff <= 105.0) & (pout >= args.min_pout))
|
||||||
|
n_total = len(vin)
|
||||||
|
vin, cur, eff = vin[keep], cur[keep], eff[keep]
|
||||||
|
if len(vin) == 0:
|
||||||
|
sys.exit(f"no usable points ({n_total} rows read; all filtered — "
|
||||||
|
f"check --min-pout / --source)")
|
||||||
|
|
||||||
|
ipk = int(np.argmax(eff))
|
||||||
|
print(f"{len(vin)} points ({n_total - len(vin)} filtered) | "
|
||||||
|
f"Vin {vin.min():.1f}..{vin.max():.1f} V | "
|
||||||
|
f"I {cur.min():.2f}..{cur.max():.2f} A | "
|
||||||
|
f"peak eff {eff[ipk]:.2f} % @ {vin[ipk]:.1f} V, {cur[ipk]:.2f} A")
|
||||||
|
|
||||||
|
cur_name = "Input current (A)" if args.current == "iin" else "Output current (A)"
|
||||||
|
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13.5, 5.8))
|
||||||
|
fig.suptitle(args.title or f"Efficiency map — {', '.join(sorted(labels))}")
|
||||||
|
|
||||||
|
# Left: operating-point scatter, color = efficiency
|
||||||
|
vmin = np.percentile(eff, 5)
|
||||||
|
sc = ax1.scatter(vin, cur, c=eff, s=14, cmap="viridis",
|
||||||
|
vmin=vmin, vmax=eff.max(), rasterized=True)
|
||||||
|
fig.colorbar(sc, ax=ax1, label="Efficiency (%)")
|
||||||
|
ax1.plot(vin[ipk], cur[ipk], "r*", ms=14, mec="k",
|
||||||
|
label=f"peak {eff[ipk]:.2f} %")
|
||||||
|
ax1.set_xlabel("Input voltage (V)")
|
||||||
|
ax1.set_ylabel(cur_name)
|
||||||
|
ax1.legend(loc="best", fontsize=8)
|
||||||
|
ax1.grid(alpha=0.3)
|
||||||
|
|
||||||
|
# Right: efficiency vs current, one mean curve per Vin bin
|
||||||
|
w = args.vin_bin
|
||||||
|
centers = np.unique(np.round(vin / w) * w)
|
||||||
|
cmap = plt.cm.plasma(np.linspace(0.0, 0.9, len(centers)))
|
||||||
|
for color, c0 in zip(cmap, centers):
|
||||||
|
m = np.abs(vin - c0) <= w / 2
|
||||||
|
if m.sum() < 2:
|
||||||
|
ax2.plot(cur[m], eff[m], "o", color=color, ms=4,
|
||||||
|
label=f"{c0:g} V")
|
||||||
|
continue
|
||||||
|
edges = np.linspace(cur[m].min(), cur[m].max() + 1e-9, 41)
|
||||||
|
idx = np.digitize(cur[m], edges)
|
||||||
|
xs, ys = [], []
|
||||||
|
for b in np.unique(idx):
|
||||||
|
bm = idx == b
|
||||||
|
xs.append(cur[m][bm].mean())
|
||||||
|
ys.append(eff[m][bm].mean())
|
||||||
|
ax2.plot(xs, ys, "-o", color=color, ms=3, lw=1.2, label=f"{c0:g} V")
|
||||||
|
ax2.set_xlabel(cur_name)
|
||||||
|
ax2.set_ylabel("Efficiency (%)")
|
||||||
|
ax2.grid(alpha=0.3)
|
||||||
|
if len(centers) <= 14:
|
||||||
|
ax2.legend(title="Vin bin", fontsize=8, ncols=1 + len(centers) // 8)
|
||||||
|
else:
|
||||||
|
norm = plt.Normalize(centers.min(), centers.max())
|
||||||
|
fig.colorbar(plt.cm.ScalarMappable(norm=norm, cmap="plasma"),
|
||||||
|
ax=ax2, label="Vin bin (V)")
|
||||||
|
|
||||||
|
fig.tight_layout()
|
||||||
|
if args.save:
|
||||||
|
fig.savefig(args.save, dpi=140)
|
||||||
|
print(f"saved: {args.save}")
|
||||||
|
else:
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+329
-212
@@ -1,17 +1,20 @@
|
|||||||
"""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
|
STM32 -> PC is a continuous 114-byte binary broadcast (magic-delimited,
|
||||||
for automated tuning scripts (not a TUI). Mirrors the binary protocol
|
repetition-validated, UART odd parity, ~100 Hz publish rate, each publish
|
||||||
from code64/debug_console/protocol.py (kept in sync with the firmware's
|
repeated ~3-4x at line rate). PC -> STM32 commands remain CRC-16 framed.
|
||||||
debug_protocol.h — CRC-16, 78-byte telemetry, current parameter map).
|
Mirrors code64/debug_console/protocol.py (kept in sync with the firmware's
|
||||||
|
debug_protocol.h).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
import struct
|
import struct
|
||||||
import time
|
import time
|
||||||
|
from collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Iterator, Optional
|
||||||
|
|
||||||
import serial
|
import serial
|
||||||
|
|
||||||
@@ -19,13 +22,13 @@ import serial
|
|||||||
|
|
||||||
SYNC_BYTE = 0xAA
|
SYNC_BYTE = 0xAA
|
||||||
|
|
||||||
CMD_TELEMETRY = 0x01
|
CMD_TELEMETRY = 0x01 # legacy; STM32->PC framed telemetry no longer sent
|
||||||
CMD_PARAM_WRITE = 0x02
|
CMD_PARAM_WRITE = 0x02
|
||||||
CMD_PARAM_WRITE_ACK = 0x03
|
CMD_PARAM_WRITE_ACK = 0x03 # legacy; acks now come via the broadcast round-robin
|
||||||
CMD_PARAM_READ_ALL = 0x04
|
CMD_PARAM_READ_ALL = 0x04 # restarts the broadcast param round-robin cursor
|
||||||
CMD_PARAM_VALUE = 0x05
|
CMD_PARAM_VALUE = 0x05 # legacy; STM32->PC framed replies no longer sent
|
||||||
CMD_PING = 0x10
|
CMD_PING = 0x10
|
||||||
CMD_PONG = 0x11
|
CMD_PONG = 0x11 # legacy; pong now increments a broadcast field
|
||||||
CMD_SHUTDOWN = 0x12 # turn off converter
|
CMD_SHUTDOWN = 0x12 # turn off converter
|
||||||
CMD_RESET = 0x13 # system reset
|
CMD_RESET = 0x13 # system reset
|
||||||
CMD_TEST_50 = 0x14 # 50% duty test mode
|
CMD_TEST_50 = 0x14 # 50% duty test mode
|
||||||
@@ -33,7 +36,8 @@ CMD_RELAY_ON = 0x15 # latch input relay closed (bench test)
|
|||||||
CMD_RELAY_OFF = 0x16 # latch input relay open (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_HOLD_CONVERTER = 0x17 # toggle "hold converter off" (boot guard + disarm trips)
|
||||||
CMD_TOGGLE_PRECHARGE = 0x18 # toggle the precharge FET (bench test)
|
CMD_TOGGLE_PRECHARGE = 0x18 # toggle the precharge FET (bench test)
|
||||||
CMD_ERROR_MSG = 0xE0
|
CMD_CLEAR_FLAGS = 0x19 # clear latched status flags
|
||||||
|
CMD_ERROR_MSG = 0xE0 # legacy; STM32->PC no longer sends framed text
|
||||||
|
|
||||||
PTYPE_FLOAT = 0
|
PTYPE_FLOAT = 0
|
||||||
PTYPE_UINT16 = 1
|
PTYPE_UINT16 = 1
|
||||||
@@ -57,36 +61,116 @@ def crc16(data: bytes) -> int:
|
|||||||
return crc
|
return crc
|
||||||
|
|
||||||
|
|
||||||
# ── Telemetry ────────────────────────────────────────────────────────
|
# ── STM32 -> PC broadcast ────────────────────────────────────────────
|
||||||
|
# A single fixed 114-byte struct streamed continuously (circular DMA). No framing
|
||||||
|
# length, no CRC. Frames are delimited by the 4-byte magic. Integrity = REPETITION
|
||||||
|
# (each `counter` value is re-sent back-to-back, so a corrupt/torn copy differs
|
||||||
|
# from its neighbours) + UART odd parity. See debug_protocol.h BroadcastFrame.
|
||||||
|
|
||||||
|
BCAST_MAGIC = 0xAA55AA55
|
||||||
|
BROADCAST_FMT = "<II16fhHhHHhBBIIIIBBhIHH" # 114 bytes; h after param_type = iin_avg_ma, trailing H = sys_current_ma
|
||||||
|
BROADCAST_SIZE = struct.calcsize(BROADCAST_FMT)
|
||||||
|
MAGIC_BYTES = struct.pack("<I", BCAST_MAGIC)
|
||||||
|
|
||||||
|
# status_flags bit -> human label (matches the firmware FLAG_* defines)
|
||||||
|
FLAG_NAMES = {
|
||||||
|
0: "STARTUP: waiting (Vfly guard)",
|
||||||
|
1: "PRECHARGE TIMEOUT",
|
||||||
|
2: "GUARD: VIN_MAX",
|
||||||
|
3: "GUARD: VIN<VOUT+5V",
|
||||||
|
4: "GUARD: IIN",
|
||||||
|
5: "GUARD: IOUT",
|
||||||
|
6: "GUARD: VFLY",
|
||||||
|
7: "GUARD: ETEMP",
|
||||||
|
8: "GUARD: BTEMP",
|
||||||
|
9: "LIMIT: VOUT_MAX",
|
||||||
|
10: "LIMIT: VIN_SHUTOFF",
|
||||||
|
11: "LIMIT: IIN reverse-feed",
|
||||||
|
12: "LIMIT: IIN_MIN",
|
||||||
|
13: "LIMIT: IOUT_MAX",
|
||||||
|
14: "LIMIT: IOUT_MIN",
|
||||||
|
15: "LIMIT: VFLY_MAX",
|
||||||
|
16: "LIMIT: VIN<VOUT+5V",
|
||||||
|
17: "FMAC OVF",
|
||||||
|
18: "FMAC UNF",
|
||||||
|
19: "FMAC SAT",
|
||||||
|
20: "TEMP: ETEMP",
|
||||||
|
21: "TEMP: BTEMP",
|
||||||
|
22: "HARDFAULT",
|
||||||
|
23: "OCP: VOUT comparator",
|
||||||
|
24: "OCP: IIN comparator",
|
||||||
|
25: "OCP: ILOAD comparator",
|
||||||
|
26: "Clock Security System",
|
||||||
|
27: "Error_Handler reached",
|
||||||
|
28: "OUTPUTS ENABLED (converter went active)",
|
||||||
|
29: "TURNOFF: current-decay timeout (not COMP4)",
|
||||||
|
}
|
||||||
|
# flags that are informational / transient (yellow), the rest are faults (red):
|
||||||
|
# STARTUP_WAITING (bit 0) + all GUARD_* (bits 2..8) + OUTPUTS ENABLED (bit 28)
|
||||||
|
FLAG_INFO_MASK = (1 << 0) | sum(1 << b for b in range(2, 9)) | (1 << 28)
|
||||||
|
|
||||||
|
|
||||||
|
def flags_to_names(flags: int) -> list:
|
||||||
|
return [name for bit, name in FLAG_NAMES.items() if flags & (1 << bit)]
|
||||||
|
|
||||||
|
|
||||||
|
def decode_param_bits(param_type: int, raw: int) -> float:
|
||||||
|
"""Interpret a raw 32-bit param_value from the broadcast per its type."""
|
||||||
|
if param_type == PTYPE_FLOAT:
|
||||||
|
return struct.unpack("<f", struct.pack("<I", raw & 0xFFFFFFFF))[0]
|
||||||
|
if param_type == PTYPE_UINT16:
|
||||||
|
return float(raw & 0xFFFF)
|
||||||
|
if param_type == PTYPE_UINT8:
|
||||||
|
return float(raw & 0xFF)
|
||||||
|
if param_type in (PTYPE_INT32, PTYPE_INT16):
|
||||||
|
return float(struct.unpack("<i", struct.pack("<I", raw & 0xFFFFFFFF))[0])
|
||||||
|
return float(raw)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Telemetry:
|
class BroadcastData:
|
||||||
"""Decoded telemetry packet from the STM32 (78-byte payload)."""
|
"""One decoded broadcast frame — everything the board publishes."""
|
||||||
|
counter: int = 0
|
||||||
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 (fast ADC5; protection only — use iout_slow for power)
|
||||||
vfly: float = 0.0 # mV
|
vfly: float = 0.0 # mV
|
||||||
etemp: float = 0.0 # °C (FET / external)
|
etemp: float = 0.0 # °C (heatsink / external)
|
||||||
btemp: float = 0.0 # °C (board)
|
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_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
|
||||||
mppt_iref: float = 0.0
|
mppt_iref: float = 0.0
|
||||||
mppt_last_vin: float = 0.0
|
mppt_last_vin: float = 0.0
|
||||||
mppt_last_iin: float = 0.0
|
mppt_last_iin: float = 0.0
|
||||||
p_in: float = 0.0
|
p_in: float = 0.0 # board-computed
|
||||||
p_out: float = 0.0
|
p_out: float = 0.0 # board-computed
|
||||||
iout_slow: float = 0.0
|
iout_slow: float = 0.0 # mA (ADC4, PWM-synchronous avg-point)
|
||||||
seq: int = 0
|
last_tmp: int = 0
|
||||||
cmp_inner: int = 0 # HRTIM Timer E CMP1xR (inner pair, T2/T3)
|
VREF: int = 0
|
||||||
|
vfly_correction: int = 0
|
||||||
|
cmp_outer: int = 0 # HRTIM Timer F CMP1xR (outer pair, T1/T4)
|
||||||
|
cmp_inner: int = 0 # HRTIM Timer E CMP1xR (inner pair, T2/T3)
|
||||||
vfly_ofs_applied: int = 0 # master-phase offset last written, signed ticks
|
vfly_ofs_applied: int = 0 # master-phase offset last written, signed ticks
|
||||||
|
ctrl_mode: int = 0 # 0=OFF 1=MPPT 2=CV 3=CC
|
||||||
|
vfly_active: int = 0
|
||||||
|
status_flags: int = 0
|
||||||
|
fmac_sr: int = 0
|
||||||
|
fault_pc: int = 0
|
||||||
|
cfsr: int = 0
|
||||||
|
param_id: int = 0 # broadcast param round-robin
|
||||||
|
param_type: int = 0
|
||||||
|
param_value: int = 0 # raw bits; interpret via decode_param_bits(param_type, ...)
|
||||||
|
pong: int = 0 # increments when the MCU processes CMD_PING
|
||||||
|
sys_current_ma: int = 0 # Vout-rail housekeeping current, mA
|
||||||
|
iin_avg_ma: int = 0 # 8-sample boxcar of iin, mA (the IIN_MAX trip quantity)
|
||||||
timestamp: float = field(default_factory=time.time)
|
timestamp: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def seq(self) -> int: # back-compat for code that used t.seq
|
||||||
|
return self.counter & 0xFFFF
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def vin_V(self) -> float:
|
def vin_V(self) -> float:
|
||||||
return self.vin / 1000.0
|
return self.vin / 1000.0
|
||||||
@@ -103,39 +187,91 @@ class Telemetry:
|
|||||||
def iout_A(self) -> float:
|
def iout_A(self) -> float:
|
||||||
return self.iout / 1000.0
|
return self.iout / 1000.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vfly_V(self) -> float:
|
||||||
|
return self.vfly / 1000.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def power_in_W(self) -> float:
|
def power_in_W(self) -> float:
|
||||||
return self.vin * (-self.iin) / 1e6
|
return self.vin * (-self.iin) / 1e6
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def power_out_W(self) -> float:
|
def power_out_W(self) -> float:
|
||||||
return self.vout * self.iout / 1e6
|
# iout_slow is the accurate output current (fast iout is protection-only)
|
||||||
|
return self.vout * self.iout_slow / 1e6
|
||||||
|
|
||||||
|
@property
|
||||||
|
def power_sys_W(self) -> float:
|
||||||
|
return self.vout * self.sys_current_ma / 1e6
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def efficiency(self) -> float:
|
def efficiency(self) -> float:
|
||||||
|
"""Gross efficiency P_out/P_in (%)."""
|
||||||
p_in = self.power_in_W
|
p_in = self.power_in_W
|
||||||
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
|
||||||
|
|
||||||
|
@property
|
||||||
_TELEM_FMT = "<7f hHhH 6f 3f BxH h" # 78 bytes
|
def efficiency_net(self) -> float:
|
||||||
_TELEM_SIZE = struct.calcsize(_TELEM_FMT)
|
"""Net efficiency (P_out - P_sys)/P_in (%) — self-supply subtracted."""
|
||||||
|
p_in = self.power_in_W
|
||||||
|
if p_in <= 0.1:
|
||||||
|
return 0.0
|
||||||
|
return (self.power_out_W - self.power_sys_W) / p_in * 100.0
|
||||||
|
|
||||||
|
|
||||||
def _decode_telemetry(payload: bytes) -> Optional[Telemetry]:
|
# Back-compat alias: tuner/cli were written against the old Telemetry class.
|
||||||
if len(payload) < _TELEM_SIZE:
|
Telemetry = BroadcastData
|
||||||
return None
|
|
||||||
v = struct.unpack(_TELEM_FMT, payload[:_TELEM_SIZE])
|
|
||||||
return Telemetry(
|
def decode_broadcast(frame: bytes) -> BroadcastData:
|
||||||
vin=v[0], vout=v[1], iin=v[2], iout=v[3], vfly=v[4], etemp=v[5], btemp=v[6],
|
v = struct.unpack(BROADCAST_FMT, frame)
|
||||||
last_tmp=v[7], VREF=v[8], vfly_correction=v[9], cmp_outer=v[10],
|
return BroadcastData(
|
||||||
vfly_integral=v[11], vfly_avg_debug=v[12],
|
counter=v[1],
|
||||||
cc_output_f=v[13], mppt_iref=v[14],
|
vin=v[2], vout=v[3], iin=v[4], iout=v[5], vfly=v[6], etemp=v[7], btemp=v[8],
|
||||||
mppt_last_vin=v[15], mppt_last_iin=v[16],
|
vfly_integral=v[9], vfly_avg_debug=v[10], cc_output_f=v[11],
|
||||||
p_in=v[17], p_out=v[18], iout_slow=v[19],
|
mppt_iref=v[12], mppt_last_vin=v[13], mppt_last_iin=v[14],
|
||||||
seq=v[20], cmp_inner=v[21], vfly_ofs_applied=v[22],
|
p_in=v[15], p_out=v[16], iout_slow=v[17],
|
||||||
|
last_tmp=v[18], VREF=v[19], vfly_correction=v[20],
|
||||||
|
cmp_outer=v[21], cmp_inner=v[22], vfly_ofs_applied=v[23],
|
||||||
|
ctrl_mode=v[24], vfly_active=v[25],
|
||||||
|
status_flags=v[26], fmac_sr=v[27], fault_pc=v[28], cfsr=v[29],
|
||||||
|
param_id=v[30], param_type=v[31], param_value=v[33], pong=v[34],
|
||||||
|
sys_current_ma=v[35], iin_avg_ma=v[32],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BroadcastParser:
|
||||||
|
"""Parse the continuous 114-byte broadcast stream. Resync on the 4-byte magic;
|
||||||
|
validate a frame by REPETITION -- accept only when two consecutive byte-identical
|
||||||
|
copies arrive (a corrupt/torn copy differs from its neighbours -> discarded). No
|
||||||
|
CRC. Re-sends of the same `counter` are normal; dedup on counter downstream."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.buf = bytearray()
|
||||||
|
self._prev = None # previous raw frame awaiting a matching repeat
|
||||||
|
|
||||||
|
def feed(self, data: bytes):
|
||||||
|
self.buf += data
|
||||||
|
while True:
|
||||||
|
i = self.buf.find(MAGIC_BYTES)
|
||||||
|
if i < 0:
|
||||||
|
# no magic yet; keep only a trailing partial-magic (3 bytes)
|
||||||
|
if len(self.buf) > 3:
|
||||||
|
del self.buf[:-3]
|
||||||
|
return
|
||||||
|
if i > 0:
|
||||||
|
del self.buf[:i] # drop junk / dropped-byte shift before magic
|
||||||
|
if len(self.buf) < BROADCAST_SIZE:
|
||||||
|
return # wait for a full frame
|
||||||
|
frame = bytes(self.buf[:BROADCAST_SIZE])
|
||||||
|
del self.buf[:BROADCAST_SIZE]
|
||||||
|
if frame == self._prev:
|
||||||
|
self._prev = None # two identical copies -> accept, need a fresh pair next
|
||||||
|
yield decode_broadcast(frame)
|
||||||
|
else:
|
||||||
|
self._prev = frame # first sighting / differs -> hold, wait for the repeat
|
||||||
|
|
||||||
|
|
||||||
# ── Parameter definitions ────────────────────────────────────────────
|
# ── Parameter definitions ────────────────────────────────────────────
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -196,22 +332,25 @@ PARAMS = [
|
|||||||
ParamDef(0x73, "dither_anear", 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(0x74, "dither_afar", PTYPE_UINT16, "Dither", 716, 6442, ".0f"),
|
||||||
ParamDef(0x75, "dither_dzero", PTYPE_UINT16, "Dither", 716, 6442, ".0f"),
|
ParamDef(0x75, "dither_dzero", PTYPE_UINT16, "Dither", 716, 6442, ".0f"),
|
||||||
|
# ADC calibration
|
||||||
|
ParamDef(0x79, "adc4_trig_phase", PTYPE_UINT16, "ADC", 3, 14313, ".0f"), # HRTIM master CMP3: iout_slow sample instant
|
||||||
|
ParamDef(0x7A, "iin_zero_sum", PTYPE_UINT16, "ADC", 0, 32760, ".0f"), # IIN software zero offset, sum-of-8 counts
|
||||||
]
|
]
|
||||||
|
|
||||||
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}
|
||||||
|
|
||||||
|
|
||||||
# ── Frame building ───────────────────────────────────────────────────
|
# ── Frame building (PC -> STM32 commands, CRC-16 framed) ─────────────
|
||||||
|
|
||||||
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
|
||||||
crc = crc16(frame)
|
crc = crc16(frame)
|
||||||
return frame + bytes([(crc >> 8) & 0xFF, crc & 0xFF]) # big-endian: hi, lo
|
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:
|
||||||
if ptype == PTYPE_FLOAT:
|
if ptype == PTYPE_FLOAT:
|
||||||
val_bytes = struct.pack("<f", float(value))
|
val_bytes = struct.pack("<f", float(value))
|
||||||
elif ptype == PTYPE_UINT16:
|
elif ptype == PTYPE_UINT16:
|
||||||
@@ -225,90 +364,57 @@ def _build_param_write(param_id: int, ptype: int, value) -> bytes:
|
|||||||
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
|
||||||
return _build_frame(CMD_PARAM_WRITE, payload)
|
return build_frame(CMD_PARAM_WRITE, payload)
|
||||||
|
|
||||||
|
|
||||||
def _decode_param_value(payload: bytes) -> Optional[tuple[int, float]]:
|
def build_ping() -> bytes:
|
||||||
if len(payload) < 8:
|
return build_frame(CMD_PING)
|
||||||
return None
|
|
||||||
param_id, ptype = payload[0], payload[1]
|
|
||||||
vb = payload[4:8]
|
|
||||||
if ptype == PTYPE_FLOAT:
|
|
||||||
value = struct.unpack("<f", vb)[0]
|
|
||||||
elif ptype == PTYPE_UINT16:
|
|
||||||
value = float(struct.unpack("<H", vb[:2])[0])
|
|
||||||
elif ptype == PTYPE_UINT8:
|
|
||||||
value = float(vb[0])
|
|
||||||
elif ptype == PTYPE_INT32:
|
|
||||||
value = float(struct.unpack("<i", vb)[0])
|
|
||||||
elif ptype == PTYPE_INT16:
|
|
||||||
value = float(struct.unpack("<i", vb)[0]) # sign-extended 32-bit wire
|
|
||||||
else:
|
|
||||||
value = float(struct.unpack("<I", vb)[0])
|
|
||||||
return (param_id, value)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Frame parser state machine ───────────────────────────────────────
|
def build_shutdown() -> bytes:
|
||||||
|
return build_frame(CMD_SHUTDOWN)
|
||||||
|
|
||||||
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 build_reset() -> bytes:
|
||||||
self.state = self.WAIT_SYNC
|
return build_frame(CMD_RESET)
|
||||||
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:
|
def build_test_50() -> bytes:
|
||||||
if self.state == self.WAIT_SYNC:
|
return build_frame(CMD_TEST_50)
|
||||||
if b == SYNC_BYTE:
|
|
||||||
self.buf = bytearray([b])
|
|
||||||
self.state = self.WAIT_CMD
|
def build_relay_on() -> bytes:
|
||||||
elif self.state == self.WAIT_CMD:
|
return build_frame(CMD_RELAY_ON)
|
||||||
self.cmd = b
|
|
||||||
self.buf.append(b)
|
|
||||||
self.state = self.WAIT_LEN
|
def build_relay_off() -> bytes:
|
||||||
elif self.state == self.WAIT_LEN:
|
return build_frame(CMD_RELAY_OFF)
|
||||||
self.length = b
|
|
||||||
self.buf.append(b)
|
|
||||||
self.payload = bytearray()
|
def build_hold_converter() -> bytes:
|
||||||
self.idx = 0
|
return build_frame(CMD_HOLD_CONVERTER)
|
||||||
if b == 0:
|
|
||||||
self.state = self.WAIT_CRC_HI
|
|
||||||
elif b > 128:
|
def build_toggle_precharge() -> bytes:
|
||||||
self.state = self.WAIT_SYNC
|
return build_frame(CMD_TOGGLE_PRECHARGE)
|
||||||
else:
|
|
||||||
self.state = self.WAIT_PAYLOAD
|
|
||||||
elif self.state == self.WAIT_PAYLOAD:
|
def build_param_read_all() -> bytes:
|
||||||
self.payload.append(b)
|
return build_frame(CMD_PARAM_READ_ALL)
|
||||||
self.buf.append(b)
|
|
||||||
self.idx += 1
|
|
||||||
if self.idx >= self.length:
|
def build_clear_flags() -> bytes:
|
||||||
self.state = self.WAIT_CRC_HI
|
return build_frame(CMD_CLEAR_FLAGS)
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
# ── STM32Link — synchronous serial interface ─────────────────────────
|
# ── STM32Link — synchronous serial interface ─────────────────────────
|
||||||
|
|
||||||
class STM32Link:
|
class STM32Link:
|
||||||
"""Blocking serial link to STM32 debug protocol.
|
"""Blocking serial link to the STM32 debug protocol.
|
||||||
|
|
||||||
|
Telemetry and parameter echoes arrive via the continuous broadcast;
|
||||||
|
commands go out CRC-16 framed. The port MUST be opened with odd
|
||||||
|
parity (8-O-1) or the broadcast never validates.
|
||||||
|
|
||||||
Usage::
|
Usage::
|
||||||
|
|
||||||
@@ -321,9 +427,17 @@ class STM32Link:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, port: str, baudrate: int = 460800, timeout: float = 2.0):
|
def __init__(self, port: str, baudrate: int = 460800, timeout: float = 2.0):
|
||||||
self.ser = serial.Serial(port, baudrate, timeout=timeout)
|
self.timeout = timeout
|
||||||
self._parser = _FrameParser()
|
self.ser = serial.Serial(
|
||||||
self._param_cache: dict[int, float] = {}
|
port, baudrate, timeout=0.1,
|
||||||
|
bytesize=serial.EIGHTBITS,
|
||||||
|
parity=serial.PARITY_ODD,
|
||||||
|
stopbits=serial.STOPBITS_ONE,
|
||||||
|
)
|
||||||
|
self._parser = BroadcastParser()
|
||||||
|
self._params: dict[int, float] = {}
|
||||||
|
self._pending: deque = deque() # decoded frames not yet consumed
|
||||||
|
self._last_counter = -1
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
if self.ser and self.ser.is_open:
|
if self.ser and self.ser.is_open:
|
||||||
@@ -340,92 +454,95 @@ class STM32Link:
|
|||||||
def _send(self, frame: bytes):
|
def _send(self, frame: bytes):
|
||||||
self.ser.write(frame)
|
self.ser.write(frame)
|
||||||
|
|
||||||
def _recv_frames(self, timeout: float = 1.0) -> list[tuple[int, bytes]]:
|
def _drain_serial(self) -> None:
|
||||||
"""Read available data and return decoded frames."""
|
"""Read one serial chunk and queue ALL decoded frames.
|
||||||
frames = []
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
data = self.ser.read(self.ser.in_waiting or 1)
|
|
||||||
if data:
|
|
||||||
for cmd, payload in self._parser.feed(data):
|
|
||||||
frames.append((cmd, payload))
|
|
||||||
if frames:
|
|
||||||
# Drain any remaining data
|
|
||||||
time.sleep(0.02)
|
|
||||||
data = self.ser.read(self.ser.in_waiting)
|
|
||||||
if data:
|
|
||||||
for cmd, payload in self._parser.feed(data):
|
|
||||||
frames.append((cmd, payload))
|
|
||||||
break
|
|
||||||
return frames
|
|
||||||
|
|
||||||
def _wait_for(self, target_cmd: int, timeout: float = 2.0) -> Optional[bytes]:
|
Param round-robin echoes are stashed into self._params in passing
|
||||||
"""Wait for a specific command response, processing others."""
|
(only once counter > 0 — fields are stale during the post-reset window).
|
||||||
deadline = time.monotonic() + timeout
|
"""
|
||||||
while time.monotonic() < deadline:
|
data = self.ser.read(4096)
|
||||||
remaining = deadline - time.monotonic()
|
if not data:
|
||||||
if remaining <= 0:
|
return
|
||||||
break
|
for b in self._parser.feed(data):
|
||||||
data = self.ser.read(self.ser.in_waiting or 1)
|
if b.counter > 0 and b.param_id in PARAM_BY_ID:
|
||||||
if data:
|
self._params[b.param_id] = decode_param_bits(b.param_type, b.param_value)
|
||||||
for cmd, payload in self._parser.feed(data):
|
self._pending.append(b)
|
||||||
if cmd == target_cmd:
|
|
||||||
return payload
|
def _pump(self, deadline: float) -> Iterator[BroadcastData]:
|
||||||
# Cache param values seen in passing
|
"""Yield validated frames until deadline.
|
||||||
if cmd in (CMD_PARAM_VALUE, CMD_PARAM_WRITE_ACK):
|
|
||||||
result = _decode_param_value(payload)
|
Frames are staged through self._pending so nothing is lost when a
|
||||||
if result:
|
caller stops iterating early (generator abandoned mid-chunk).
|
||||||
self._param_cache[result[0]] = result[1]
|
"""
|
||||||
# Cache telemetry too
|
while True:
|
||||||
if cmd == CMD_TELEMETRY:
|
while self._pending:
|
||||||
self._last_telemetry = _decode_telemetry(payload)
|
yield self._pending.popleft()
|
||||||
return None
|
if time.monotonic() >= deadline:
|
||||||
|
return
|
||||||
|
self._drain_serial()
|
||||||
|
|
||||||
# ── Commands ─────────────────────────────────────────────────────
|
# ── Commands ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def ping(self, timeout: float = 2.0) -> bool:
|
def ping(self, timeout: float = 2.0) -> bool:
|
||||||
"""Send PING, return True if PONG received."""
|
"""Send PING, return True when the broadcast pong counter increments."""
|
||||||
self._send(_build_frame(CMD_PING))
|
baseline: Optional[int] = None
|
||||||
return self._wait_for(CMD_PONG, timeout) is not None
|
for b in self._pump(time.monotonic() + min(0.7, timeout)):
|
||||||
|
baseline = b.pong # pong is valid even at counter == 0
|
||||||
|
break
|
||||||
|
if baseline is None:
|
||||||
|
return False # no broadcast at all -> not connected
|
||||||
|
self._send(build_ping())
|
||||||
|
for b in self._pump(time.monotonic() + timeout):
|
||||||
|
if b.pong != baseline:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
"""Command the converter off."""
|
"""Command the converter off."""
|
||||||
self._send(_build_frame(CMD_SHUTDOWN))
|
self._send(build_shutdown())
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
"""Command a system reset."""
|
"""Command a system reset."""
|
||||||
self._send(_build_frame(CMD_RESET))
|
self._send(build_reset())
|
||||||
|
|
||||||
def test_50(self):
|
def test_50(self):
|
||||||
"""Enter 50% duty test mode."""
|
"""Enter 50% duty test mode."""
|
||||||
self._send(_build_frame(CMD_TEST_50))
|
self._send(build_test_50())
|
||||||
|
|
||||||
def relay_on(self):
|
def relay_on(self):
|
||||||
"""Latch the input relay closed (bench test)."""
|
"""Latch the input relay closed (bench test)."""
|
||||||
self._send(_build_frame(CMD_RELAY_ON))
|
self._send(build_relay_on())
|
||||||
|
|
||||||
def relay_off(self):
|
def relay_off(self):
|
||||||
"""Latch the input relay open (bench test)."""
|
"""Latch the input relay open (bench test)."""
|
||||||
self._send(_build_frame(CMD_RELAY_OFF))
|
self._send(build_relay_off())
|
||||||
|
|
||||||
def hold_converter(self):
|
def hold_converter(self):
|
||||||
"""Toggle 'hold converter off' (boot guard + disarm trips)."""
|
"""Toggle 'hold converter off' (boot guard + disarm trips)."""
|
||||||
self._send(_build_frame(CMD_HOLD_CONVERTER))
|
self._send(build_hold_converter())
|
||||||
|
|
||||||
def toggle_precharge(self):
|
def toggle_precharge(self):
|
||||||
"""Toggle the precharge FET (bench test)."""
|
"""Toggle the precharge FET (bench test)."""
|
||||||
self._send(_build_frame(CMD_TOGGLE_PRECHARGE))
|
self._send(build_toggle_precharge())
|
||||||
|
|
||||||
def read_telemetry(self, timeout: float = 2.0) -> Optional[Telemetry]:
|
def clear_flags(self):
|
||||||
"""Wait for next telemetry packet."""
|
"""Clear latched status flags."""
|
||||||
payload = self._wait_for(CMD_TELEMETRY, timeout)
|
self._send(build_clear_flags())
|
||||||
if payload:
|
|
||||||
return _decode_telemetry(payload)
|
# ── Telemetry ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def read_telemetry(self, timeout: float = 2.0) -> Optional[BroadcastData]:
|
||||||
|
"""Return the next FRESH publish (counter-deduped — repeats skipped)."""
|
||||||
|
for b in self._pump(time.monotonic() + timeout):
|
||||||
|
if b.counter > 0 and b.counter != self._last_counter:
|
||||||
|
self._last_counter = b.counter
|
||||||
|
return b
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def read_telemetry_avg(self, n: int = 10, timeout: float = 5.0) -> Optional[Telemetry]:
|
def read_telemetry_avg(self, n: int = 10, timeout: float = 5.0) -> Optional[BroadcastData]:
|
||||||
"""Read n telemetry packets and return the average."""
|
"""Average n fresh publishes (~n/100 s). Float fields are averaged;
|
||||||
samples: list[Telemetry] = []
|
int/flag fields come from the last sample."""
|
||||||
|
samples: list[BroadcastData] = []
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
while len(samples) < n and time.monotonic() < deadline:
|
while len(samples) < n and time.monotonic() < deadline:
|
||||||
t = self.read_telemetry(timeout=deadline - time.monotonic())
|
t = self.read_telemetry(timeout=deadline - time.monotonic())
|
||||||
@@ -433,43 +550,38 @@ class STM32Link:
|
|||||||
samples.append(t)
|
samples.append(t)
|
||||||
if not samples:
|
if not samples:
|
||||||
return None
|
return None
|
||||||
# Average all analog float fields
|
avg = dataclasses.replace(samples[-1])
|
||||||
avg = Telemetry()
|
|
||||||
for attr in ("vin", "vout", "iin", "iout", "vfly", "etemp", "btemp",
|
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", "iout_slow"):
|
"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
|
|
||||||
return avg
|
return avg
|
||||||
|
|
||||||
def request_all_params(self):
|
# ── Parameters ───────────────────────────────────────────────────
|
||||||
"""Request all parameter values from the STM32."""
|
|
||||||
self._send(_build_frame(CMD_PARAM_READ_ALL))
|
|
||||||
|
|
||||||
def read_all_params(self, timeout: float = 3.0) -> dict[str, float]:
|
def request_all_params(self):
|
||||||
"""Request and collect all parameter values."""
|
"""Restart the broadcast param round-robin from the first param."""
|
||||||
self._param_cache.clear()
|
self._send(build_param_read_all())
|
||||||
|
|
||||||
|
def read_all_params(self, timeout: float = 5.0) -> dict[str, float]:
|
||||||
|
"""Collect all parameter values from the broadcast round-robin
|
||||||
|
(one param per publish -> full rotation ~0.4 s at 100 Hz)."""
|
||||||
|
self._params.clear()
|
||||||
self.request_all_params()
|
self.request_all_params()
|
||||||
deadline = time.monotonic() + timeout
|
for _ in self._pump(time.monotonic() + timeout):
|
||||||
while time.monotonic() < deadline:
|
if len(self._params) >= len(PARAMS):
|
||||||
data = self.ser.read(self.ser.in_waiting or 1)
|
break
|
||||||
if data:
|
|
||||||
for cmd, payload in self._parser.feed(data):
|
|
||||||
if cmd == CMD_PARAM_VALUE:
|
|
||||||
result = _decode_param_value(payload)
|
|
||||||
if result:
|
|
||||||
self._param_cache[result[0]] = result[1]
|
|
||||||
time.sleep(0.05)
|
|
||||||
# Convert to name->value
|
|
||||||
return {
|
return {
|
||||||
PARAM_BY_ID[pid].name: val
|
PARAM_BY_ID[pid].name: val
|
||||||
for pid, val in self._param_cache.items()
|
for pid, val in self._params.items()
|
||||||
if pid in PARAM_BY_ID
|
if pid in PARAM_BY_ID
|
||||||
}
|
}
|
||||||
|
|
||||||
def write_param(self, name: str, value: float, wait_ack: bool = True) -> bool:
|
def write_param(self, name: str, value: float, wait_ack: bool = True) -> bool:
|
||||||
"""Write a parameter by name. Returns True if ACK received."""
|
"""Write a parameter by name. The write is acknowledged when the
|
||||||
|
broadcast round-robin echoes the new value back (clamped + formatted
|
||||||
|
the way the firmware reports it); retries until it matches."""
|
||||||
pdef = PARAM_BY_NAME.get(name)
|
pdef = PARAM_BY_NAME.get(name)
|
||||||
if not pdef:
|
if not pdef:
|
||||||
raise ValueError(f"Unknown parameter: {name!r}")
|
raise ValueError(f"Unknown parameter: {name!r}")
|
||||||
@@ -477,17 +589,22 @@ class STM32Link:
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{name}: {value} out of range [{pdef.min_val}, {pdef.max_val}]"
|
f"{name}: {value} out of range [{pdef.min_val}, {pdef.max_val}]"
|
||||||
)
|
)
|
||||||
frame = _build_param_write(pdef.id, pdef.ptype, value)
|
frame = build_param_write(pdef.id, pdef.ptype, value)
|
||||||
self._send(frame)
|
if not wait_ack:
|
||||||
if wait_ack:
|
self._send(frame)
|
||||||
payload = self._wait_for(CMD_PARAM_WRITE_ACK, timeout=2.0)
|
return True
|
||||||
if payload:
|
clamped = max(pdef.min_val, min(pdef.max_val, float(value)))
|
||||||
result = _decode_param_value(payload)
|
expect = f"{clamped:{pdef.fmt}}"
|
||||||
if result:
|
for _ in range(6):
|
||||||
self._param_cache[result[0]] = result[1]
|
self._send(frame)
|
||||||
return True
|
# one full round-robin rotation is ~0.4 s; 0.5 s sees the echo once
|
||||||
return False
|
for b in self._pump(time.monotonic() + 0.5):
|
||||||
return True
|
if b.counter > 0 and b.param_id == pdef.id:
|
||||||
|
echoed = decode_param_bits(b.param_type, b.param_value)
|
||||||
|
if f"{echoed:{pdef.fmt}}" == expect:
|
||||||
|
self._params[pdef.id] = echoed
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def write_param_by_id(self, param_id: int, value: float) -> bool:
|
def write_param_by_id(self, param_id: int, value: float) -> bool:
|
||||||
"""Write a parameter by ID."""
|
"""Write a parameter by ID."""
|
||||||
|
|||||||
+1
-1
@@ -61,7 +61,7 @@ class Tuner:
|
|||||||
self,
|
self,
|
||||||
bench: MPPTTestbench,
|
bench: MPPTTestbench,
|
||||||
link: STM32Link,
|
link: STM32Link,
|
||||||
settle_time: float = 3.0,
|
settle_time: float = 5.0,
|
||||||
stm_avg_samples: int = 10,
|
stm_avg_samples: int = 10,
|
||||||
):
|
):
|
||||||
self.bench = bench
|
self.bench = bench
|
||||||
|
|||||||
Reference in New Issue
Block a user