Compare commits
4
Commits
5b168439e8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
158e1ccf03 | ||
|
|
465455d427 | ||
|
|
f709d634e8 | ||
|
|
ca5f0fe9c2 |
@@ -115,7 +115,17 @@ The GUI provides:
|
|||||||
protection shut the output down), the sweep aborts immediately, reports
|
protection shut the output down), the sweep aborts immediately, reports
|
||||||
which protection fired (OVP / OV / OC / OP / OT), and saves the points
|
which protection fired (OVP / OV / OC / OP / OT), and saves the points
|
||||||
collected so far instead of logging garbage rows for the rest of the
|
collected so far instead of logging garbage rows for the rest of the
|
||||||
grid.
|
grid. A converter-output collapse (load voltage below half the expected
|
||||||
|
Vout while the supply is still up -- the DUT shut itself down) aborts
|
||||||
|
the same way; when the STM32 link is up the abort message names the
|
||||||
|
firmware limit/fault bits from the last frame before the brownout
|
||||||
|
blackout.
|
||||||
|
- Sweep finish estimate: the status line shows `ETA hh:mm (N pts, ~M min
|
||||||
|
left)` next to each measured point. The remaining grid is deterministic
|
||||||
|
(the steps that pass the feasibility gate), but the cost per point is
|
||||||
|
not (settle time + instrument round-trips that vary per setup), so the
|
||||||
|
per-point time is measured as a running average -- thermal-hold pauses
|
||||||
|
excluded -- and extrapolated over the remaining feasible points.
|
||||||
- Load range pinning: a mid-sweep auto-range transition on the Prodigit
|
- Load range pinning: a mid-sweep auto-range transition on the Prodigit
|
||||||
momentarily unloads the converter, so at sweep start the CC range is
|
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
|
pinned to Range II for the whole run (auto-ranging restored after, with
|
||||||
@@ -199,9 +209,18 @@ uv run bench-plot data_20260703_140000.csv
|
|||||||
uv run bench-plot run1_telem.csv run2_telem.csv --vin-bin 2 --save eff.png
|
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),
|
# options: --current iout|iin, --source auto|hioki|instr|stm (data logs),
|
||||||
# --min-pout W (default 5), --vin-bin V (default 1), --save PNG
|
# --min-pout W (default 5), --vin-bin V (default 1), --save PNG,
|
||||||
|
# --pick / --no-pick (per-file Vin-bin checkbox picker)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Multiple files are pooled into one dataset. When two files cover the same
|
||||||
|
Vin bin, a checkbox picker opens automatically -- one row per Vin bin, one
|
||||||
|
column per file, cell numbers showing points per bin, overlapping bins
|
||||||
|
highlighted -- so you decide per bin which file's data to use (e.g. keep the
|
||||||
|
re-measured 72 V column from the new run, the rest from the old one).
|
||||||
|
`--pick` forces the picker even without overlap (useful to cut bad columns
|
||||||
|
from a single file); `--no-pick` skips it for scripted/batch use.
|
||||||
|
|
||||||
Also reachable via the GUI's "Plot Eff..." button (Logging section, opens the
|
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
|
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).
|
(double-click for the dialog, or drag && drop CSV files onto it).
|
||||||
|
|||||||
+136
-13
@@ -29,6 +29,7 @@ from testbench.bench import MPPTTestbench
|
|||||||
from testbench.gui_workers import InstrumentWorker, Cmd, STM32Worker
|
from testbench.gui_workers import InstrumentWorker, Cmd, STM32Worker
|
||||||
from testbench.stm32_link import (
|
from testbench.stm32_link import (
|
||||||
FLAG_NAMES, FLAG_INFO_MASK, build_ping, build_clear_flags,
|
FLAG_NAMES, FLAG_INFO_MASK, build_ping, build_clear_flags,
|
||||||
|
flags_to_names,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -156,6 +157,7 @@ class TestbenchGUI(tk.Tk):
|
|||||||
|
|
||||||
self.bench: MPPTTestbench | None = None
|
self.bench: MPPTTestbench | None = None
|
||||||
self.worker: InstrumentWorker | None = None
|
self.worker: InstrumentWorker | None = None
|
||||||
|
self._teardown_thread: threading.Thread | None = None
|
||||||
self._log_file = None
|
self._log_file = None
|
||||||
self._log_writer = None
|
self._log_writer = None
|
||||||
self._log_count = 0
|
self._log_count = 0
|
||||||
@@ -1141,6 +1143,10 @@ class TestbenchGUI(tk.Tk):
|
|||||||
# ── Connection ────────────────────────────────────────────────────
|
# ── Connection ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _connect(self) -> None:
|
def _connect(self) -> None:
|
||||||
|
if self._teardown_thread and self._teardown_thread.is_alive():
|
||||||
|
self._console("Previous disconnect is still releasing the "
|
||||||
|
"instruments - try again in a few seconds", "warn")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
supply_addr = self._supply_addr.get().strip()
|
supply_addr = self._supply_addr.get().strip()
|
||||||
if supply_addr == "auto":
|
if supply_addr == "auto":
|
||||||
@@ -1193,16 +1199,9 @@ class TestbenchGUI(tk.Tk):
|
|||||||
|
|
||||||
def _disconnect(self) -> None:
|
def _disconnect(self) -> None:
|
||||||
self._stop_log()
|
self._stop_log()
|
||||||
if self.worker:
|
worker, bench = self.worker, self.bench
|
||||||
self.worker.stop()
|
self.worker = None
|
||||||
self.worker.join(timeout=5)
|
self.bench = None # _poll chain sees both None and ends
|
||||||
self.worker = None
|
|
||||||
if self.bench:
|
|
||||||
try:
|
|
||||||
self.bench.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self.bench = None
|
|
||||||
|
|
||||||
self._btn_connect.config(state=tk.NORMAL)
|
self._btn_connect.config(state=tk.NORMAL)
|
||||||
self._btn_setup.config(state=tk.DISABLED)
|
self._btn_setup.config(state=tk.DISABLED)
|
||||||
@@ -1212,7 +1211,40 @@ class TestbenchGUI(tk.Tk):
|
|||||||
self._load_baud.config(state=tk.NORMAL)
|
self._load_baud.config(state=tk.NORMAL)
|
||||||
self._meter_addr.config(state=tk.NORMAL)
|
self._meter_addr.config(state=tk.NORMAL)
|
||||||
self._status_label.config(text="Disconnected")
|
self._status_label.config(text="Disconnected")
|
||||||
self._console("Disconnected")
|
|
||||||
|
if not worker and not bench:
|
||||||
|
self._console("Disconnected")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Instrument teardown runs OFF the GUI thread: with dead/absent
|
||||||
|
# devices every VISA/serial call blocks in multi-second timeouts,
|
||||||
|
# which froze the GUI ("not responding" -> apparent crash) when
|
||||||
|
# this was inline (2026-07-06 15:52). Closing a VISA session
|
||||||
|
# while the worker is still inside a read can also crash NI-VISA
|
||||||
|
# natively, so if the worker won't exit the sessions are left
|
||||||
|
# open -- the OS reclaims them at process exit.
|
||||||
|
def _teardown():
|
||||||
|
def say(msg, tag=None):
|
||||||
|
try:
|
||||||
|
self._console(msg, tag) if tag else self._console(msg)
|
||||||
|
except Exception:
|
||||||
|
pass # window already destroyed
|
||||||
|
if worker:
|
||||||
|
worker.stop()
|
||||||
|
worker.join(timeout=15)
|
||||||
|
if bench:
|
||||||
|
if worker and worker.is_alive():
|
||||||
|
say("Instrument worker stuck in an I/O timeout - "
|
||||||
|
"connections left open (freed on exit)", "warn")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
bench.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
say("Disconnected")
|
||||||
|
|
||||||
|
self._teardown_thread = threading.Thread(target=_teardown, daemon=True)
|
||||||
|
self._teardown_thread.start()
|
||||||
|
|
||||||
def _setup_all(self) -> None:
|
def _setup_all(self) -> None:
|
||||||
self._send(Cmd.SETUP_ALL)
|
self._send(Cmd.SETUP_ALL)
|
||||||
@@ -1715,6 +1747,25 @@ class TestbenchGUI(tk.Tk):
|
|||||||
return None
|
return None
|
||||||
return b.etemp, b.btemp
|
return b.etemp, b.btemp
|
||||||
|
|
||||||
|
def _stm32_fault_note(self) -> str:
|
||||||
|
"""Last STM32 fault flags as a console suffix, '' when unlinked.
|
||||||
|
|
||||||
|
On a converter collapse the MCU (fed from the Vout rail) browns
|
||||||
|
out, so the LAST frame before the blackout usually carries the
|
||||||
|
limit/fault bit that fired -- report it even when stale.
|
||||||
|
"""
|
||||||
|
w = self.stm32
|
||||||
|
if not w:
|
||||||
|
return ""
|
||||||
|
b, wall = w.get_latest()
|
||||||
|
if b is None:
|
||||||
|
return ""
|
||||||
|
names = flags_to_names(b.status_flags & ~FLAG_INFO_MASK)
|
||||||
|
txt = ", ".join(names) if names else "no fault bits"
|
||||||
|
age = time.time() - wall
|
||||||
|
stale = f", {age:.0f}s stale" if age > TELEM_STALE_S else ""
|
||||||
|
return f" [STM32: {txt}{stale}]"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _supply_trip_cause(bench) -> str:
|
def _supply_trip_cause(bench) -> str:
|
||||||
"""Best-effort query of which supply protection fired."""
|
"""Best-effort query of which supply protection fired."""
|
||||||
@@ -1880,6 +1931,43 @@ class TestbenchGUI(tk.Tk):
|
|||||||
self._console(f"Load range readback failed ({e}) - range check "
|
self._console(f"Load range readback failed ({e}) - range check "
|
||||||
f"skipped", "warn")
|
f"skipped", "warn")
|
||||||
|
|
||||||
|
# Finish-time prediction. The grid is deterministic (steps that
|
||||||
|
# pass the feasibility gate), but the cost per point is NOT: it is
|
||||||
|
# settle (known) + instrument round-trips (HIOKI auto-range wait +
|
||||||
|
# VISA latency, varies per instrument setup) + occasional holds.
|
||||||
|
# So the per-point time is MEASURED as a running average and
|
||||||
|
# extrapolated over the remaining feasible grid points.
|
||||||
|
def _in_range(x, stop_, step_):
|
||||||
|
return (x <= stop_ + step_ / 2 if step_ > 0
|
||||||
|
else x >= stop_ + step_ / 2)
|
||||||
|
|
||||||
|
def _gate_ok(ll_, v_):
|
||||||
|
# mirror of the in-loop rejection gate (reads the live
|
||||||
|
# vout_est / range_max / psu_imax)
|
||||||
|
if range_max is not None and ll_ > range_max * 1.001:
|
||||||
|
return False
|
||||||
|
return _est_input_current(load_mode, ll_, v_, vout_est) <= psu_imax
|
||||||
|
|
||||||
|
def _pts_remaining(v_now, ll_next):
|
||||||
|
"""Feasible grid points from (v_now, ll_next) to the end."""
|
||||||
|
if v_step == 0 or l_step == 0:
|
||||||
|
return 0
|
||||||
|
cnt = 0
|
||||||
|
vv, ll_ = v_now, ll_next
|
||||||
|
while _in_range(vv, v_stop, v_step):
|
||||||
|
while _in_range(ll_, l_stop, l_step):
|
||||||
|
if _gate_ok(ll_, vv):
|
||||||
|
cnt += 1
|
||||||
|
ll_ += l_step
|
||||||
|
vv += v_step
|
||||||
|
ll_ = l_start
|
||||||
|
return cnt
|
||||||
|
|
||||||
|
self._console(
|
||||||
|
f"Sweep grid: {_pts_remaining(v_start, l_start)} feasible "
|
||||||
|
f"point(s) planned - finish estimate appears after the first "
|
||||||
|
f"point")
|
||||||
|
|
||||||
bench.supply.set_current(current_limit)
|
bench.supply.set_current(current_limit)
|
||||||
bench.supply.output_on()
|
bench.supply.output_on()
|
||||||
bench._apply_load_value(load_mode, l_start)
|
bench._apply_load_value(load_mode, l_start)
|
||||||
@@ -1889,6 +1977,8 @@ class TestbenchGUI(tk.Tk):
|
|||||||
n = 0
|
n = 0
|
||||||
v = v_start
|
v = v_start
|
||||||
applied = l_start # last load setpoint actually commanded
|
applied = l_start # last load setpoint actually commanded
|
||||||
|
t_pt = None # running avg seconds per accepted point
|
||||||
|
t_last = time.monotonic()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while not stop.is_set():
|
while not stop.is_set():
|
||||||
@@ -1929,9 +2019,11 @@ class TestbenchGUI(tk.Tk):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Pause near the firmware thermal trips (holds at ~1 A)
|
# Pause near the firmware thermal trips (holds at ~1 A)
|
||||||
|
t0_hold = time.monotonic()
|
||||||
held = self._thermal_hold(bench, load_mode, vout_est, stop)
|
held = self._thermal_hold(bench, load_mode, vout_est, stop)
|
||||||
if held is not None:
|
if held is not None:
|
||||||
applied = held
|
applied = held
|
||||||
|
t_last += time.monotonic() - t0_hold # ETA: skip hold
|
||||||
if stop.is_set():
|
if stop.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -1959,6 +2051,23 @@ class TestbenchGUI(tk.Tk):
|
|||||||
f"{n} collected point(s)", "error")
|
f"{n} collected point(s)", "error")
|
||||||
stop.set()
|
stop.set()
|
||||||
break
|
break
|
||||||
|
# Converter-output collapse abort: the DUT shut itself
|
||||||
|
# down (firmware limit / fault) while the supply stayed
|
||||||
|
# up -- the supply-side check above never sees this
|
||||||
|
# (run 2026-07-07: Vout 48->3V at Vin=55V and the sweep
|
||||||
|
# kept stepping the rest of the grid).
|
||||||
|
if point.load_voltage < vout_est * 0.5:
|
||||||
|
results.pop() # this point is garbage
|
||||||
|
n -= 1
|
||||||
|
self._console(
|
||||||
|
f"Converter output collapsed at V={v:g}V "
|
||||||
|
f"{load_mode}={ll:g}{unit}: Vout "
|
||||||
|
f"{point.load_voltage:.1f}V, expected ~"
|
||||||
|
f"{vout_est:.0f}V{self._stm32_fault_note()} - "
|
||||||
|
f"aborting sweep, saving {n} collected "
|
||||||
|
f"point(s)", "error")
|
||||||
|
stop.set()
|
||||||
|
break
|
||||||
if point.load_voltage > 5.0:
|
if point.load_voltage > 5.0:
|
||||||
vout_est = point.load_voltage
|
vout_est = point.load_voltage
|
||||||
# Measured backstop: the estimate can be off (eff, Vout)
|
# Measured backstop: the estimate can be off (eff, Vout)
|
||||||
@@ -1980,6 +2089,17 @@ class TestbenchGUI(tk.Tk):
|
|||||||
ll += l_step
|
ll += l_step
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Update the finish-time estimate from this point's
|
||||||
|
# measured wall time (thermal holds already excluded)
|
||||||
|
now_m = time.monotonic()
|
||||||
|
dt_pt = now_m - t_last
|
||||||
|
t_last = now_m
|
||||||
|
t_pt = (dt_pt if t_pt is None
|
||||||
|
else 0.7 * t_pt + 0.3 * dt_pt)
|
||||||
|
rem = _pts_remaining(v, ll + l_step)
|
||||||
|
eta = time.strftime(
|
||||||
|
"%H:%M", time.localtime(time.time() + rem * t_pt))
|
||||||
|
|
||||||
# Push data for live graph/readout updates
|
# Push data for live graph/readout updates
|
||||||
gui_data = {
|
gui_data = {
|
||||||
"supply_V": point.supply_voltage,
|
"supply_V": point.supply_voltage,
|
||||||
@@ -2009,11 +2129,14 @@ class TestbenchGUI(tk.Tk):
|
|||||||
pass
|
pass
|
||||||
self._sweep_data_queue.put_nowait(gui_data)
|
self._sweep_data_queue.put_nowait(gui_data)
|
||||||
|
|
||||||
|
mins = rem * t_pt / 60.0
|
||||||
self.after(
|
self.after(
|
||||||
0,
|
0,
|
||||||
lambda v=v, ll=ll, pt=point, n=n: self._svi_status.config(
|
lambda v=v, ll=ll, pt=point, n=n, eta=eta, rem=rem,
|
||||||
|
mins=mins: self._svi_status.config(
|
||||||
text=f"[{n}] V={v:.1f}V {load_mode}={ll:.1f}{unit} "
|
text=f"[{n}] V={v:.1f}V {load_mode}={ll:.1f}{unit} "
|
||||||
f"EFF={pt.efficiency:.1f}%"
|
f"EFF={pt.efficiency:.1f}% ETA {eta} "
|
||||||
|
f"({rem} pts, ~{mins:.0f} min left)"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+185
-16
@@ -8,9 +8,15 @@ Auto-detects the three CSV formats produced by the tooling:
|
|||||||
Left panel: operating-point scatter (x = Vin, y = current, color = efficiency).
|
Left panel: operating-point scatter (x = Vin, y = current, color = efficiency).
|
||||||
Right panel: efficiency vs current, one curve per Vin bin.
|
Right panel: efficiency vs current, one curve per Vin bin.
|
||||||
|
|
||||||
|
Multiple files are pooled into one dataset. When several files contain the
|
||||||
|
same Vin bin, a checkbox picker opens (one row per bin, one column per file)
|
||||||
|
so you can choose which file's data to use per bin; force it with --pick or
|
||||||
|
suppress it with --no-pick.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
bench-plot data_20260703_120000.csv
|
bench-plot data_20260703_120000.csv
|
||||||
bench-plot run_telem.csv another_telem.csv --current iin --save eff.png
|
bench-plot run_telem.csv another_telem.csv --current iin --save eff.png
|
||||||
|
bench-plot sweep_a.csv sweep_b.csv --pick
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -18,7 +24,9 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import csv
|
import csv
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -45,9 +53,29 @@ def _detect_format(header: list[str]) -> str:
|
|||||||
return "telem"
|
return "telem"
|
||||||
if "voltage_set" in cols and "efficiency" in cols:
|
if "voltage_set" in cols and "efficiency" in cols:
|
||||||
return "sweep"
|
return "sweep"
|
||||||
|
if not header:
|
||||||
|
raise ValueError("empty file (a log that is still being written?)")
|
||||||
raise ValueError(f"unrecognized CSV header: {header[:6]}...")
|
raise ValueError(f"unrecognized CSV header: {header[:6]}...")
|
||||||
|
|
||||||
|
|
||||||
|
def _fatal(msg: str) -> None:
|
||||||
|
"""Print the error AND show it in a messagebox: when spawned from the
|
||||||
|
GUI button nobody sees stderr, so exiting silently looks like a no-op."""
|
||||||
|
print(msg, file=sys.stderr)
|
||||||
|
if not os.environ.get("BENCH_PLOT_HEADLESS"):
|
||||||
|
try:
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import messagebox
|
||||||
|
r = tk.Tk()
|
||||||
|
r.withdraw()
|
||||||
|
r.attributes("-topmost", True)
|
||||||
|
messagebox.showerror("bench-plot", msg, parent=r)
|
||||||
|
r.destroy()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def _extract(path: str, source: str) -> tuple[dict, str]:
|
def _extract(path: str, source: str) -> tuple[dict, str]:
|
||||||
"""Read one CSV -> dict of float lists (vin_V, iin_A, iout_A, eff_pct,
|
"""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."""
|
p_out_W) plus the efficiency-source label actually used."""
|
||||||
@@ -120,6 +148,110 @@ def _extract(path: str, source: str) -> tuple[dict, str]:
|
|||||||
return out, labels[source]
|
return out, labels[source]
|
||||||
|
|
||||||
|
|
||||||
|
class _BinPicker:
|
||||||
|
"""Checkbox grid: one row per Vin bin, one column per file.
|
||||||
|
|
||||||
|
result is a list of selected-center sets (aligned with bin_table)
|
||||||
|
after OK, or None if the window was cancelled/closed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ROW_H = 26
|
||||||
|
|
||||||
|
def __init__(self, bin_table: list[tuple[str, dict]], w: float):
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk
|
||||||
|
self._tk = tk
|
||||||
|
self.root = tk.Tk()
|
||||||
|
self.root.title(f"Select Vin bins per file ({w:g} V bins)")
|
||||||
|
# Spawned from the GUI's Plot Eff button, Windows denies foreground
|
||||||
|
# to the new process (the user is mid-click in the GUI) and the
|
||||||
|
# picker opens hidden BEHIND it -- keep this short-lived dialog
|
||||||
|
# on top and grab focus.
|
||||||
|
self.root.lift()
|
||||||
|
self.root.attributes("-topmost", True)
|
||||||
|
self.root.focus_force()
|
||||||
|
self._n_files = len(bin_table)
|
||||||
|
self._vars: dict[tuple[int, float], object] = {}
|
||||||
|
self.result: list[set] | None = None
|
||||||
|
|
||||||
|
centers = sorted({c for _, bins in bin_table for c in bins})
|
||||||
|
shared = {c for c in centers
|
||||||
|
if sum(c in bins for _, bins in bin_table) > 1}
|
||||||
|
|
||||||
|
top = ttk.Frame(self.root, padding=6)
|
||||||
|
top.pack(fill="both", expand=True)
|
||||||
|
canvas = tk.Canvas(
|
||||||
|
top, highlightthickness=0,
|
||||||
|
height=min(self.ROW_H * (len(centers) + 2) + 8, 560))
|
||||||
|
vsb = ttk.Scrollbar(top, orient="vertical", command=canvas.yview)
|
||||||
|
grid = ttk.Frame(canvas)
|
||||||
|
grid.bind("<Configure>",
|
||||||
|
lambda e: canvas.configure(scrollregion=canvas.bbox("all"),
|
||||||
|
width=grid.winfo_reqwidth()))
|
||||||
|
canvas.create_window((0, 0), window=grid, anchor="nw")
|
||||||
|
canvas.configure(yscrollcommand=vsb.set)
|
||||||
|
canvas.pack(side="left", fill="both", expand=True)
|
||||||
|
vsb.pack(side="right", fill="y")
|
||||||
|
canvas.bind_all("<MouseWheel>", lambda e: canvas.yview_scroll(
|
||||||
|
-1 if e.delta > 0 else 1, "units"))
|
||||||
|
|
||||||
|
ttk.Label(grid, text="Vin bin").grid(row=0, column=0, padx=6, sticky="w")
|
||||||
|
for fi, (name, _bins) in enumerate(bin_table):
|
||||||
|
short = name if len(name) <= 26 else "..." + name[-23:]
|
||||||
|
ttk.Label(grid, text=short).grid(row=0, column=1 + fi, padx=8)
|
||||||
|
ttk.Button(grid, text="all/none", width=8,
|
||||||
|
command=lambda fi=fi: self._toggle_col(fi)
|
||||||
|
).grid(row=1, column=1 + fi, padx=8)
|
||||||
|
for ri, c in enumerate(centers):
|
||||||
|
lbl = ttk.Label(grid, text=f"{c:g} V")
|
||||||
|
if c in shared:
|
||||||
|
lbl.configure(foreground="#cc6600")
|
||||||
|
lbl.grid(row=2 + ri, column=0, padx=6, sticky="w")
|
||||||
|
for fi, (_name, bins) in enumerate(bin_table):
|
||||||
|
if c not in bins:
|
||||||
|
continue
|
||||||
|
var = tk.BooleanVar(self.root, value=True)
|
||||||
|
self._vars[(fi, c)] = var
|
||||||
|
ttk.Checkbutton(grid, text=str(bins[c]), variable=var
|
||||||
|
).grid(row=2 + ri, column=1 + fi,
|
||||||
|
padx=8, sticky="w")
|
||||||
|
|
||||||
|
bar = ttk.Frame(self.root, padding=(6, 0, 6, 6))
|
||||||
|
bar.pack(fill="x")
|
||||||
|
ttk.Label(bar, text="numbers = points per bin, "
|
||||||
|
"orange = bin present in several files"
|
||||||
|
).pack(side="left")
|
||||||
|
ttk.Button(bar, text="Cancel",
|
||||||
|
command=self.root.destroy).pack(side="right", padx=4)
|
||||||
|
ttk.Button(bar, text="Plot", command=self._ok).pack(side="right")
|
||||||
|
|
||||||
|
def _toggle_col(self, fi: int) -> None:
|
||||||
|
cells = [v for (f, _c), v in self._vars.items() if f == fi]
|
||||||
|
state = not all(v.get() for v in cells)
|
||||||
|
for v in cells:
|
||||||
|
v.set(state)
|
||||||
|
|
||||||
|
def _ok(self) -> None:
|
||||||
|
self.result = [
|
||||||
|
{c for (f, c), v in self._vars.items() if f == fi and v.get()}
|
||||||
|
for fi in range(self._n_files)]
|
||||||
|
self.root.destroy()
|
||||||
|
|
||||||
|
def run(self) -> list[set] | None:
|
||||||
|
self.root.mainloop()
|
||||||
|
return self.result
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_bins(bin_table: list[tuple[str, dict]], w: float) -> list[set] | None:
|
||||||
|
"""Show the picker; on a headless/Tk failure fall back to everything."""
|
||||||
|
try:
|
||||||
|
picker = _BinPicker(bin_table, w)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"bin picker unavailable ({e}) - using all bins")
|
||||||
|
return [set(bins) for _, bins in bin_table]
|
||||||
|
return picker.run()
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
ap = argparse.ArgumentParser(
|
ap = argparse.ArgumentParser(
|
||||||
description="Plot efficiency vs input voltage vs current from bench CSVs.")
|
description="Plot efficiency vs input voltage vs current from bench CSVs.")
|
||||||
@@ -137,6 +269,12 @@ def main() -> None:
|
|||||||
help="drop points below this output power (default 5.0)")
|
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("--save", metavar="PNG", help="write the figure instead of showing it")
|
||||||
ap.add_argument("--title", default=None, help="figure title override")
|
ap.add_argument("--title", default=None, help="figure title override")
|
||||||
|
g = ap.add_mutually_exclusive_group()
|
||||||
|
g.add_argument("--pick", action="store_true",
|
||||||
|
help="always show the per-file Vin-bin checkbox picker")
|
||||||
|
g.add_argument("--no-pick", action="store_true",
|
||||||
|
help="never show the picker (default: it opens when "
|
||||||
|
"several files contain the same Vin bin)")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
if not args.csv:
|
if not args.csv:
|
||||||
@@ -144,33 +282,65 @@ def main() -> None:
|
|||||||
from tkinter import filedialog
|
from tkinter import filedialog
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
root.withdraw()
|
root.withdraw()
|
||||||
|
root.attributes("-topmost", True)
|
||||||
args.csv = list(filedialog.askopenfilenames(
|
args.csv = list(filedialog.askopenfilenames(
|
||||||
|
parent=root,
|
||||||
title="Select logged CSV(s) to plot",
|
title="Select logged CSV(s) to plot",
|
||||||
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")]))
|
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")]))
|
||||||
root.destroy()
|
root.destroy()
|
||||||
if not args.csv:
|
if not args.csv:
|
||||||
sys.exit("no file selected")
|
sys.exit("no file selected")
|
||||||
|
|
||||||
data: dict[str, list] = {k: [] for k in ("vin_V", "iin_A", "iout_A", "eff_pct", "p_out_W")}
|
# Load each file separately (kept apart for the per-file bin picker)
|
||||||
labels = set()
|
w = args.vin_bin
|
||||||
|
parts, labels, n_total, bad = [], set(), 0, []
|
||||||
for path in args.csv:
|
for path in args.csv:
|
||||||
part, label = _extract(path, args.source)
|
try:
|
||||||
for k in data:
|
part, label = _extract(path, args.source)
|
||||||
data[k].extend(part[k])
|
except Exception as e:
|
||||||
|
bad.append(f"{os.path.basename(path)}: {e}")
|
||||||
|
print(f" skipping {os.path.basename(path)}: {e}")
|
||||||
|
continue
|
||||||
labels.add(label)
|
labels.add(label)
|
||||||
|
fvin = np.asarray(part["vin_V"])
|
||||||
|
fcur = np.asarray(part["iin_A" if args.current == "iin" else "iout_A"])
|
||||||
|
feff = np.asarray(part["eff_pct"])
|
||||||
|
fpout = np.asarray(part["p_out_W"])
|
||||||
|
n_total += len(fvin)
|
||||||
|
keep = (np.isfinite(fvin) & np.isfinite(fcur) & np.isfinite(feff)
|
||||||
|
& (feff > 0.0) & (feff <= 105.0) & (fpout >= args.min_pout))
|
||||||
|
p = {"name": os.path.basename(path), "vin": fvin[keep],
|
||||||
|
"cur": fcur[keep], "eff": feff[keep]}
|
||||||
|
p["bins"] = np.round(p["vin"] / w) * w
|
||||||
|
parts.append(p)
|
||||||
|
if len(args.csv) > 1:
|
||||||
|
rng = (f"Vin bins {p['bins'].min():g}..{p['bins'].max():g} V"
|
||||||
|
if len(p["vin"]) else "no usable points")
|
||||||
|
print(f" {p['name']}: {len(p['vin'])} pts, {rng}")
|
||||||
|
|
||||||
vin = np.asarray(data["vin_V"])
|
if not parts:
|
||||||
cur = np.asarray(data["iin_A" if args.current == "iin" else "iout_A"])
|
_fatal("none of the selected files were readable:\n" + "\n".join(bad))
|
||||||
eff = np.asarray(data["eff_pct"])
|
|
||||||
pout = np.asarray(data["p_out_W"])
|
|
||||||
|
|
||||||
keep = (np.isfinite(vin) & np.isfinite(cur) & np.isfinite(eff)
|
# Per-file Vin-bin selection: opens automatically when files overlap
|
||||||
& (eff > 0.0) & (eff <= 105.0) & (pout >= args.min_pout))
|
bin_table = [(p["name"], Counter(p["bins"].tolist())) for p in parts]
|
||||||
n_total = len(vin)
|
files_per_bin = Counter(c for _, bins in bin_table for c in bins)
|
||||||
vin, cur, eff = vin[keep], cur[keep], eff[keep]
|
overlap = any(n > 1 for n in files_per_bin.values())
|
||||||
|
if not args.no_pick and (args.pick or (overlap and len(parts) > 1)):
|
||||||
|
sels = _pick_bins(bin_table, w)
|
||||||
|
if sels is None:
|
||||||
|
sys.exit("cancelled")
|
||||||
|
for p, sel in zip(parts, sels):
|
||||||
|
m = (np.isin(p["bins"], sorted(sel)) if sel
|
||||||
|
else np.zeros(len(p["bins"]), dtype=bool))
|
||||||
|
for k in ("vin", "cur", "eff", "bins"):
|
||||||
|
p[k] = p[k][m]
|
||||||
|
|
||||||
|
vin = np.concatenate([p["vin"] for p in parts])
|
||||||
|
cur = np.concatenate([p["cur"] for p in parts])
|
||||||
|
eff = np.concatenate([p["eff"] for p in parts])
|
||||||
if len(vin) == 0:
|
if len(vin) == 0:
|
||||||
sys.exit(f"no usable points ({n_total} rows read; all filtered — "
|
_fatal(f"no usable points ({n_total} rows read; all filtered or "
|
||||||
f"check --min-pout / --source)")
|
f"deselected — check --min-pout / --source / bin picker)")
|
||||||
|
|
||||||
ipk = int(np.argmax(eff))
|
ipk = int(np.argmax(eff))
|
||||||
print(f"{len(vin)} points ({n_total - len(vin)} filtered) | "
|
print(f"{len(vin)} points ({n_total - len(vin)} filtered) | "
|
||||||
@@ -195,7 +365,6 @@ def main() -> None:
|
|||||||
ax1.grid(alpha=0.3)
|
ax1.grid(alpha=0.3)
|
||||||
|
|
||||||
# Right: efficiency vs current, one mean curve per Vin bin
|
# Right: efficiency vs current, one mean curve per Vin bin
|
||||||
w = args.vin_bin
|
|
||||||
centers = np.unique(np.round(vin / w) * w)
|
centers = np.unique(np.round(vin / w) * w)
|
||||||
cmap = plt.cm.plasma(np.linspace(0.0, 0.9, len(centers)))
|
cmap = plt.cm.plasma(np.linspace(0.0, 0.9, len(centers)))
|
||||||
for color, c0 in zip(cmap, centers):
|
for color, c0 in zip(cmap, centers):
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ class BroadcastData:
|
|||||||
param_value: int = 0 # raw bits; interpret via decode_param_bits(param_type, ...)
|
param_value: int = 0 # raw bits; interpret via decode_param_bits(param_type, ...)
|
||||||
pong: int = 0 # increments when the MCU processes CMD_PING
|
pong: int = 0 # increments when the MCU processes CMD_PING
|
||||||
sys_current_ma: int = 0 # Vout-rail housekeeping current, mA
|
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)
|
iin_avg_ma: int = 0 # 8-sample boxcar of iin, mA (the IIN_MAX/IIN_MIN trip quantity)
|
||||||
timestamp: float = field(default_factory=time.time)
|
timestamp: float = field(default_factory=time.time)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
Reference in New Issue
Block a user