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: if v_step == 0:
raise ValueError("v_step cannot be zero") 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 # Auto-correct step direction
if v_start < v_stop and v_step < 0: if v_start < v_stop and v_step < 0:
v_step = -v_step v_step = -v_step
@@ -378,6 +381,8 @@ class MPPTTestbench:
if i_step == 0: if i_step == 0:
raise ValueError("i_step cannot be zero") raise ValueError("i_step cannot be zero")
self.check_supply_capability(voltage, current_limit)
# Auto-correct step direction # Auto-correct step direction
if i_start < i_stop and i_step < 0: if i_start < i_stop and i_step < 0:
i_step = -i_step i_step = -i_step
@@ -553,6 +558,33 @@ class MPPTTestbench:
return results 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 ───────────────────────────────────── # ── 2D Voltage × Current Sweep ─────────────────────────────────────
def sweep_vi( def sweep_vi(
@@ -560,16 +592,17 @@ class MPPTTestbench:
v_start: float, v_start: float,
v_stop: float, v_stop: float,
v_step: float, v_step: float,
i_start: float, l_start: float,
i_stop: float, l_stop: float,
i_step: float, l_step: float,
current_limit: float, current_limit: float,
settle_time: float = 2.0, settle_time: float = 2.0,
load_mode: str = "CC",
) -> list[SweepPoint]: ) -> 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 At each supply voltage, sweeps the load through the full
range, recording efficiency at every (V, I) combination. Produces range, recording efficiency at every combination. Produces
a complete efficiency map of the DUT. a complete efficiency map of the DUT.
After the sweep the load turns OFF and the supply returns to 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_start: Starting supply voltage (V).
v_stop: Final supply voltage (V). v_stop: Final supply voltage (V).
v_step: Voltage step size (V). Sign is auto-corrected. v_step: Voltage step size (V). Sign is auto-corrected.
i_start: Starting load current (A). l_start: Starting load setpoint (A for CC, W for CP).
i_stop: Final load current (A). l_stop: Final load setpoint.
i_step: Current step size (A). Sign is auto-corrected. l_step: Load step size. Sign is auto-corrected.
current_limit: Supply current limit (A) for the entire sweep. current_limit: Supply current limit (A) for the entire sweep.
settle_time: Seconds to wait after each setpoint change. settle_time: Seconds to wait after each setpoint change.
load_mode: Load mode — "CC" (constant current) or "CP"
(constant power).
""" """
if v_step == 0: if v_step == 0:
raise ValueError("v_step cannot be zero") raise ValueError("v_step cannot be zero")
if i_step == 0: if l_step == 0:
raise ValueError("i_step cannot be zero") 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 # Auto-correct step directions
if v_start < v_stop and v_step < 0: if v_start < v_stop and v_step < 0:
@@ -596,21 +638,25 @@ class MPPTTestbench:
elif v_start > v_stop and v_step > 0: elif v_start > v_stop and v_step > 0:
v_step = -v_step v_step = -v_step
if i_start < i_stop and i_step < 0: if l_start < l_stop and l_step < 0:
i_step = -i_step l_step = -l_step
elif i_start > i_stop and i_step > 0: elif l_start > l_stop and l_step > 0:
i_step = -i_step l_step = -l_step
# Count steps for progress display # Count steps for progress display
v_count = int(abs(v_stop - v_start) / abs(v_step)) + 1 v_count = int(abs(v_stop - v_start) / abs(v_step)) + 1
i_count = int(abs(i_stop - i_start) / abs(i_step)) + 1 l_count = int(abs(l_stop - l_start) / abs(l_step)) + 1
total = v_count * i_count total = v_count * l_count
print(f" Grid: {v_count} voltage × {i_count} current = {total} points") 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.set_current(current_limit)
self.supply.output_on() self.supply.output_on()
self.load.set_mode("CC") self.load.set_mode(load_mode)
self.load.set_cc_current(i_start) self._apply_load_value(load_mode, l_start)
self.load.load_on() self.load.load_on()
results: list[SweepPoint] = [] results: list[SweepPoint] = []
@@ -625,30 +671,30 @@ class MPPTTestbench:
break break
self.supply.set_voltage(v) self.supply.set_voltage(v)
i = i_start ll = l_start
while True: 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 break
if i_step < 0 and i < i_stop + i_step / 2: if l_step < 0 and ll < l_stop + l_step / 2:
break break
self.load.set_cc_current(i) self._apply_load_value(load_mode, ll)
time.sleep(settle_time) 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) results.append(point)
n += 1 n += 1
print( print(
f" [{n:>4d}/{total}] " 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_in={point.input_power:8.2f}W "
f"P_out={point.output_power:8.2f}W " f"P_out={point.output_power:8.2f}W "
f"EFF={point.efficiency:6.2f}%" f"EFF={point.efficiency:6.2f}%"
) )
i += i_step ll += l_step
v += v_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: 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( print(
f"2D sweep: V={args.v_start:.1f}-{args.v_stop:.1f}V (step {args.v_step:.1f}), " 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" f"I_limit={args.current_limit:.1f}A, settle={args.settle:.1f}s"
) )
print() print()
@@ -451,11 +453,12 @@ def cmd_sweep_vi(bench: MPPTTestbench, args: argparse.Namespace) -> None:
v_start=args.v_start, v_start=args.v_start,
v_stop=args.v_stop, v_stop=args.v_stop,
v_step=args.v_step, v_step=args.v_step,
i_start=args.i_start, l_start=args.l_start,
i_stop=args.i_stop, l_stop=args.l_stop,
i_step=args.i_step, l_step=args.l_step,
current_limit=args.current_limit, current_limit=args.current_limit,
settle_time=args.settle, settle_time=args.settle,
load_mode=mode,
) )
_write_sweep_csv(results, args.output) _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 --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 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 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 shade-profile --profile cloud_pass.csv --settle 2.0 -o shade_results.csv
%(prog)s supply set --voltage 24 --current 10 %(prog)s supply set --voltage 24 --current 10
%(prog)s supply on %(prog)s supply on
@@ -651,13 +655,14 @@ examples:
p_eff.add_argument("--load-value", type=float) p_eff.add_argument("--load-value", type=float)
# sweep-vi (2D) # 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-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-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("--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("--l-start", type=float, required=True, help="Start load setpoint (A for CC, W for CP)")
p_svi.add_argument("--i-stop", type=float, required=True, help="Stop load current (A)") p_svi.add_argument("--l-stop", type=float, required=True, help="Stop load setpoint")
p_svi.add_argument("--i-step", type=float, required=True, help="Current step (A)") 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("--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("--settle", type=float, default=2.0, help="Settle time per step (s)")
p_svi.add_argument("-o", "--output", help="CSV output file") 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 self._profile_after_id = None
def _build_sweep_vi_controls(self, parent) -> 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) frame.pack(fill=tk.X, padx=4, pady=4)
# Voltage range # Voltage range
@@ -521,21 +521,31 @@ class TestbenchGUI(tk.Tk):
self._svi_v_step.insert(0, "5") self._svi_v_step.insert(0, "5")
self._svi_v_step.pack(side=tk.LEFT, padx=2) self._svi_v_step.pack(side=tk.LEFT, padx=2)
# Current range # Load mode selector
row = ttk.Frame(frame) row = ttk.Frame(frame)
row.pack(fill=tk.X, pady=1) row.pack(fill=tk.X, pady=1)
ttk.Label(row, text="I start:", width=10).pack(side=tk.LEFT) ttk.Label(row, text="Load mode:", width=10).pack(side=tk.LEFT)
self._svi_i_start = ttk.Entry(row, width=7) self._svi_load_mode = ttk.Combobox(row, values=["CC", "CP"], width=4, state="readonly")
self._svi_i_start.insert(0, "0.5") self._svi_load_mode.set("CC")
self._svi_i_start.pack(side=tk.LEFT, padx=2) 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) ttk.Label(row, text="stop:").pack(side=tk.LEFT)
self._svi_i_stop = ttk.Entry(row, width=7) self._svi_l_stop = ttk.Entry(row, width=7)
self._svi_i_stop.insert(0, "30") self._svi_l_stop.insert(0, "30")
self._svi_i_stop.pack(side=tk.LEFT, padx=2) self._svi_l_stop.pack(side=tk.LEFT, padx=2)
ttk.Label(row, text="step:").pack(side=tk.LEFT) ttk.Label(row, text="step:").pack(side=tk.LEFT)
self._svi_i_step = ttk.Entry(row, width=5) self._svi_l_step = ttk.Entry(row, width=5)
self._svi_i_step.insert(0, "1") self._svi_l_step.insert(0, "1")
self._svi_i_step.pack(side=tk.LEFT, padx=2) self._svi_l_step.pack(side=tk.LEFT, padx=2)
# Supply current limit + settle # Supply current limit + settle
row = ttk.Frame(frame) row = ttk.Frame(frame)
@@ -890,6 +900,13 @@ class TestbenchGUI(tk.Tk):
return _fmt_eng(val) return _fmt_eng(val)
return _fmt(val, decimals=4) 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: def _set_range(self, channel: int, vi: str, combo: ttk.Combobox) -> None:
"""Set voltage or current range for a HIOKI channel.""" """Set voltage or current range for a HIOKI channel."""
val = combo.get() val = combo.get()
@@ -1084,11 +1101,12 @@ class TestbenchGUI(tk.Tk):
"v_start": float(self._svi_v_start.get()), "v_start": float(self._svi_v_start.get()),
"v_stop": float(self._svi_v_stop.get()), "v_stop": float(self._svi_v_stop.get()),
"v_step": float(self._svi_v_step.get()), "v_step": float(self._svi_v_step.get()),
"i_start": float(self._svi_i_start.get()), "l_start": float(self._svi_l_start.get()),
"i_stop": float(self._svi_i_stop.get()), "l_stop": float(self._svi_l_stop.get()),
"i_step": float(self._svi_i_step.get()), "l_step": float(self._svi_l_step.get()),
"current_limit": float(self._svi_ilimit.get()), "current_limit": float(self._svi_ilimit.get()),
"settle_time": float(self._svi_settle.get()), "settle_time": float(self._svi_settle.get()),
"load_mode": self._svi_load_mode.get(),
} }
except ValueError: except ValueError:
messagebox.showerror("Invalid Input", "Check sweep parameters.") messagebox.showerror("Invalid Input", "Check sweep parameters.")
@@ -1104,9 +1122,11 @@ class TestbenchGUI(tk.Tk):
if not path: if not path:
return return
mode = params["load_mode"]
unit = "A" if mode == "CC" else "W"
self._console( self._console(
f"2D sweep: V={params['v_start']}-{params['v_stop']}V " 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", "success",
) )
@@ -1149,28 +1169,34 @@ class TestbenchGUI(tk.Tk):
def _sweep_vi_loop(self, p: dict, stop: threading.Event) -> list: def _sweep_vi_loop(self, p: dict, stop: threading.Event) -> list:
"""Run the 2D sweep on a background thread. Returns list of SweepPoint.""" """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 bench = self.bench
v_start, v_stop, v_step = p["v_start"], p["v_stop"], p["v_step"] 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"] current_limit = p["current_limit"]
settle = p["settle_time"] settle = p["settle_time"]
load_mode = p.get("load_mode", "CC")
unit = "A" if load_mode == "CC" else "W"
# Auto-correct directions # Auto-correct directions
if v_start < v_stop and v_step < 0: if v_start < v_stop and v_step < 0:
v_step = -v_step v_step = -v_step
elif v_start > v_stop and v_step > 0: elif v_start > v_stop and v_step > 0:
v_step = -v_step v_step = -v_step
if i_start < i_stop and i_step < 0: if l_start < l_stop and l_step < 0:
i_step = -i_step l_step = -l_step
elif i_start > i_stop and i_step > 0: elif l_start > l_stop and l_step > 0:
i_step = -i_step 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.set_current(current_limit)
bench.supply.output_on() bench.supply.output_on()
bench.load.set_mode("CC") bench.load.set_mode(load_mode)
bench.load.set_cc_current(i_start) bench._apply_load_value(load_mode, l_start)
bench.load.load_on() bench.load.load_on()
results = [] results = []
@@ -1185,20 +1211,20 @@ class TestbenchGUI(tk.Tk):
break break
bench.supply.set_voltage(v) bench.supply.set_voltage(v)
i = i_start ll = l_start
while not stop.is_set(): 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 break
if i_step < 0 and i < i_stop + i_step / 2: if l_step < 0 and ll < l_stop + l_step / 2:
break break
bench.load.set_cc_current(i) bench._apply_load_value(load_mode, ll)
time.sleep(settle) time.sleep(settle)
if stop.is_set(): if stop.is_set():
break break
point = bench._record_point(v, current_limit, load_setpoint=i) point = bench._record_point(v, current_limit, load_setpoint=ll)
results.append(point) results.append(point)
n += 1 n += 1
@@ -1233,13 +1259,13 @@ class TestbenchGUI(tk.Tk):
self.after( self.after(
0, 0,
lambda v=v, i=i, pt=point, n=n: self._svi_status.config( lambda v=v, ll=ll, pt=point, n=n: self._svi_status.config(
text=f"[{n}] V={v:.1f}V I={i:.1f}A " text=f"[{n}] V={v:.1f}V {load_mode}={ll:.1f}{unit} "
f"EFF={pt.efficiency:.1f}%" f"EFF={pt.efficiency:.1f}%"
), ),
) )
i += i_step ll += l_step
v += v_step v += v_step
finally: finally:
bench.load.load_off() bench.load.load_off()