Compare commits

...
4 Commits
Author SHA1 Message Date
janikandClaude Fable 5 158e1ccf03 stm32_link: sync iin_avg_ma comment with live firmware protocol
Firmware now runs BOTH iin limits (IIN_MAX reverse-feed and the IIN_MIN
forward OCP, raised to -47A) on the 8-sample boxcar; wire format is
unchanged (verified field-by-field against the live code64 console,
including a synthetic-frame decode). Note the field is a raw int16 mA
cast and wraps at +-32.768A (observed +26.5A at a true -39.0A) -- the
float iin field is the honest monitor above that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:06:00 +07:00
janikandClaude Fable 5 465455d427 GUI sweep: converter-collapse abort + measured finish-time estimate
The mid-sweep abort only watched the supply readback (~0V = supply
protection tripped), so a DUT-side collapse sailed past it: run
2026-07-07 10:58, Vout 48->3.1V at Vin=55V with the supply still up at
55V/0A, sweep kept stepping the 75V column through garbage. Add a
second abort when the load voltage drops below half the expected Vout;
the message names the STM32 limit/fault bits from the last frame
before the brownout blackout (the MCU rides the Vout rail).

Status line now appends ETA hh:mm (N pts, ~M min left): remaining
grid is deterministic (feasibility-gated steps), per-point cost is not
(instrument round-trips vary per setup), so it is a running average of
measured point times (thermal holds excluded) extrapolated over the
remaining feasible points. Grid size is printed at sweep start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:39:48 +07:00
janikandClaude Fable 5 f709d634e8 bench-plot: per-file Vin-bin checkbox picker for combining runs
Combining multiple CSVs pooled everything blindly; when two runs cover
the same Vin bin the user now gets a checkbox grid (row per Vin bin,
column per file, counts per cell, overlapping bins highlighted) to
decide per bin which file contributes. Opens automatically on overlap;
--pick forces it (also useful to cut bad columns from a single file),
--no-pick suppresses it for scripted use.

Also: picker/file-dialog windows force themselves to the foreground
(spawned from the GUI button, Windows opened them hidden behind the
GUI); unreadable or still-being-written (empty) CSVs are skipped with a
note instead of killing the run; fatal errors show a messagebox since
stderr is invisible when launched from the GUI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:59:34 +07:00
janikandClaude Fable 5 ca5f0fe9c2 GUI: non-blocking disconnect, never close VISA sessions under the worker
Pressing Disconnect with the instruments powered off froze the GUI
(apparent crash, 2026-07-06 15:52): worker.join(5) expired while the
worker sat in multi-second VISA timeouts, then bench.close() ran on the
GUI thread -- each driver close() first writes local() to the dead
device (stacked timeouts) and closing a session under an in-flight read
can also take NI-VISA down natively.

_disconnect now detaches the UI immediately and runs all instrument
teardown on a background thread; if the worker is still stuck in I/O
after a 15s join the sessions are deliberately left open (OS reclaims
them at exit) instead of being closed under a live read. Reconnecting
while a teardown is still releasing the instruments is refused with a
console hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:59:20 +07:00
4 changed files with 343 additions and 32 deletions
+21 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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):
+1 -1
View File
@@ -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