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:
2026-03-12 16:31:33 +07:00
parent 5fe2ec4556
commit 3f65b5f2f2
5 changed files with 245 additions and 2 deletions

View File

@@ -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,