Compare commits

...
2 Commits
Author SHA1 Message Date
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
3 changed files with 237 additions and 28 deletions
+10 -1
View File
@@ -199,9 +199,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
# 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
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).
+40 -9
View File
@@ -156,6 +156,7 @@ class TestbenchGUI(tk.Tk):
self.bench: MPPTTestbench | None = None
self.worker: InstrumentWorker | None = None
self._teardown_thread: threading.Thread | None = None
self._log_file = None
self._log_writer = None
self._log_count = 0
@@ -1141,6 +1142,10 @@ class TestbenchGUI(tk.Tk):
# ── Connection ────────────────────────────────────────────────────
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:
supply_addr = self._supply_addr.get().strip()
if supply_addr == "auto":
@@ -1193,16 +1198,9 @@ class TestbenchGUI(tk.Tk):
def _disconnect(self) -> None:
self._stop_log()
if self.worker:
self.worker.stop()
self.worker.join(timeout=5)
worker, bench = self.worker, self.bench
self.worker = None
if self.bench:
try:
self.bench.close()
except Exception:
pass
self.bench = None
self.bench = None # _poll chain sees both None and ends
self._btn_connect.config(state=tk.NORMAL)
self._btn_setup.config(state=tk.DISABLED)
@@ -1212,7 +1210,40 @@ class TestbenchGUI(tk.Tk):
self._load_baud.config(state=tk.NORMAL)
self._meter_addr.config(state=tk.NORMAL)
self._status_label.config(text="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:
self._send(Cmd.SETUP_ALL)
+184 -15
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).
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:
bench-plot data_20260703_120000.csv
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
@@ -18,7 +24,9 @@ from __future__ import annotations
import argparse
import csv
import math
import os
import sys
from collections import Counter
import matplotlib.pyplot as plt
import numpy as np
@@ -45,9 +53,29 @@ def _detect_format(header: list[str]) -> str:
return "telem"
if "voltage_set" in cols and "efficiency" in cols:
return "sweep"
if not header:
raise ValueError("empty file (a log that is still being written?)")
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]:
"""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."""
@@ -120,6 +148,110 @@ def _extract(path: str, source: str) -> tuple[dict, str]:
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:
ap = argparse.ArgumentParser(
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)")
ap.add_argument("--save", metavar="PNG", help="write the figure instead of showing it")
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()
if not args.csv:
@@ -144,33 +282,65 @@ def main() -> None:
from tkinter import filedialog
root = tk.Tk()
root.withdraw()
root.attributes("-topmost", True)
args.csv = list(filedialog.askopenfilenames(
parent=root,
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()
# Load each file separately (kept apart for the per-file bin picker)
w = args.vin_bin
parts, labels, n_total, bad = [], set(), 0, []
for path in args.csv:
try:
part, label = _extract(path, args.source)
for k in data:
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)
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"])
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"])
if not parts:
_fatal("none of the selected files were readable:\n" + "\n".join(bad))
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]
# Per-file Vin-bin selection: opens automatically when files overlap
bin_table = [(p["name"], Counter(p["bins"].tolist())) for p in parts]
files_per_bin = Counter(c for _, bins in bin_table for c in bins)
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:
sys.exit(f"no usable points ({n_total} rows read; all filtered "
f"check --min-pout / --source)")
_fatal(f"no usable points ({n_total} rows read; all filtered or "
f"deselected — check --min-pout / --source / bin picker)")
ipk = int(np.argmax(eff))
print(f"{len(vin)} points ({n_total - len(vin)} filtered) | "
@@ -195,7 +365,6 @@ def main() -> None:
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):