diff --git a/testbench/bench.py b/testbench/bench.py index 2b47184..1722315 100644 --- a/testbench/bench.py +++ b/testbench/bench.py @@ -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 diff --git a/testbench/cli.py b/testbench/cli.py index 236dd77..fd35130 100644 --- a/testbench/cli.py +++ b/testbench/cli.py @@ -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") diff --git a/testbench/gui.py b/testbench/gui.py index d57e1e2..e5351b4 100644 --- a/testbench/gui.py +++ b/testbench/gui.py @@ -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("<>", 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()