Add CP load mode to 2D sweep and supply sanity checks

- 2D sweep now supports CC (constant current) and CP (constant power)
  load modes via --load-mode flag (CLI) and combobox (GUI)
- Supply capability check before all sweeps: validates max voltage
  against supply range and prints V/I/P summary
- Renamed sweep-vi args from --i-start/stop/step to --l-start/stop/step
  to reflect that the load setpoint can be current or power
- GUI labels update dynamically based on selected load mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 15:57:44 +07:00
parent 05f23d6417
commit aced4f1e23
3 changed files with 146 additions and 69 deletions

View File

@@ -316,6 +316,9 @@ class MPPTTestbench:
if v_step == 0:
raise ValueError("v_step cannot be zero")
max_v = max(abs(v_start), abs(v_stop))
self.check_supply_capability(max_v, current_limit)
# Auto-correct step direction
if v_start < v_stop and v_step < 0:
v_step = -v_step
@@ -378,6 +381,8 @@ class MPPTTestbench:
if i_step == 0:
raise ValueError("i_step cannot be zero")
self.check_supply_capability(voltage, current_limit)
# Auto-correct step direction
if i_start < i_stop and i_step < 0:
i_step = -i_step
@@ -553,6 +558,33 @@ class MPPTTestbench:
return results
# ── Sanity checks ────────────────────────────────────────────────
def check_supply_capability(
self,
max_voltage: float,
current_limit: float,
) -> None:
"""Check that the supply can deliver the requested V and I.
Queries the supply's voltage range (max V) and verifies the
requested parameters are within bounds. Raises ValueError if not.
"""
supply_max_v = self.supply.get_voltage_range()
if max_voltage > supply_max_v:
raise ValueError(
f"Requested voltage {max_voltage:.1f}V exceeds supply "
f"range {supply_max_v:.1f}V"
)
max_power = max_voltage * current_limit
print(
f" Supply check: V_max={max_voltage:.1f}V, "
f"I_limit={current_limit:.1f}A, "
f"P_max={max_power:.0f}W "
f"(range {supply_max_v:.0f}V)"
)
# ── 2D Voltage × Current Sweep ─────────────────────────────────────
def sweep_vi(
@@ -560,16 +592,17 @@ class MPPTTestbench:
v_start: float,
v_stop: float,
v_step: float,
i_start: float,
i_stop: float,
i_step: float,
l_start: float,
l_stop: float,
l_step: float,
current_limit: float,
settle_time: float = 2.0,
load_mode: str = "CC",
) -> list[SweepPoint]:
"""2D sweep: voltage (outer) × load current (inner).
"""2D sweep: voltage (outer) × load setpoint (inner).
At each supply voltage, sweeps the load current through the full
range, recording efficiency at every (V, I) combination. Produces
At each supply voltage, sweeps the load through the full
range, recording efficiency at every combination. Produces
a complete efficiency map of the DUT.
After the sweep the load turns OFF and the supply returns to
@@ -579,16 +612,25 @@ class MPPTTestbench:
v_start: Starting supply voltage (V).
v_stop: Final supply voltage (V).
v_step: Voltage step size (V). Sign is auto-corrected.
i_start: Starting load current (A).
i_stop: Final load current (A).
i_step: Current step size (A). Sign is auto-corrected.
l_start: Starting load setpoint (A for CC, W for CP).
l_stop: Final load setpoint.
l_step: Load step size. Sign is auto-corrected.
current_limit: Supply current limit (A) for the entire sweep.
settle_time: Seconds to wait after each setpoint change.
load_mode: Load mode — "CC" (constant current) or "CP"
(constant power).
"""
if v_step == 0:
raise ValueError("v_step cannot be zero")
if i_step == 0:
raise ValueError("i_step cannot be zero")
if l_step == 0:
raise ValueError("l_step cannot be zero")
load_mode = load_mode.upper()
if load_mode not in ("CC", "CP"):
raise ValueError(f"load_mode must be CC or CP, got {load_mode!r}")
# Sanity check
max_v = max(abs(v_start), abs(v_stop))
self.check_supply_capability(max_v, current_limit)
# Auto-correct step directions
if v_start < v_stop and v_step < 0:
@@ -596,21 +638,25 @@ class MPPTTestbench:
elif v_start > v_stop and v_step > 0:
v_step = -v_step
if i_start < i_stop and i_step < 0:
i_step = -i_step
elif i_start > i_stop and i_step > 0:
i_step = -i_step
if l_start < l_stop and l_step < 0:
l_step = -l_step
elif l_start > l_stop and l_step > 0:
l_step = -l_step
# Count steps for progress display
v_count = int(abs(v_stop - v_start) / abs(v_step)) + 1
i_count = int(abs(i_stop - i_start) / abs(i_step)) + 1
total = v_count * i_count
print(f" Grid: {v_count} voltage × {i_count} current = {total} points")
l_count = int(abs(l_stop - l_start) / abs(l_step)) + 1
total = v_count * l_count
unit = "A" if load_mode == "CC" else "W"
print(
f" Grid: {v_count} voltage × {l_count} {load_mode} "
f"= {total} points"
)
self.supply.set_current(current_limit)
self.supply.output_on()
self.load.set_mode("CC")
self.load.set_cc_current(i_start)
self.load.set_mode(load_mode)
self._apply_load_value(load_mode, l_start)
self.load.load_on()
results: list[SweepPoint] = []
@@ -625,30 +671,30 @@ class MPPTTestbench:
break
self.supply.set_voltage(v)
i = i_start
ll = l_start
while True:
if i_step > 0 and i > i_stop + i_step / 2:
if l_step > 0 and ll > l_stop + l_step / 2:
break
if i_step < 0 and i < i_stop + i_step / 2:
if l_step < 0 and ll < l_stop + l_step / 2:
break
self.load.set_cc_current(i)
self._apply_load_value(load_mode, ll)
time.sleep(settle_time)
point = self._record_point(v, current_limit, load_setpoint=i)
point = self._record_point(v, current_limit, load_setpoint=ll)
results.append(point)
n += 1
print(
f" [{n:>4d}/{total}] "
f"V={v:6.1f}V I_load={i:6.2f}A "
f"V={v:6.1f}V {load_mode}={ll:6.2f}{unit} "
f"P_in={point.input_power:8.2f}W "
f"P_out={point.output_power:8.2f}W "
f"EFF={point.efficiency:6.2f}%"
)
i += i_step
ll += l_step
v += v_step

View File

@@ -439,10 +439,12 @@ def cmd_efficiency(bench: MPPTTestbench, args: argparse.Namespace) -> None:
def cmd_sweep_vi(bench: MPPTTestbench, args: argparse.Namespace) -> None:
"""Run a 2D voltage × load current sweep."""
"""Run a 2D voltage × load sweep."""
mode = args.load_mode
unit = "A" if mode == "CC" else "W"
print(
f"2D sweep: V={args.v_start:.1f}-{args.v_stop:.1f}V (step {args.v_step:.1f}), "
f"I_load={args.i_start:.2f}-{args.i_stop:.2f}A (step {args.i_step:.2f}), "
f"{mode}={args.l_start:.2f}-{args.l_stop:.2f}{unit} (step {args.l_step:.2f}), "
f"I_limit={args.current_limit:.1f}A, settle={args.settle:.1f}s"
)
print()
@@ -451,11 +453,12 @@ def cmd_sweep_vi(bench: MPPTTestbench, args: argparse.Namespace) -> None:
v_start=args.v_start,
v_stop=args.v_stop,
v_step=args.v_step,
i_start=args.i_start,
i_stop=args.i_stop,
i_step=args.i_step,
l_start=args.l_start,
l_stop=args.l_stop,
l_step=args.l_step,
current_limit=args.current_limit,
settle_time=args.settle,
load_mode=mode,
)
_write_sweep_csv(results, args.output)
@@ -564,7 +567,8 @@ examples:
%(prog)s sweep --v-start 10 --v-stop 50 --v-step 1 --current-limit 10 -o sweep.csv
%(prog)s sweep-load --voltage 75 --current-limit 10 --i-start 1 --i-stop 20 --i-step 1 -o load.csv
%(prog)s efficiency --voltage 36 --current-limit 10 --samples 10
%(prog)s sweep-vi --v-start 35 --v-stop 100 --v-step 5 --i-start 0.5 --i-stop 30 --i-step 1 --current-limit 35 -o map.csv
%(prog)s sweep-vi --v-start 35 --v-stop 100 --v-step 5 --l-start 0.5 --l-stop 30 --l-step 1 --current-limit 35 -o map.csv
%(prog)s sweep-vi --v-start 35 --v-stop 100 --v-step 5 --l-start 50 --l-stop 500 --l-step 50 --load-mode CP --current-limit 35 -o map_cp.csv
%(prog)s shade-profile --profile cloud_pass.csv --settle 2.0 -o shade_results.csv
%(prog)s supply set --voltage 24 --current 10
%(prog)s supply on
@@ -651,13 +655,14 @@ examples:
p_eff.add_argument("--load-value", type=float)
# sweep-vi (2D)
p_svi = sub.add_parser("sweep-vi", help="2D voltage × load current sweep (efficiency map)")
p_svi = sub.add_parser("sweep-vi", help="2D voltage × load sweep (efficiency map)")
p_svi.add_argument("--v-start", type=float, required=True, help="Start voltage (V)")
p_svi.add_argument("--v-stop", type=float, required=True, help="Stop voltage (V)")
p_svi.add_argument("--v-step", type=float, required=True, help="Voltage step (V)")
p_svi.add_argument("--i-start", type=float, required=True, help="Start load current (A)")
p_svi.add_argument("--i-stop", type=float, required=True, help="Stop load current (A)")
p_svi.add_argument("--i-step", type=float, required=True, help="Current step (A)")
p_svi.add_argument("--l-start", type=float, required=True, help="Start load setpoint (A for CC, W for CP)")
p_svi.add_argument("--l-stop", type=float, required=True, help="Stop load setpoint")
p_svi.add_argument("--l-step", type=float, required=True, help="Load step size")
p_svi.add_argument("--load-mode", choices=["CC", "CP"], default="CC", help="Load mode: CC (current) or CP (power)")
p_svi.add_argument("--current-limit", type=float, required=True, help="Supply current limit (A)")
p_svi.add_argument("--settle", type=float, default=2.0, help="Settle time per step (s)")
p_svi.add_argument("-o", "--output", help="CSV output file")

View File

@@ -502,7 +502,7 @@ class TestbenchGUI(tk.Tk):
self._profile_after_id = None
def _build_sweep_vi_controls(self, parent) -> None:
frame = ttk.LabelFrame(parent, text="2D Sweep (V × I)", padding=8)
frame = ttk.LabelFrame(parent, text="2D Sweep (V × Load)", padding=8)
frame.pack(fill=tk.X, padx=4, pady=4)
# Voltage range
@@ -521,21 +521,31 @@ class TestbenchGUI(tk.Tk):
self._svi_v_step.insert(0, "5")
self._svi_v_step.pack(side=tk.LEFT, padx=2)
# Current range
# Load mode selector
row = ttk.Frame(frame)
row.pack(fill=tk.X, pady=1)
ttk.Label(row, text="I start:", width=10).pack(side=tk.LEFT)
self._svi_i_start = ttk.Entry(row, width=7)
self._svi_i_start.insert(0, "0.5")
self._svi_i_start.pack(side=tk.LEFT, padx=2)
ttk.Label(row, text="Load mode:", width=10).pack(side=tk.LEFT)
self._svi_load_mode = ttk.Combobox(row, values=["CC", "CP"], width=4, state="readonly")
self._svi_load_mode.set("CC")
self._svi_load_mode.pack(side=tk.LEFT, padx=2)
self._svi_load_mode.bind("<<ComboboxSelected>>", self._on_svi_mode_change)
# Load setpoint range
row = ttk.Frame(frame)
row.pack(fill=tk.X, pady=1)
self._svi_l_label = ttk.Label(row, text="I start:", width=10)
self._svi_l_label.pack(side=tk.LEFT)
self._svi_l_start = ttk.Entry(row, width=7)
self._svi_l_start.insert(0, "0.5")
self._svi_l_start.pack(side=tk.LEFT, padx=2)
ttk.Label(row, text="stop:").pack(side=tk.LEFT)
self._svi_i_stop = ttk.Entry(row, width=7)
self._svi_i_stop.insert(0, "30")
self._svi_i_stop.pack(side=tk.LEFT, padx=2)
self._svi_l_stop = ttk.Entry(row, width=7)
self._svi_l_stop.insert(0, "30")
self._svi_l_stop.pack(side=tk.LEFT, padx=2)
ttk.Label(row, text="step:").pack(side=tk.LEFT)
self._svi_i_step = ttk.Entry(row, width=5)
self._svi_i_step.insert(0, "1")
self._svi_i_step.pack(side=tk.LEFT, padx=2)
self._svi_l_step = ttk.Entry(row, width=5)
self._svi_l_step.insert(0, "1")
self._svi_l_step.pack(side=tk.LEFT, padx=2)
# Supply current limit + settle
row = ttk.Frame(frame)
@@ -890,6 +900,13 @@ class TestbenchGUI(tk.Tk):
return _fmt_eng(val)
return _fmt(val, decimals=4)
def _on_svi_mode_change(self, _event=None) -> None:
mode = self._svi_load_mode.get()
if mode == "CC":
self._svi_l_label.config(text="I start:")
else:
self._svi_l_label.config(text="P start:")
def _set_range(self, channel: int, vi: str, combo: ttk.Combobox) -> None:
"""Set voltage or current range for a HIOKI channel."""
val = combo.get()
@@ -1084,11 +1101,12 @@ class TestbenchGUI(tk.Tk):
"v_start": float(self._svi_v_start.get()),
"v_stop": float(self._svi_v_stop.get()),
"v_step": float(self._svi_v_step.get()),
"i_start": float(self._svi_i_start.get()),
"i_stop": float(self._svi_i_stop.get()),
"i_step": float(self._svi_i_step.get()),
"l_start": float(self._svi_l_start.get()),
"l_stop": float(self._svi_l_stop.get()),
"l_step": float(self._svi_l_step.get()),
"current_limit": float(self._svi_ilimit.get()),
"settle_time": float(self._svi_settle.get()),
"load_mode": self._svi_load_mode.get(),
}
except ValueError:
messagebox.showerror("Invalid Input", "Check sweep parameters.")
@@ -1104,9 +1122,11 @@ class TestbenchGUI(tk.Tk):
if not path:
return
mode = params["load_mode"]
unit = "A" if mode == "CC" else "W"
self._console(
f"2D sweep: V={params['v_start']}-{params['v_stop']}V "
f"I={params['i_start']}-{params['i_stop']}A",
f"{mode}={params['l_start']}-{params['l_stop']}{unit}",
"success",
)
@@ -1149,28 +1169,34 @@ class TestbenchGUI(tk.Tk):
def _sweep_vi_loop(self, p: dict, stop: threading.Event) -> list:
"""Run the 2D sweep on a background thread. Returns list of SweepPoint."""
from testbench.bench import SweepPoint, IDLE_VOLTAGE
from testbench.bench import IDLE_VOLTAGE
bench = self.bench
v_start, v_stop, v_step = p["v_start"], p["v_stop"], p["v_step"]
i_start, i_stop, i_step = p["i_start"], p["i_stop"], p["i_step"]
l_start, l_stop, l_step = p["l_start"], p["l_stop"], p["l_step"]
current_limit = p["current_limit"]
settle = p["settle_time"]
load_mode = p.get("load_mode", "CC")
unit = "A" if load_mode == "CC" else "W"
# Auto-correct directions
if v_start < v_stop and v_step < 0:
v_step = -v_step
elif v_start > v_stop and v_step > 0:
v_step = -v_step
if i_start < i_stop and i_step < 0:
i_step = -i_step
elif i_start > i_stop and i_step > 0:
i_step = -i_step
if l_start < l_stop and l_step < 0:
l_step = -l_step
elif l_start > l_stop and l_step > 0:
l_step = -l_step
# Sanity check
max_v = max(abs(v_start), abs(v_stop))
bench.check_supply_capability(max_v, current_limit)
bench.supply.set_current(current_limit)
bench.supply.output_on()
bench.load.set_mode("CC")
bench.load.set_cc_current(i_start)
bench.load.set_mode(load_mode)
bench._apply_load_value(load_mode, l_start)
bench.load.load_on()
results = []
@@ -1185,20 +1211,20 @@ class TestbenchGUI(tk.Tk):
break
bench.supply.set_voltage(v)
i = i_start
ll = l_start
while not stop.is_set():
if i_step > 0 and i > i_stop + i_step / 2:
if l_step > 0 and ll > l_stop + l_step / 2:
break
if i_step < 0 and i < i_stop + i_step / 2:
if l_step < 0 and ll < l_stop + l_step / 2:
break
bench.load.set_cc_current(i)
bench._apply_load_value(load_mode, ll)
time.sleep(settle)
if stop.is_set():
break
point = bench._record_point(v, current_limit, load_setpoint=i)
point = bench._record_point(v, current_limit, load_setpoint=ll)
results.append(point)
n += 1
@@ -1233,13 +1259,13 @@ class TestbenchGUI(tk.Tk):
self.after(
0,
lambda v=v, i=i, pt=point, n=n: self._svi_status.config(
text=f"[{n}] V={v:.1f}V I={i:.1f}A "
lambda v=v, ll=ll, pt=point, n=n: self._svi_status.config(
text=f"[{n}] V={v:.1f}V {load_mode}={ll:.1f}{unit} "
f"EFF={pt.efficiency:.1f}%"
),
)
i += i_step
ll += l_step
v += v_step
finally:
bench.load.load_off()