Files
mppt-testbench/testbench/cli.py
grabowski aced4f1e23 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>
2026-03-11 15:57:44 +07:00

728 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CLI for the MPPT Tracker Testbench.
Orchestrates IT6500D (supply) + Prodigit 3366G (load) + HIOKI 3193-10 (meter).
"""
from __future__ import annotations
import argparse
import csv
import sys
import time
from it6500.driver import IT6500
from prodigit3366g.driver import Prodigit3366G
from hioki3193.driver import Hioki3193
from testbench.bench import MPPTTestbench
# ── Instrument connection ─────────────────────────────────────────────
def connect_bench(args: argparse.Namespace) -> MPPTTestbench:
"""Create and return a connected MPPTTestbench from CLI args."""
supply_addr = MPPTTestbench.find_supply(args.supply_address)
meter_addr = MPPTTestbench.find_meter(args.meter_address)
load_port = args.load_port
print(f"Supply: {supply_addr}")
print(f"Load: {load_port}")
print(f"Meter: {meter_addr}")
print()
supply = IT6500(supply_addr, timeout_ms=args.timeout)
load = Prodigit3366G(load_port, baudrate=args.load_baud)
meter = Hioki3193(meter_addr, timeout_ms=args.timeout)
return MPPTTestbench(supply, load, meter)
# ── Commands ──────────────────────────────────────────────────────────
def cmd_identify(bench: MPPTTestbench, _args: argparse.Namespace) -> None:
"""Identify all three instruments."""
print("=== DC Power Supply (IT6500D) ===")
print(f" Identity: {bench.supply.idn()}")
print(f" Output: {'ON' if bench.supply.get_output_state() else 'OFF'}")
print(f" V set: {bench.supply.get_voltage():.4f} V")
print(f" I set: {bench.supply.get_current():.4f} A")
print()
print("=== DC Electronic Load (Prodigit 3366G) ===")
print(f" Model: {bench.load.name()}")
print(f" Load: {'ON' if bench.load.get_load_state() else 'OFF'}")
print(f" Mode: {bench.load.get_mode()}")
print()
print("=== Power Analyzer (HIOKI 3193-10) ===")
print(f" Identity: {bench.meter.idn()}")
print(f" Options: {bench.meter.options()}")
print(f" Wiring: {bench.meter.get_wiring_mode()}")
def cmd_setup(bench: MPPTTestbench, _args: argparse.Namespace) -> None:
"""Configure all instruments for MPPT testing."""
print("Setting up all instruments for MPPT testing...")
bench.setup_all()
print()
print("Supply: remote mode")
print("Load: remote mode")
print("Meter: 1P2W, DC coupling, auto-range, EFF1=P6/P5")
print("Display: SELECT16 — U5,I5,P5,EFF1 (left) | U6,I6,P6 (right)")
print()
print("Ready. Use 'measure' or 'monitor' to start reading data.")
def cmd_measure(bench: MPPTTestbench, _args: argparse.Namespace) -> None:
"""Take a single measurement from all instruments."""
data = bench.measure_all()
print("=== Supply (IT6500D) ===")
print(f" Voltage = {data['supply_V']:>10.4f} V")
print(f" Current = {data['supply_I']:>10.4f} A")
print(f" Power = {data['supply_P']:>10.4f} W")
print()
print("=== Load (Prodigit 3366G) ===")
print(f" Voltage = {data['load_V']:>10.4f} V")
print(f" Current = {data['load_I']:>10.4f} A")
print(f" Power = {data['load_P']:>10.4f} W")
print()
print("=== Meter (HIOKI 3193-10) ===")
print(f" Input: U5={data['meter_U5']:>+12.4E} V "
f"I5={data['meter_I5']:>+12.4E} A "
f"P5={data['meter_P5']:>+12.4E} W")
print(f" Output: U6={data['meter_U6']:>+12.4E} V "
f"I6={data['meter_I6']:>+12.4E} A "
f"P6={data['meter_P6']:>+12.4E} W")
print(f" EFF1 = {data['meter_EFF1']:>+12.4E} %")
def cmd_monitor(bench: MPPTTestbench, args: argparse.Namespace) -> None:
"""Continuously monitor all instruments."""
interval = args.interval
writer = None
outfile = None
columns = [
"timestamp",
"supply_V", "supply_I", "supply_P",
"load_V", "load_I", "load_P",
"meter_U5", "meter_I5", "meter_P5",
"meter_U6", "meter_I6", "meter_P6",
"meter_EFF1",
]
if args.output:
outfile = open(args.output, "w", newline="")
writer = csv.writer(outfile)
writer.writerow(columns)
print(f"Logging to {args.output}")
print(
f"{'Time':>10s} "
f"{'Sup_V':>8s} {'Sup_I':>8s} {'Sup_P':>8s} "
f"{'Ld_V':>8s} {'Ld_I':>8s} {'Ld_P':>8s} "
f"{'P_in':>10s} {'P_out':>10s} {'EFF%':>8s}"
)
print("-" * 105)
try:
count = 0
while args.count == 0 or count < args.count:
data = bench.measure_all()
ts = time.strftime("%H:%M:%S")
print(
f"{ts:>10s} "
f"{data['supply_V']:8.3f} {data['supply_I']:8.3f} {data['supply_P']:8.2f} "
f"{data['load_V']:8.3f} {data['load_I']:8.3f} {data['load_P']:8.2f} "
f"{data['meter_P5']:>+10.3E} {data['meter_P6']:>+10.3E} {data['meter_EFF1']:8.2f}"
)
if writer:
writer.writerow(
[time.strftime("%Y-%m-%d %H:%M:%S")]
+ [f"{data[c]:.6f}" for c in columns[1:]]
)
outfile.flush()
count += 1
if args.count == 0 or count < args.count:
time.sleep(interval)
except KeyboardInterrupt:
print("\nMonitoring stopped.")
finally:
if outfile:
outfile.close()
print(f"Data saved to {args.output}")
def cmd_live(bench: MPPTTestbench, args: argparse.Namespace) -> None:
"""Live monitor with real-time graphs."""
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from collections import deque
max_points = args.history
interval_ms = int(args.interval * 1000)
timestamps: deque[float] = deque(maxlen=max_points)
series: dict[str, deque[float]] = {
k: deque(maxlen=max_points)
for k in [
"supply_P", "load_P",
"meter_P5", "meter_P6", "meter_EFF1",
"meter_U5", "meter_U6",
"meter_I5", "meter_I6",
]
}
t0 = time.time()
writer = None
outfile = None
columns = [
"timestamp",
"supply_V", "supply_I", "supply_P",
"load_V", "load_I", "load_P",
"meter_U5", "meter_I5", "meter_P5",
"meter_U6", "meter_I6", "meter_P6",
"meter_EFF1",
]
if args.output:
outfile = open(args.output, "w", newline="")
writer = csv.writer(outfile)
writer.writerow(columns)
print(f"Logging to {args.output}")
fig, axes = plt.subplots(4, 1, figsize=(14, 12), squeeze=False)
fig.suptitle("MPPT Testbench Live Monitor", fontsize=14, fontweight="bold")
axes = axes.flatten()
# Subplot 0: Power
axes[0].set_ylabel("Power (W)")
axes[0].set_title("Power")
axes[0].grid(True, alpha=0.3)
line_p5, = axes[0].plot([], [], label="P_in (meter)", linewidth=1.5)
line_p6, = axes[0].plot([], [], label="P_out (meter)", linewidth=1.5)
line_sp, = axes[0].plot([], [], label="Supply P", linewidth=1, linestyle="--", alpha=0.6)
line_lp, = axes[0].plot([], [], label="Load P", linewidth=1, linestyle="--", alpha=0.6)
axes[0].legend(loc="upper left", fontsize=9)
# Subplot 1: Efficiency
axes[1].set_ylabel("Efficiency (%)")
axes[1].set_title("Efficiency")
axes[1].grid(True, alpha=0.3)
line_eff, = axes[1].plot([], [], label="EFF1", linewidth=1.5, color="green")
axes[1].legend(loc="upper left", fontsize=9)
# Subplot 2: Voltage
axes[2].set_ylabel("Voltage (V)")
axes[2].set_title("Voltage")
axes[2].grid(True, alpha=0.3)
line_u5, = axes[2].plot([], [], label="U5 (input)", linewidth=1.5)
line_u6, = axes[2].plot([], [], label="U6 (output)", linewidth=1.5)
axes[2].legend(loc="upper left", fontsize=9)
# Subplot 3: Current
axes[3].set_ylabel("Current (A)")
axes[3].set_title("Current")
axes[3].set_xlabel("Time (s)")
axes[3].grid(True, alpha=0.3)
line_i5, = axes[3].plot([], [], label="I5 (input)", linewidth=1.5)
line_i6, = axes[3].plot([], [], label="I6 (output)", linewidth=1.5)
axes[3].legend(loc="upper left", fontsize=9)
fig.tight_layout()
ERROR_THRESHOLD = 1e90
all_lines = [line_p5, line_p6, line_sp, line_lp, line_eff, line_u5, line_u6, line_i5, line_i6]
def _clean(val: float) -> float:
return float("nan") if abs(val) > ERROR_THRESHOLD else val
def update(_frame):
try:
data = bench.measure_all()
except Exception as e:
print(f"Read error: {e}")
return all_lines
now = time.time() - t0
timestamps.append(now)
for key in series:
series[key].append(_clean(data[key]))
ts = time.strftime("%H:%M:%S")
print(
f"{ts} P_in={data['meter_P5']:+.2E} "
f"P_out={data['meter_P6']:+.2E} "
f"EFF={data['meter_EFF1']:.1f}%"
)
if writer:
writer.writerow(
[time.strftime("%Y-%m-%d %H:%M:%S")]
+ [f"{data[c]:.6f}" for c in columns[1:]]
)
outfile.flush()
t_list = list(timestamps)
line_p5.set_data(t_list, list(series["meter_P5"]))
line_p6.set_data(t_list, list(series["meter_P6"]))
line_sp.set_data(t_list, list(series["supply_P"]))
line_lp.set_data(t_list, list(series["load_P"]))
line_eff.set_data(t_list, list(series["meter_EFF1"]))
line_u5.set_data(t_list, list(series["meter_U5"]))
line_u6.set_data(t_list, list(series["meter_U6"]))
line_i5.set_data(t_list, list(series["meter_I5"]))
line_i6.set_data(t_list, list(series["meter_I6"]))
for ax in axes:
ax.relim()
ax.autoscale_view()
return all_lines
_anim = FuncAnimation(
fig, update, interval=interval_ms, blit=False, cache_frame_data=False
)
try:
plt.show()
except KeyboardInterrupt:
pass
finally:
if outfile:
outfile.close()
print(f"Data saved to {args.output}")
def cmd_sweep(bench: MPPTTestbench, args: argparse.Namespace) -> None:
"""Run a voltage sweep to characterize MPPT tracking."""
print(
f"Voltage sweep: {args.v_start:.1f}V -> {args.v_stop:.1f}V, "
f"step={args.v_step:.2f}V, I_limit={args.current_limit:.1f}A, "
f"settle={args.settle:.1f}s"
)
# Ensure load is configured
if args.load_mode:
bench.load.set_mode(args.load_mode)
if args.load_value is not None:
mode = bench.load.get_mode()
if mode == "CC":
bench.load.set_cc_current(args.load_value)
elif mode == "CR":
bench.load.set_cr_resistance(args.load_value)
elif mode == "CV":
bench.load.set_cv_voltage(args.load_value)
elif mode == "CP":
bench.load.set_cp_power(args.load_value)
bench.load.load_on()
print()
results = bench.sweep_voltage(
v_start=args.v_start,
v_stop=args.v_stop,
v_step=args.v_step,
current_limit=args.current_limit,
settle_time=args.settle,
load_setpoint=args.load_value if args.load_value is not None else 0.0,
)
bench.load.load_off()
_write_sweep_csv(results, args.output)
_print_sweep_summary(results)
def _write_sweep_csv(results: list, output: str | None) -> None:
"""Write sweep results to CSV."""
if not output:
return
with open(output, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow([
"voltage_set", "current_limit", "load_setpoint",
"supply_V", "supply_I", "supply_P",
"load_V", "load_I", "load_P",
"input_power", "output_power", "efficiency",
])
for pt in results:
writer.writerow([
f"{pt.voltage_set:.4f}",
f"{pt.current_limit:.4f}",
f"{pt.load_setpoint:.4f}",
f"{pt.supply_voltage:.4f}",
f"{pt.supply_current:.4f}",
f"{pt.supply_power:.4f}",
f"{pt.load_voltage:.4f}",
f"{pt.load_current:.4f}",
f"{pt.load_power:.4f}",
f"{pt.input_power:.4f}",
f"{pt.output_power:.4f}",
f"{pt.efficiency:.4f}",
])
print(f"\nSweep data saved to {output}")
def _print_sweep_summary(results: list) -> None:
"""Print best-efficiency point from sweep results."""
if results:
best = max(results, key=lambda p: p.efficiency)
print(f"\nBest efficiency: {best.efficiency:.2f}% "
f"at V_set={best.voltage_set:.2f}V "
f"I_load={best.load_setpoint:.2f}A "
f"(P_in={best.input_power:.2f}W, P_out={best.output_power:.2f}W)")
def cmd_sweep_load(bench: MPPTTestbench, args: argparse.Namespace) -> None:
"""Run a load current sweep at a fixed supply voltage."""
print(
f"Load current sweep: {args.i_start:.2f}A -> {args.i_stop:.2f}A, "
f"step={args.i_step:.2f}A, V={args.voltage:.1f}V, "
f"I_limit={args.current_limit:.1f}A, settle={args.settle:.1f}s"
)
print()
results = bench.sweep_load_current(
voltage=args.voltage,
current_limit=args.current_limit,
i_start=args.i_start,
i_stop=args.i_stop,
i_step=args.i_step,
settle_time=args.settle,
)
_write_sweep_csv(results, args.output)
_print_sweep_summary(results)
def cmd_efficiency(bench: MPPTTestbench, args: argparse.Namespace) -> None:
"""Measure efficiency at a fixed operating point."""
# Configure load
if args.load_mode:
bench.load.set_mode(args.load_mode)
if args.load_value is not None:
mode = bench.load.get_mode()
if mode == "CC":
bench.load.set_cc_current(args.load_value)
elif mode == "CR":
bench.load.set_cr_resistance(args.load_value)
elif mode == "CV":
bench.load.set_cv_voltage(args.load_value)
elif mode == "CP":
bench.load.set_cp_power(args.load_value)
bench.load.load_on()
print(
f"Measuring efficiency at {args.voltage:.1f}V, "
f"{args.current_limit:.1f}A limit, "
f"{args.samples} samples..."
)
print()
result = bench.measure_efficiency(
voltage=args.voltage,
current_limit=args.current_limit,
settle_time=args.settle,
samples=args.samples,
sample_interval=args.interval,
)
bench.load.load_off()
print()
print(f"Average input power: {result['avg_input_power']:.4f} W")
print(f"Average output power: {result['avg_output_power']:.4f} W")
print(f"Average efficiency: {result['avg_efficiency']:.2f} %")
def cmd_sweep_vi(bench: MPPTTestbench, args: argparse.Namespace) -> None:
"""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"{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()
results = bench.sweep_vi(
v_start=args.v_start,
v_stop=args.v_stop,
v_step=args.v_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)
_print_sweep_summary(results)
def cmd_shade_profile(bench: MPPTTestbench, args: argparse.Namespace) -> None:
"""Run a shade / irradiance profile from a CSV file."""
steps = MPPTTestbench.load_shade_profile(args.profile)
duration = steps[-1]["time"] if steps else 0
print(f"Shade profile: {args.profile}")
print(f" {len(steps)} steps over {duration:.0f}s, settle={args.settle:.1f}s")
# Show what modes/values are used
modes = {s.get("load_mode", "?") for s in steps}
voltages = [s["voltage"] for s in steps]
currents = [s["current_limit"] for s in steps]
print(
f" Voltage: {min(voltages):.1f} - {max(voltages):.1f}V, "
f"I_limit: {min(currents):.1f} - {max(currents):.1f}A, "
f"Load modes: {', '.join(sorted(modes))}"
)
print()
results = bench.run_shade_profile(steps, settle_time=args.settle)
_write_sweep_csv(results, args.output)
_print_sweep_summary(results)
def cmd_supply(bench: MPPTTestbench, args: argparse.Namespace) -> None:
"""Control the DC supply directly."""
if args.action == "on":
bench.supply.output_on()
print("Supply output ON")
elif args.action == "off":
bench.supply.output_off()
print("Supply output OFF")
elif args.action == "set":
if args.voltage is not None and args.current is not None:
bench.supply.apply(args.voltage, args.current)
print(f"Supply set: {args.voltage:.4f} V, {args.current:.4f} A")
elif args.voltage is not None:
bench.supply.set_voltage(args.voltage)
print(f"Supply voltage: {args.voltage:.4f} V")
elif args.current is not None:
bench.supply.set_current(args.current)
print(f"Supply current: {args.current:.4f} A")
else:
print("Specify --voltage and/or --current")
def cmd_load(bench: MPPTTestbench, args: argparse.Namespace) -> None:
"""Control the electronic load directly."""
if args.action == "on":
bench.load.load_on()
print("Load ON")
elif args.action == "off":
bench.load.load_off()
print("Load OFF")
elif args.action == "set":
if args.mode:
bench.load.set_mode(args.mode)
if args.value is not None:
mode = bench.load.get_mode()
if mode == "CC":
bench.load.set_cc_current(args.value)
print(f"Load CC: {args.value:.4f} A")
elif mode == "CR":
bench.load.set_cr_resistance(args.value)
print(f"Load CR: {args.value:.4f} ohm")
elif mode == "CV":
bench.load.set_cv_voltage(args.value)
print(f"Load CV: {args.value:.4f} V")
elif mode == "CP":
bench.load.set_cp_power(args.value)
print(f"Load CP: {args.value:.4f} W")
elif args.mode:
print(f"Load mode: {args.mode}")
def cmd_safe_off(bench: MPPTTestbench, _args: argparse.Namespace) -> None:
"""Emergency shutdown: turn off load, then supply."""
print("Shutting down...")
bench.safe_off()
print(" Load OFF")
print(" Supply OFF")
print("Done.")
# ── Main ──────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
description="MPPT Tracker Testbench: IT6500D + Prodigit 3366G + HIOKI 3193-10",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
examples:
%(prog)s identify
%(prog)s setup
%(prog)s measure
%(prog)s monitor --interval 1.0 --output data.csv
%(prog)s live --interval 0.5
%(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 --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
%(prog)s load set --mode CC --value 5.0
%(prog)s load on
%(prog)s safe-off
""",
)
# Global instrument connection args
parser.add_argument(
"--supply-address",
help="IT6500D VISA address (auto-detect if omitted)",
)
parser.add_argument(
"--load-port", default="COM1",
help="Prodigit 3366G serial port (default: COM1)",
)
parser.add_argument(
"--load-baud", type=int, default=115200,
help="Prodigit 3366G baud rate (default: 115200)",
)
parser.add_argument(
"--meter-address",
help="HIOKI 3193-10 VISA address (auto-detect if omitted)",
)
parser.add_argument(
"--timeout", type=int, default=5000,
help="VISA timeout in ms (default: 5000)",
)
sub = parser.add_subparsers(dest="command", required=True)
# identify
sub.add_parser("identify", help="Identify all connected instruments")
# setup
sub.add_parser("setup", help="Configure all instruments for MPPT testing")
# measure
sub.add_parser("measure", help="Single measurement from all instruments")
# monitor
p_mon = sub.add_parser("monitor", help="Continuous monitoring of all instruments")
p_mon.add_argument("-i", "--interval", type=float, default=1.0)
p_mon.add_argument("-n", "--count", type=int, default=0, help="0=infinite")
p_mon.add_argument("-o", "--output", help="CSV output file")
# live
p_live = sub.add_parser("live", help="Live real-time graph of all instruments")
p_live.add_argument("-i", "--interval", type=float, default=1.0)
p_live.add_argument("-o", "--output", help="CSV output file")
p_live.add_argument("--history", type=int, default=300)
# sweep
p_sweep = sub.add_parser("sweep", help="Voltage sweep to characterize MPPT tracking")
p_sweep.add_argument("--v-start", type=float, required=True, help="Start voltage (V)")
p_sweep.add_argument("--v-stop", type=float, required=True, help="Stop voltage (V)")
p_sweep.add_argument("--v-step", type=float, required=True, help="Voltage step (V)")
p_sweep.add_argument("--current-limit", type=float, required=True, help="Current limit (A)")
p_sweep.add_argument("--settle", type=float, default=1.0, help="Settle time per step (s)")
p_sweep.add_argument("--load-mode", choices=["CC", "CR", "CV", "CP"], help="Set load mode before sweep")
p_sweep.add_argument("--load-value", type=float, help="Set load value before sweep")
p_sweep.add_argument("-o", "--output", help="CSV output file")
# sweep-load
p_swl = sub.add_parser("sweep-load", help="Load current sweep at fixed supply voltage")
p_swl.add_argument("--voltage", type=float, required=True, help="Fixed supply voltage (V)")
p_swl.add_argument("--current-limit", type=float, required=True, help="Supply current limit (A)")
p_swl.add_argument("--i-start", type=float, required=True, help="Start load current (A)")
p_swl.add_argument("--i-stop", type=float, required=True, help="Stop load current (A)")
p_swl.add_argument("--i-step", type=float, required=True, help="Current step (A)")
p_swl.add_argument("--settle", type=float, default=1.0, help="Settle time per step (s)")
p_swl.add_argument("-o", "--output", help="CSV output file")
# efficiency
p_eff = sub.add_parser("efficiency", help="Measure efficiency at fixed operating point")
p_eff.add_argument("--voltage", type=float, required=True, help="Supply voltage (V)")
p_eff.add_argument("--current-limit", type=float, required=True, help="Current limit (A)")
p_eff.add_argument("--samples", type=int, default=5, help="Number of readings to average")
p_eff.add_argument("--settle", type=float, default=2.0, help="Initial settle time (s)")
p_eff.add_argument("-i", "--interval", type=float, default=1.0, help="Interval between samples")
p_eff.add_argument("--load-mode", choices=["CC", "CR", "CV", "CP"])
p_eff.add_argument("--load-value", type=float)
# sweep-vi (2D)
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("--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")
# shade-profile
p_shade = sub.add_parser("shade-profile", help="Run a shade/irradiance profile from CSV")
p_shade.add_argument("--profile", required=True, help="Profile CSV file (time,voltage,current_limit,...)")
p_shade.add_argument("--settle", type=float, default=2.0, help="Settle time per step (s)")
p_shade.add_argument("-o", "--output", help="CSV output file for results")
# supply (direct control)
p_sup = sub.add_parser("supply", help="Direct supply control")
p_sup_sub = p_sup.add_subparsers(dest="action", required=True)
p_sup_sub.add_parser("on", help="Turn supply output ON")
p_sup_sub.add_parser("off", help="Turn supply output OFF")
p_sup_set = p_sup_sub.add_parser("set", help="Set supply voltage/current")
p_sup_set.add_argument("-v", "--voltage", type=float)
p_sup_set.add_argument("-c", "--current", type=float)
# load (direct control)
p_ld = sub.add_parser("load", help="Direct load control")
p_ld_sub = p_ld.add_subparsers(dest="action", required=True)
p_ld_sub.add_parser("on", help="Turn load ON")
p_ld_sub.add_parser("off", help="Turn load OFF")
p_ld_set = p_ld_sub.add_parser("set", help="Set load mode and value")
p_ld_set.add_argument("-m", "--mode", choices=["CC", "CR", "CV", "CP"])
p_ld_set.add_argument("-v", "--value", type=float, help="Setpoint value")
# safe-off
sub.add_parser("safe-off", help="Emergency: turn off load and supply")
args = parser.parse_args()
dispatch = {
"identify": cmd_identify,
"setup": cmd_setup,
"measure": cmd_measure,
"monitor": cmd_monitor,
"live": cmd_live,
"sweep": cmd_sweep,
"sweep-load": cmd_sweep_load,
"efficiency": cmd_efficiency,
"sweep-vi": cmd_sweep_vi,
"shade-profile": cmd_shade_profile,
"supply": cmd_supply,
"load": cmd_load,
"safe-off": cmd_safe_off,
}
bench = connect_bench(args)
try:
bench.supply.remote()
bench.load.remote()
dispatch[args.command](bench, args)
except KeyboardInterrupt:
print("\nInterrupted.")
finally:
bench.close()
if __name__ == "__main__":
main()