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

@@ -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)"
)

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,

View File

@@ -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("<KeyRelease>", 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)

View File

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