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 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).
+40 -9
View File
@@ -156,6 +156,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 +1142,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 +1198,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.join(timeout=5)
self.worker = None self.worker = None
if self.bench: self.bench = None # _poll chain sees both None and ends
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 +1210,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")
if not worker and not bench:
self._console("Disconnected") 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)
+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). 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:
try:
part, label = _extract(path, args.source) part, label = _extract(path, args.source)
for k in data: except Exception as e:
data[k].extend(part[k]) 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):