diff --git a/README.md b/README.md index 25a9622..609bad3 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/testbench/plot_eff.py b/testbench/plot_eff.py index 54a11a1..97013d4 100644 --- a/testbench/plot_eff.py +++ b/testbench/plot_eff.py @@ -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("", + 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("", 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: - part, label = _extract(path, args.source) - for k in data: - data[k].extend(part[k]) + try: + part, label = _extract(path, args.source) + 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):