Add plot-sweep command, degauss buttons, sweep ramp-down, and time estimate
- Add offline `plot-sweep` CLI command: generates efficiency overlay, heatmap, and power loss plots from sweep CSV (no instruments needed) - Add degauss buttons to CH5/CH6 in GUI (sends :DEMAg SCPI command) - Gradual load ramp-down at end of 2D sweep to avoid transients - Live sweep time estimate in GUI based on grid size × settle time - Update HIOKI submodule to include degauss CLI command Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
162
testbench/cli.py
162
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,
|
||||
|
||||
Reference in New Issue
Block a user