diff --git a/HIOKI-3193-10 b/HIOKI-3193-10 index 62f3a36..d1486e9 160000 --- a/HIOKI-3193-10 +++ b/HIOKI-3193-10 @@ -1 +1 @@ -Subproject commit 62f3a3690e559d1dee6ba53ec6b9e82662dc5d04 +Subproject commit d1486e9df1ce05dbf437bfe3baf30d1203b94c6b diff --git a/testbench/bench.py b/testbench/bench.py index d6511a7..b525b33 100644 --- a/testbench/bench.py +++ b/testbench/bench.py @@ -739,10 +739,26 @@ class MPPTTestbench: v += v_step finally: + # Ramp load down gradually to avoid sudden transients + cur_load = ll - l_step if n > 0 else l_start + ramp_steps = max(int(abs(cur_load - l_start) / abs(l_step)), 1) if l_step != 0 else 1 + ramp_steps = min(ramp_steps, 10) # cap at 10 steps + if abs(cur_load) > abs(l_start) and ramp_steps > 1: + decrement = (cur_load - l_start) / ramp_steps + print(f"\n Ramping load down from {cur_load:.1f} to " + f"{l_start:.1f}{unit} in {ramp_steps} steps...") + for i in range(1, ramp_steps + 1): + step_val = cur_load - decrement * i + self._apply_load_value(load_mode, step_val) + time.sleep(0.5) + else: + self._apply_load_value(load_mode, l_start) + time.sleep(0.5) + self.load.load_off() self.supply.set_voltage(IDLE_VOLTAGE) print( - f"\n Load OFF. Supply returning to {IDLE_VOLTAGE:.0f}V " + f" Load OFF. Supply returning to {IDLE_VOLTAGE:.0f}V " f"(output stays ON)" ) diff --git a/testbench/cli.py b/testbench/cli.py index fd35130..9b72ecb 100644 --- a/testbench/cli.py +++ b/testbench/cli.py @@ -550,6 +550,155 @@ def cmd_safe_off(bench: MPPTTestbench, _args: argparse.Namespace) -> None: print("Done.") +# ── Offline plot command (no instruments needed) ───────────────────── + + +def cmd_plot_sweep(_args: argparse.Namespace) -> None: + """Generate analysis plots from a 2D sweep CSV.""" + import numpy as np + import matplotlib.pyplot as plt + from matplotlib.ticker import MaxNLocator + from pathlib import Path + + path = Path(_args.csv) + if not path.exists(): + print(f"File not found: {path}") + sys.exit(1) + + # Read CSV + with open(path) as f: + reader = csv.DictReader(f) + rows = list(reader) + + if not rows: + print("CSV is empty.") + sys.exit(1) + + # Parse data + v_set = np.array([float(r["voltage_set"]) for r in rows]) + load_sp = np.array([float(r["load_setpoint"]) for r in rows]) + p_in = np.array([float(r["input_power"]) for r in rows]) + p_out = np.array([float(r["output_power"]) for r in rows]) + eff = np.array([float(r["efficiency"]) for r in rows]) + + # Auto-detect mode: if load_setpoint values >> typical currents, it's CP + voltages = sorted(set(v_set)) + loads = sorted(set(load_sp)) + is_cp = max(loads) > 100 # heuristic: CP setpoints are in watts + load_unit = "W" if is_cp else "A" + load_label = "Load Power (W)" if is_cp else "Load Current (A)" + mode_str = "CP" if is_cp else "CC" + + # Filter out bogus points (EFF overflow at near-zero power) + valid = (eff > 0) & (eff < 110) & (p_in > 1.0) + v_set_v, load_sp_v, p_in_v, p_out_v, eff_v = ( + v_set[valid], load_sp[valid], p_in[valid], p_out[valid], eff[valid], + ) + + # Best efficiency point + best_idx = np.argmax(eff_v) + best_eff = eff_v[best_idx] + best_v = v_set_v[best_idx] + best_load = load_sp_v[best_idx] + best_pin = p_in_v[best_idx] + best_pout = p_out_v[best_idx] + + print(f"File: {path.name}") + print(f"Mode: {mode_str}, {len(rows)} points, {len(voltages)} voltages × {len(loads)} loads") + print(f"Best efficiency: {best_eff:.2f}%") + print(f" at V_set={best_v:.1f}V, {mode_str}={best_load:.1f}{load_unit}") + print(f" P_in={best_pin:.1f}W, P_out={best_pout:.1f}W") + + # Colour map for voltage lines + cmap = plt.cm.viridis + colours = {v: cmap(i / max(len(voltages) - 1, 1)) for i, v in enumerate(voltages)} + + # ── Figure 1: Efficiency vs Load, one line per voltage ─────────── + fig1, ax1 = plt.subplots(figsize=(12, 7)) + for v in voltages: + mask = (v_set_v == v) + if not np.any(mask): + continue + x, y = load_sp_v[mask], eff_v[mask] + order = np.argsort(x) + ax1.plot(x[order], y[order], "o-", color=colours[v], markersize=3, + label=f"{v:.0f}V") + + # Mark best point + ax1.plot(best_load, best_eff, "*", color="red", markersize=18, zorder=10, + label=f"Best: {best_eff:.2f}% @ {best_v:.0f}V/{best_load:.0f}{load_unit}") + + ax1.set_xlabel(load_label, fontsize=12) + ax1.set_ylabel("Efficiency (%)", fontsize=12) + ax1.set_title(f"MPPT Efficiency vs {mode_str} Load — All Voltages Overlaid", fontsize=14) + ax1.legend(loc="lower right", fontsize=8, ncol=2) + ax1.grid(True, alpha=0.3) + ax1.set_ylim(bottom=max(0, eff_v.min() - 2), top=min(100.5, eff_v.max() + 1)) + fig1.tight_layout() + + # ── Figure 2: Efficiency heatmap ───────────────────────────────── + fig2, ax2 = plt.subplots(figsize=(12, 7)) + v_grid = np.array(sorted(voltages)) + l_grid = np.array(sorted(loads)) + eff_map = np.full((len(v_grid), len(l_grid)), np.nan) + for r in rows: + vi = np.searchsorted(v_grid, float(r["voltage_set"])) + li = np.searchsorted(l_grid, float(r["load_setpoint"])) + e = float(r["efficiency"]) + if 0 < e < 110 and float(r["input_power"]) > 1.0: + if vi < len(v_grid) and li < len(l_grid): + eff_map[vi, li] = e + + im = ax2.pcolormesh(l_grid, v_grid, eff_map, cmap="RdYlGn", shading="nearest") + cb = fig2.colorbar(im, ax=ax2, label="Efficiency (%)") + ax2.plot(best_load, best_v, "*", color="blue", markersize=18, zorder=10) + ax2.annotate(f"{best_eff:.1f}%", (best_load, best_v), + textcoords="offset points", xytext=(10, 10), + fontsize=10, fontweight="bold", color="blue") + ax2.set_xlabel(load_label, fontsize=12) + ax2.set_ylabel("Supply Voltage (V)", fontsize=12) + ax2.set_title(f"Efficiency Map — {mode_str} Sweep", fontsize=14) + fig2.tight_layout() + + # ── Figure 3: Power loss vs Load ───────────────────────────────── + fig3, ax3 = plt.subplots(figsize=(12, 7)) + for v in voltages: + mask = (v_set_v == v) + if not np.any(mask): + continue + x = load_sp_v[mask] + loss = p_in_v[mask] - p_out_v[mask] + order = np.argsort(x) + ax3.plot(x[order], loss[order], "o-", color=colours[v], markersize=3, + label=f"{v:.0f}V") + + ax3.set_xlabel(load_label, fontsize=12) + ax3.set_ylabel("Power Loss (W)", fontsize=12) + ax3.set_title(f"Power Loss vs {mode_str} Load — All Voltages Overlaid", fontsize=14) + ax3.legend(loc="upper left", fontsize=8, ncol=2) + ax3.grid(True, alpha=0.3) + fig3.tight_layout() + + # Save or show + if _args.output_dir: + out = Path(_args.output_dir) + out.mkdir(parents=True, exist_ok=True) + else: + out = path.parent + + stem = path.stem + fig1.savefig(out / f"{stem}_efficiency.png", dpi=150) + fig2.savefig(out / f"{stem}_heatmap.png", dpi=150) + fig3.savefig(out / f"{stem}_loss.png", dpi=150) + print(f"\nSaved plots to {out}:") + print(f" {stem}_efficiency.png") + print(f" {stem}_heatmap.png") + print(f" {stem}_loss.png") + + if not _args.no_show: + plt.show() + + # ── Main ────────────────────────────────────────────────────────────── @@ -575,6 +724,8 @@ examples: %(prog)s load set --mode CC --value 5.0 %(prog)s load on %(prog)s safe-off + %(prog)s plot-sweep sweep_vi_20260312_151212.csv + %(prog)s plot-sweep sweep_vi_20260312_151212.csv --no-show -o plots/ """, ) @@ -694,8 +845,19 @@ examples: # safe-off sub.add_parser("safe-off", help="Emergency: turn off load and supply") + # plot-sweep (offline, no instruments) + p_plot = sub.add_parser("plot-sweep", help="Plot efficiency analysis from sweep CSV (no instruments needed)") + p_plot.add_argument("csv", help="Sweep CSV file to plot") + p_plot.add_argument("-o", "--output-dir", help="Directory for saved plots (default: same as CSV)") + p_plot.add_argument("--no-show", action="store_true", help="Save plots without displaying") + args = parser.parse_args() + # Offline commands (no instruments needed) + if args.command == "plot-sweep": + cmd_plot_sweep(args) + return + dispatch = { "identify": cmd_identify, "setup": cmd_setup, diff --git a/testbench/gui.py b/testbench/gui.py index 5aa2c9a..b924f70 100644 --- a/testbench/gui.py +++ b/testbench/gui.py @@ -410,6 +410,8 @@ class TestbenchGUI(tk.Tk): self._ch5_i_range.pack(side=tk.LEFT, padx=2) ttk.Button(rng5, text="Set", width=3, command=lambda: self._set_range(5, "I", self._ch5_i_range)).pack(side=tk.LEFT) + ttk.Button(rng5, text="Degauss", width=7, + command=lambda: self._send(Cmd.DEGAUSS, [5])).pack(side=tk.LEFT, padx=(8, 0)) self._ch5_range_label = ttk.Label(input_frame, text="V: --- / I: ---", font=("Consolas", 9)) self._ch5_range_label.pack(anchor=tk.W) @@ -437,6 +439,8 @@ class TestbenchGUI(tk.Tk): self._ch6_i_range.pack(side=tk.LEFT, padx=2) ttk.Button(rng6, text="Set", width=3, command=lambda: self._set_range(6, "I", self._ch6_i_range)).pack(side=tk.LEFT) + ttk.Button(rng6, text="Degauss", width=7, + command=lambda: self._send(Cmd.DEGAUSS, [6])).pack(side=tk.LEFT, padx=(8, 0)) self._ch6_range_label = ttk.Label(output_frame, text="V: --- / I: ---", font=("Consolas", 9)) self._ch6_range_label.pack(anchor=tk.W) @@ -570,6 +574,15 @@ class TestbenchGUI(tk.Tk): self._svi_status = ttk.Label(frame, text="Idle", font=("Consolas", 9)) self._svi_status.pack(anchor=tk.W, pady=2) + self._svi_estimate = ttk.Label(frame, text="", font=("Consolas", 9)) + self._svi_estimate.pack(anchor=tk.W) + + # Bind entries to recalculate time estimate on change + for entry in (self._svi_v_start, self._svi_v_stop, self._svi_v_step, + self._svi_l_start, self._svi_l_stop, self._svi_l_step, + self._svi_settle): + entry.bind("", lambda _e: self._update_svi_estimate()) + self._update_svi_estimate() self._svi_thread = None self._svi_stop_event = None @@ -907,6 +920,37 @@ class TestbenchGUI(tk.Tk): self._svi_l_label.config(text="I start:") else: self._svi_l_label.config(text="P start:") + self._update_svi_estimate() + + def _update_svi_estimate(self) -> None: + """Recalculate and display estimated sweep duration.""" + try: + v_start = float(self._svi_v_start.get()) + v_stop = float(self._svi_v_stop.get()) + v_step = abs(float(self._svi_v_step.get())) + l_start = float(self._svi_l_start.get()) + l_stop = float(self._svi_l_stop.get()) + l_step = abs(float(self._svi_l_step.get())) + settle = float(self._svi_settle.get()) + if v_step <= 0 or l_step <= 0: + raise ValueError + v_count = int(abs(v_stop - v_start) / v_step) + 1 + l_count = int(abs(l_stop - l_start) / l_step) + 1 + total = v_count * l_count + secs = total * settle + mins, s = divmod(int(secs), 60) + hrs, m = divmod(mins, 60) + if hrs: + time_str = f"{hrs}h {m:02d}m {s:02d}s" + elif m: + time_str = f"{m}m {s:02d}s" + else: + time_str = f"{s}s" + self._svi_estimate.config( + text=f"{total} points, ~{time_str} (settle only)" + ) + except (ValueError, ZeroDivisionError): + self._svi_estimate.config(text="") def _set_range(self, channel: int, vi: str, combo: ttk.Combobox) -> None: """Set voltage or current range for a HIOKI channel.""" @@ -1286,6 +1330,22 @@ class TestbenchGUI(tk.Tk): ll += l_step v += v_step finally: + # Ramp load down gradually to avoid sudden transients + cur_load = ll - l_step if n > 0 else l_start + ramp_steps = max(int(abs(cur_load - l_start) / abs(l_step)), 1) if l_step != 0 else 1 + ramp_steps = min(ramp_steps, 10) # cap at 10 steps + if abs(cur_load) > abs(l_start) and ramp_steps > 1: + decrement = (cur_load - l_start) / ramp_steps + print(f"Ramping load down from {cur_load:.1f} to " + f"{l_start:.1f}{unit} in {ramp_steps} steps...") + for i in range(1, ramp_steps + 1): + step_val = cur_load - decrement * i + bench._apply_load_value(load_mode, step_val) + time.sleep(0.5) + else: + bench._apply_load_value(load_mode, l_start) + time.sleep(0.5) + bench.load.load_off() bench.supply.set_voltage(IDLE_VOLTAGE) diff --git a/testbench/gui_workers.py b/testbench/gui_workers.py index b869994..3f4bb5a 100644 --- a/testbench/gui_workers.py +++ b/testbench/gui_workers.py @@ -43,6 +43,7 @@ class Cmd(Enum): SET_CURRENT_RANGE = auto() SET_VOLTAGE_AUTO = auto() SET_CURRENT_AUTO = auto() + DEGAUSS = auto() # System SET_INTERVAL = auto() @@ -205,6 +206,10 @@ class InstrumentWorker(threading.Thread): bench.meter.set_voltage_auto(args[0], args[1]) case Cmd.SET_CURRENT_AUTO: bench.meter.set_current_auto(args[0], args[1]) + case Cmd.DEGAUSS: + channels = args[0] if args else [5, 6] + items = ",".join(f"I{ch}" for ch in channels) + bench.meter.write(f":DEMAg {items}") # System case Cmd.SET_INTERVAL: