From ce493204cbdc4c02462f9a944cfef8bc266dd3b7 Mon Sep 17 00:00:00 2001 From: grabowski Date: Fri, 13 Mar 2026 11:40:42 +0700 Subject: [PATCH] Add plot-tune command for multi-voltage parameter sweep analysis Overlays efficiency curves from multiple CSV files (one per voltage), finds per-voltage best and overall sweetspot (best average across all voltages), and generates efficiency overlay, heatmap, and loss plots. Co-Authored-By: Claude Opus 4.6 --- testbench/cli.py | 206 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) diff --git a/testbench/cli.py b/testbench/cli.py index e4b9754..a6e9fd3 100644 --- a/testbench/cli.py +++ b/testbench/cli.py @@ -817,6 +817,203 @@ def cmd_plot_sweep(_args: argparse.Namespace) -> None: plt.show() +def cmd_plot_tune(_args: argparse.Namespace) -> None: + """Analyze multiple tuning CSVs: overlay efficiency vs parameter, find sweetspot.""" + import numpy as np + import matplotlib.pyplot as plt + from pathlib import Path + + csv_files = _args.csv + if not csv_files: + print("No CSV files specified.") + sys.exit(1) + + # Load all CSVs + all_rows = [] + for f in csv_files: + p = Path(f) + if not p.exists(): + print(f"File not found: {p}") + sys.exit(1) + with open(p) as fh: + reader = csv.DictReader(fh) + rows = list(reader) + print(f" {p.name}: {len(rows)} points") + all_rows.extend(rows) + + if not all_rows: + print("No data.") + sys.exit(1) + + param_name = all_rows[0]["param_name"] + param_vals = np.array([float(r["param_value"]) for r in all_rows]) + voltages_arr = np.array([float(r["voltage_set"]) for r in all_rows]) + eff_hioki = np.array([float(r["meter_eff"]) for r in all_rows]) + eff_stm = np.array([float(r["stm_eff"]) for r in all_rows]) + temp = np.array([float(r["stm_etemp"]) for r in all_rows]) + pin = np.array([float(r["meter_pin"]) for r in all_rows]) + pout = np.array([float(r["meter_pout"]) for r in all_rows]) + + voltages = sorted(set(voltages_arr)) + valid = (eff_hioki > 0) & (eff_hioki < 110) & (pin > 1.0) + + print(f"\nParameter: {param_name}") + print(f"Voltages: {', '.join(f'{v:.0f}V' for v in voltages)}") + print(f"Total: {len(all_rows)} points ({int(valid.sum())} valid)") + + # Per-voltage best + print(f"\n{'Voltage':>8} {'Best DT':>8} {'Efficiency':>12} {'Temp':>8} {'P_in':>8} {'P_out':>8}") + print("-" * 62) + per_voltage_best = {} + for v in voltages: + mask = valid & (voltages_arr == v) + if not np.any(mask): + continue + idx = np.argmax(eff_hioki[mask]) + indices = np.where(mask)[0] + best_i = indices[idx] + per_voltage_best[v] = param_vals[best_i] + print(f"{v:>7.0f}V {param_vals[best_i]:>8.0f} " + f"{eff_hioki[best_i]:>11.2f}% {temp[best_i]:>7.0f}C " + f"{pin[best_i]:>7.1f}W {pout[best_i]:>7.1f}W") + + # Overall best + valid_eff = eff_hioki.copy() + valid_eff[~valid] = -1 + overall_best_i = np.argmax(valid_eff) + print(f"\nOverall best: {param_name}={param_vals[overall_best_i]:.0f} " + f"@ {voltages_arr[overall_best_i]:.0f}V ->{eff_hioki[overall_best_i]:.2f}%") + + # Sweetspot: the value that maximizes average efficiency across all voltages + unique_params = sorted(set(param_vals)) + avg_eff = [] + for pv in unique_params: + mask = valid & (param_vals == pv) + if np.any(mask): + avg_eff.append(np.mean(eff_hioki[mask])) + else: + avg_eff.append(0.0) + avg_eff = np.array(avg_eff) + sweetspot_idx = np.argmax(avg_eff) + sweetspot_val = unique_params[sweetspot_idx] + sweetspot_eff = avg_eff[sweetspot_idx] + print(f"Sweetspot (best avg across all voltages): " + f"{param_name}={sweetspot_val:.0f} ->avg EFF={sweetspot_eff:.2f}%") + + # Colour map + cmap = plt.cm.viridis + colours = {v: cmap(i / max(len(voltages) - 1, 1)) for i, v in enumerate(voltages)} + + # ── Figure 1: Efficiency vs parameter, one line per voltage ────── + fig1, (ax1, ax1b) = plt.subplots(2, 1, figsize=(14, 9), sharex=True, + gridspec_kw={"height_ratios": [3, 1]}) + + for v in voltages: + mask = valid & (voltages_arr == v) + if not np.any(mask): + continue + x, y = param_vals[mask], eff_hioki[mask] + order = np.argsort(x) + ax1.plot(x[order], y[order], "o-", color=colours[v], markersize=3, + label=f"{v:.0f}V (best={per_voltage_best.get(v, 0):.0f})") + + # Average line + ax1.plot(unique_params, avg_eff, "s--", color="black", markersize=4, + linewidth=2, label=f"Average (sweetspot={sweetspot_val:.0f})", zorder=8) + + # Sweetspot marker + ax1.axvline(sweetspot_val, color="red", linestyle="--", alpha=0.6, linewidth=2) + ax1.plot(sweetspot_val, sweetspot_eff, "*", color="red", markersize=20, + zorder=10, label=f"Sweetspot: {sweetspot_val:.0f} ->{sweetspot_eff:.2f}%") + + ax1.set_ylabel("Efficiency (%)", fontsize=12) + ax1.set_title(f"Deadtime Tuning: {param_name} — All Voltages Overlaid", fontsize=14) + ax1.legend(loc="lower right", fontsize=8, ncol=2) + ax1.grid(True, alpha=0.3) + + # Tighten y-axis to show detail + valid_effs = eff_hioki[valid] + if len(valid_effs) > 0: + ax1.set_ylim(bottom=max(0, valid_effs.min() - 1), + top=min(100.5, valid_effs.max() + 0.5)) + + # Temperature subplot + for v in voltages: + mask = valid & (voltages_arr == v) + if not np.any(mask): + continue + x, y = param_vals[mask], temp[mask] + order = np.argsort(x) + ax1b.plot(x[order], y[order], "o-", color=colours[v], markersize=2) + + ax1b.axvline(sweetspot_val, color="red", linestyle="--", alpha=0.6, linewidth=2) + ax1b.set_xlabel(f"{param_name} (HRTIM ticks)", fontsize=12) + ax1b.set_ylabel("Temp (°C)", fontsize=11) + ax1b.grid(True, alpha=0.3) + fig1.tight_layout() + + # ── Figure 2: Heatmap (parameter × voltage) ───────────────────── + fig2, ax2 = plt.subplots(figsize=(14, 7)) + p_grid = np.array(sorted(set(param_vals))) + v_grid = np.array(voltages) + eff_map = np.full((len(v_grid), len(p_grid)), np.nan) + for r in all_rows: + e = float(r["meter_eff"]) + if not (0 < e < 110 and float(r["meter_pin"]) > 1.0): + continue + vi = np.searchsorted(v_grid, float(r["voltage_set"])) + pi = np.searchsorted(p_grid, float(r["param_value"])) + if vi < len(v_grid) and pi < len(p_grid): + eff_map[vi, pi] = e + + im = ax2.pcolormesh(p_grid, v_grid, eff_map, cmap="RdYlGn", shading="nearest") + fig2.colorbar(im, ax=ax2, label="Efficiency (%)") + ax2.axvline(sweetspot_val, color="blue", linestyle="--", linewidth=2, alpha=0.7) + ax2.set_xlabel(f"{param_name} (HRTIM ticks)", fontsize=12) + ax2.set_ylabel("Supply Voltage (V)", fontsize=12) + ax2.set_title(f"Efficiency Map: {param_name} × Voltage", fontsize=14) + fig2.tight_layout() + + # ── Figure 3: Power loss vs parameter ──────────────────────────── + fig3, ax3 = plt.subplots(figsize=(14, 7)) + for v in voltages: + mask = valid & (voltages_arr == v) + if not np.any(mask): + continue + x = param_vals[mask] + loss = pin[mask] - pout[mask] + order = np.argsort(x) + ax3.plot(x[order], loss[order], "o-", color=colours[v], markersize=3, + label=f"{v:.0f}V") + + ax3.axvline(sweetspot_val, color="red", linestyle="--", alpha=0.6, linewidth=2) + ax3.set_xlabel(f"{param_name} (HRTIM ticks)", fontsize=12) + ax3.set_ylabel("Power Loss (W)", fontsize=12) + ax3.set_title(f"Power Loss vs {param_name} — All Voltages", fontsize=14) + ax3.legend(fontsize=8, ncol=2) + ax3.grid(True, alpha=0.3) + fig3.tight_layout() + + # Save + if _args.output_dir: + out = Path(_args.output_dir) + out.mkdir(parents=True, exist_ok=True) + else: + out = Path(csv_files[0]).parent + + stem = f"tune_{param_name}" + 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 ────────────────────────────────────────────────────────────── @@ -1015,12 +1212,21 @@ examples: 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") + # plot-tune (offline, no instruments) + p_pt = sub.add_parser("plot-tune", help="Analyze multiple tuning CSVs: overlay, find sweetspot (no instruments needed)") + p_pt.add_argument("csv", nargs="+", help="Tuning CSV files (e.g. dt_0_3A_50V.csv dt_0_3A_60V.csv ...)") + p_pt.add_argument("-o", "--output-dir", help="Directory for saved plots (default: same as first CSV)") + p_pt.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 + if args.command == "plot-tune": + cmd_plot_tune(args) + return dispatch = { "identify": cmd_identify,