Initial commit: Prodigit 3366G DC Electronic Load RS-232 driver and CLI

Python package for controlling the Prodigit 3366G (600V/420A/6kW) over
RS-232 serial. Includes driver with full command support (CC/CR/CV/CP
modes, measurements, battery test, OCP/OPP, dynamic mode, limits) and
CLI tool with identify, measure, monitor, live graph, and raw send.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 10:09:07 +07:00
commit 1594a240c0
9 changed files with 1467 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
"""Prodigit 3366G High Power DC Electronic Load RS-232 driver."""
from prodigit3366g.driver import Prodigit3366G
__all__ = ["Prodigit3366G"]

343
prodigit3366g/cli.py Normal file
View File

@@ -0,0 +1,343 @@
"""CLI tool for controlling the Prodigit 3366G DC Electronic Load via RS-232."""
from __future__ import annotations
import argparse
import csv
import sys
import time
from prodigit3366g.driver import Prodigit3366G
def cmd_identify(load: Prodigit3366G, _args: argparse.Namespace) -> None:
"""Print instrument identity and status."""
print(f"Model: {load.name()}")
print(f"Mode: {load.get_mode()}")
print(f"Load: {'ON' if load.get_load_state() else 'OFF'}")
print(f"Level: {load.get_level()}")
print(f"Sense: {load.get_sense()}")
print(f"Dynamic: {'ON' if load.get_dynamic() else 'OFF'}")
prot = load.get_protection_status()
active = [k for k, v in prot.items() if v]
print(f"Protection: {', '.join(active) if active else 'None'}")
def cmd_measure(load: Prodigit3366G, _args: argparse.Namespace) -> None:
"""Take a single measurement."""
result = load.measure_all()
print(f" Voltage = {result.voltage:>10.4f} V")
print(f" Current = {result.current:>10.4f} A")
print(f" Power = {result.power:>10.4f} W")
def cmd_monitor(load: Prodigit3366G, args: argparse.Namespace) -> None:
"""Continuously monitor measurements at an interval."""
interval = args.interval
writer = None
outfile = None
if args.output:
outfile = open(args.output, "w", newline="")
writer = csv.writer(outfile)
writer.writerow(["timestamp", "voltage_V", "current_A", "power_W"])
print(f"Logging to {args.output}")
print(f"{'Time':>10s} {'Voltage(V)':>12s} {'Current(A)':>12s} {'Power(W)':>12s}")
print("-" * 52)
try:
count = 0
while args.count == 0 or count < args.count:
result = load.measure_all()
ts = time.strftime("%H:%M:%S")
print(
f"{ts:>10s} {result.voltage:>12.4f} "
f"{result.current:>12.4f} {result.power:>12.4f}"
)
if writer:
writer.writerow([
time.strftime("%Y-%m-%d %H:%M:%S"),
f"{result.voltage:.4f}",
f"{result.current:.4f}",
f"{result.power:.4f}",
])
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(load: Prodigit3366G, args: argparse.Namespace) -> None:
"""Live monitor with real-time graph."""
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)
voltages: deque[float] = deque(maxlen=max_points)
currents: deque[float] = deque(maxlen=max_points)
powers: deque[float] = deque(maxlen=max_points)
t0 = time.time()
writer = None
outfile = None
if args.output:
outfile = open(args.output, "w", newline="")
writer = csv.writer(outfile)
writer.writerow(["timestamp", "voltage_V", "current_A", "power_W"])
print(f"Logging to {args.output}")
fig, axes = plt.subplots(3, 1, figsize=(12, 9), squeeze=False)
fig.suptitle("Prodigit 3366G Live Monitor", fontsize=14, fontweight="bold")
axes = axes.flatten()
labels = [("Voltage", "V", voltages), ("Current", "A", currents), ("Power", "W", powers)]
lines = []
for ax, (title, unit, _) in zip(axes, labels):
ax.set_ylabel(f"{title} ({unit})")
ax.set_xlabel("Time (s)")
ax.grid(True, alpha=0.3)
ax.set_title(title, fontsize=11)
line, = ax.plot([], [], linewidth=1.5)
lines.append(line)
ax.legend([title], loc="upper left", fontsize=9)
fig.tight_layout()
def update(_frame):
try:
result = load.measure_all()
except Exception as e:
print(f"Read error: {e}")
return lines
now = time.time() - t0
timestamps.append(now)
voltages.append(result.voltage)
currents.append(result.current)
powers.append(result.power)
ts = time.strftime("%H:%M:%S")
print(
f"{ts} V={result.voltage:.4f} "
f"I={result.current:.4f} P={result.power:.4f}"
)
if writer:
writer.writerow([
time.strftime("%Y-%m-%d %H:%M:%S"),
f"{result.voltage:.4f}",
f"{result.current:.4f}",
f"{result.power:.4f}",
])
outfile.flush()
t_list = list(timestamps)
for line, (_, _, data) in zip(lines, labels):
line.set_data(t_list, list(data))
for ax in axes:
ax.relim()
ax.autoscale_view()
return 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_set(load: Prodigit3366G, args: argparse.Namespace) -> None:
"""Set load mode and value."""
mode = args.mode.upper()
value = args.value
load.set_mode(mode)
if mode == "CC":
load.set_cc_current(value)
print(f"Mode: CC, Current: {value:.4f} A")
elif mode == "CR":
load.set_cr_resistance(value)
print(f"Mode: CR, Resistance: {value:.3f} Ohm")
elif mode == "CV":
load.set_cv_voltage(value)
print(f"Mode: CV, Voltage: {value:.4f} V")
elif mode == "CP":
load.set_cp_power(value)
print(f"Mode: CP, Power: {value:.4f} W")
def cmd_load(load: Prodigit3366G, args: argparse.Namespace) -> None:
"""Turn load ON or OFF."""
if args.state.upper() in ("ON", "1"):
load.load_on()
print("Load ON")
else:
load.load_off()
print("Load OFF")
def cmd_battery(load: Prodigit3366G, args: argparse.Namespace) -> None:
"""Battery discharge test control."""
action = args.action
if action == "start":
if args.uvp is not None:
load.set_battery_test(uvp=args.uvp)
if args.time is not None:
load.set_battery_test(time_s=args.time)
load.battery_test_start()
print("Battery discharge test started.")
elif action == "stop":
load.battery_test_stop()
results = load.get_battery_results()
print("Battery test stopped. Results:")
for k, v in results.items():
print(f" {k:>6s} = {v}")
elif action == "status":
results = load.get_battery_results()
print("Battery test results:")
for k, v in results.items():
print(f" {k:>6s} = {v}")
def cmd_send(load: Prodigit3366G, args: argparse.Namespace) -> None:
"""Send a raw command."""
command = " ".join(args.raw_command)
result = load.send(command)
if result is not None:
print(f"Response: {result}")
else:
print("OK")
def cmd_protection(load: Prodigit3366G, args: argparse.Namespace) -> None:
"""Check or clear protection status."""
if args.clear:
load.clear_protection()
print("Protection flags cleared.")
else:
prot = load.get_protection_status()
for name, active in prot.items():
status = "TRIGGERED" if active else "OK"
print(f" {name}: {status}")
def main() -> None:
parser = argparse.ArgumentParser(
description="Prodigit 3366G DC Electronic Load RS-232 Tool",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
examples:
%(prog)s identify
%(prog)s measure
%(prog)s monitor --interval 1.0 --output data.csv
%(prog)s live --interval 0.5
%(prog)s set CC 10.0
%(prog)s set CV 48.0
%(prog)s load on
%(prog)s load off
%(prog)s battery start --uvp 42.0 --time 3600
%(prog)s battery stop
%(prog)s protection
%(prog)s protection --clear
%(prog)s send "MODE?"
%(prog)s send "CC CURR:HIGH 5.0"
""",
)
parser.add_argument(
"-p", "--port", default="COM1",
help="Serial port (default: COM1)",
)
parser.add_argument(
"-b", "--baudrate", type=int, default=9600,
help="Baud rate (default: 9600)",
)
parser.add_argument(
"--timeout", type=float, default=2.0,
help="Serial timeout in seconds (default: 2.0)",
)
sub = parser.add_subparsers(dest="command", required=True)
# identify
sub.add_parser("identify", help="Show instrument identity and status")
# measure
sub.add_parser("measure", help="Take a single measurement")
# monitor
p_mon = sub.add_parser("monitor", help="Continuously monitor measurements")
p_mon.add_argument("-i", "--interval", type=float, default=1.0, help="Seconds between readings (default: 1.0)")
p_mon.add_argument("-n", "--count", type=int, default=0, help="Number of readings (0=infinite)")
p_mon.add_argument("-o", "--output", help="CSV output file path")
# live
p_live = sub.add_parser("live", help="Live monitor with real-time graph")
p_live.add_argument("-i", "--interval", type=float, default=1.0, help="Seconds between readings (default: 1.0)")
p_live.add_argument("-o", "--output", help="CSV output file path")
p_live.add_argument("--history", type=int, default=300, help="Max data points to display (default: 300)")
# set
p_set = sub.add_parser("set", help="Set load mode and value")
p_set.add_argument("mode", choices=["CC", "cc", "CR", "cr", "CV", "cv", "CP", "cp"], help="Load mode")
p_set.add_argument("value", type=float, help="Set value (A for CC, Ohm for CR, V for CV, W for CP)")
# load
p_load = sub.add_parser("load", help="Turn load ON or OFF")
p_load.add_argument("state", choices=["on", "off", "ON", "OFF", "1", "0"], help="ON or OFF")
# battery
p_batt = sub.add_parser("battery", help="Battery discharge test")
p_batt.add_argument("action", choices=["start", "stop", "status"])
p_batt.add_argument("--uvp", type=float, help="Under-voltage protection cutoff (V)")
p_batt.add_argument("--time", type=int, help="Discharge time limit (seconds, 0=unlimited)")
# protection
p_prot = sub.add_parser("protection", help="Check or clear protection status")
p_prot.add_argument("--clear", action="store_true", help="Clear protection flags")
# send
p_send = sub.add_parser("send", help="Send raw RS-232 command")
p_send.add_argument("raw_command", nargs="+", help="Command string (queries auto-detected by '?')")
args = parser.parse_args()
dispatch = {
"identify": cmd_identify,
"measure": cmd_measure,
"monitor": cmd_monitor,
"live": cmd_live,
"set": cmd_set,
"load": cmd_load,
"battery": cmd_battery,
"protection": cmd_protection,
"send": cmd_send,
}
with Prodigit3366G(port=args.port, baudrate=args.baudrate, timeout=args.timeout) as load:
load.remote()
dispatch[args.command](load, args)
if __name__ == "__main__":
main()

557
prodigit3366g/driver.py Normal file
View File

@@ -0,0 +1,557 @@
"""Prodigit 3366G High Power DC Electronic Load RS-232 driver.
3366G specs: 600V, 420A, 6-9kW (turbo 1.5x)
Modes: CC (Constant Current), CR (Constant Resistance),
CV (Constant Voltage), CP (Constant Power)
RS-232 protocol:
Baud: 9600-115200 (configurable on front panel)
Data: 8 bits, No parity, 1 stop bit
Flow: Hardware RTS/CTS
Terminator: LF (0x0A) or CR+LF
Commands use "Simple Type" format by default.
Query commands append {?} to the setting command.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
import serial
@dataclass
class MeasurementResult:
"""Container for a single measurement snapshot."""
voltage: float
current: float
power: float
timestamp: float = field(default_factory=time.time)
def __repr__(self) -> str:
return (
f"MeasurementResult(V={self.voltage:.4f}, "
f"I={self.current:.4f}, P={self.power:.4f})"
)
class Prodigit3366G:
"""Driver for Prodigit 3366G DC Electronic Load over RS-232.
Args:
port: Serial port name, e.g. "COM1" or "/dev/ttyUSB0".
baudrate: Baud rate (must match front panel setting). Default 9600.
timeout: Read timeout in seconds.
"""
# 3366G specifications
MAX_VOLTAGE = 600.0 # V
MAX_CURRENT_R1 = 420.0 # A (Range 1)
MAX_CURRENT_R2 = 630.0 # A (turbo 1.5x)
MAX_POWER_R1 = 6000.0 # W
MAX_POWER_R2 = 9000.0 # W (turbo)
# Mode codes returned by MODE?
MODE_MAP = {0: "CC", 1: "CR", 2: "CV", 3: "CP"}
MODE_REVERSE = {"CC": 0, "CR": 1, "CV": 2, "CP": 3}
def __init__(
self,
port: str = "COM1",
baudrate: int = 9600,
timeout: float = 2.0,
) -> None:
self._port_name = port
self._ser = serial.Serial(
port=port,
baudrate=baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
rtscts=True,
timeout=timeout,
)
# -- Low-level communication --
def write(self, command: str) -> None:
"""Send a command to the instrument (appends LF terminator)."""
self._ser.write((command + "\n").encode("ascii"))
def query(self, command: str) -> str:
"""Send a query and return the response string."""
self._ser.reset_input_buffer()
self.write(command)
response = self._ser.readline().decode("ascii").strip()
return response
def close(self) -> None:
"""Close the serial connection."""
if self._ser.is_open:
try:
self.local()
except Exception:
pass
self._ser.close()
def __enter__(self) -> Prodigit3366G:
return self
def __exit__(self, *exc) -> None:
self.close()
# -- System Commands --
def remote(self) -> None:
"""Enter remote control mode (RS-232)."""
self.write("REMOTE")
def local(self) -> None:
"""Exit remote mode, return to local front panel control."""
self.write("LOCAL")
def name(self) -> str:
"""Query the model number of the load."""
return self.query("NAME?")
def reset(self) -> None:
"""Reset instrument to factory defaults."""
self.write("*RST")
time.sleep(1)
def recall(self, slot: int) -> None:
"""Recall a saved state from memory (1-150)."""
self.write(f"RECALL {slot}")
def store(self, slot: int) -> None:
"""Save current state to memory (1-150)."""
self.write(f"STORE {slot}")
# -- Load ON/OFF --
def load_on(self) -> None:
"""Turn on the load (start sinking current)."""
self.write("LOAD ON")
def load_off(self) -> None:
"""Turn off the load (stop sinking current)."""
self.write("LOAD OFF")
def get_load_state(self) -> bool:
"""Query load on/off state. Returns True if ON."""
resp = self.query("LOAD?")
return resp.strip() == "1"
# -- Mode Selection --
def set_mode(self, mode: str) -> None:
"""Set load mode: 'CC', 'CR', 'CV', or 'CP'."""
mode = mode.upper()
if mode not in self.MODE_REVERSE:
raise ValueError(f"Invalid mode '{mode}'. Must be CC, CR, CV, or CP.")
self.write(f"MODE {mode}")
def get_mode(self) -> str:
"""Query current load mode. Returns 'CC', 'CR', 'CV', or 'CP'."""
resp = self.query("MODE?")
code = int(resp.strip())
return self.MODE_MAP.get(code, f"UNKNOWN({code})")
# -- Constant Current (CC) --
def set_cc_current(self, amps: float, level: str = "HIGH") -> None:
"""Set CC mode current level.
Args:
amps: Current in Amperes (0-420A R1, 0-630A turbo).
level: 'HIGH' or 'LOW' level.
"""
level = level.upper()
self.write(f"CC CURR:{level} {amps:.5f}")
def get_cc_current(self, level: str = "HIGH") -> float:
"""Query CC mode current setting."""
level = level.upper()
resp = self.query(f"CC CURR:{level}?")
return float(resp)
# -- Constant Resistance (CR) --
def set_cr_resistance(self, ohms: float, level: str = "HIGH") -> None:
"""Set CR mode resistance level.
Args:
ohms: Resistance in Ohms.
level: 'HIGH' or 'LOW' level.
"""
level = level.upper()
self.write(f"CR RES:{level} {ohms:.3f}")
def get_cr_resistance(self, level: str = "HIGH") -> float:
"""Query CR mode resistance setting."""
level = level.upper()
resp = self.query(f"CR RES:{level}?")
return float(resp)
# -- Constant Voltage (CV) --
def set_cv_voltage(self, volts: float, level: str = "HIGH") -> None:
"""Set CV mode voltage level.
Args:
volts: Voltage in Volts (0-600V).
level: 'HIGH' or 'LOW' level.
"""
level = level.upper()
self.write(f"CV VOLT:{level} {volts:.5f}")
def get_cv_voltage(self, level: str = "HIGH") -> float:
"""Query CV mode voltage setting."""
level = level.upper()
resp = self.query(f"CV VOLT:{level}?")
return float(resp)
# -- Constant Power (CP) --
def set_cp_power(self, watts: float, level: str = "HIGH") -> None:
"""Set CP mode power level.
Args:
watts: Power in Watts (0-6000W R1, 0-9000W turbo).
level: 'HIGH' or 'LOW' level.
"""
level = level.upper()
self.write(f"CP:{level} {watts:.5f}")
def get_cp_power(self, level: str = "HIGH") -> float:
"""Query CP mode power setting."""
level = level.upper()
resp = self.query(f"CP:{level}?")
return float(resp)
# -- Level (HIGH/LOW toggle for dynamic mode) --
def set_level(self, level: str) -> None:
"""Set active level: 'HIGH' or 'LOW'."""
level = level.upper()
self.write(f"LEV {level}")
def get_level(self) -> str:
"""Query active level. Returns 'LOW' or 'HIGH'."""
resp = self.query("LEV?")
return "HIGH" if resp.strip() == "1" else "LOW"
# -- Dynamic Mode --
def set_dynamic(self, on: bool) -> None:
"""Enable/disable dynamic (switching between HIGH/LOW) mode."""
self.write(f"DYN {'ON' if on else 'OFF'}")
def get_dynamic(self) -> bool:
"""Query dynamic mode state."""
resp = self.query("DYN?")
return resp.strip() == "1"
# -- Slew Rate --
def set_rise_slew(self, slew: float) -> None:
"""Set rise slew rate in A/us."""
self.write(f"RISE {slew}")
def get_rise_slew(self) -> float:
"""Query rise slew rate."""
return float(self.query("RISE?"))
def set_fall_slew(self, slew: float) -> None:
"""Set fall slew rate in A/us."""
self.write(f"FALL {slew}")
def get_fall_slew(self) -> float:
"""Query fall slew rate."""
return float(self.query("FALL?"))
# -- Dynamic Period --
def set_period(self, high_ms: float, low_ms: float) -> None:
"""Set dynamic mode period (THIGH and TLOW) in mS."""
self.write(f"PERD:HIGH {high_ms:.5f}")
self.write(f"PERD:LOW {low_ms:.5f}")
def get_period(self, level: str = "HIGH") -> float:
"""Query dynamic period for HIGH or LOW."""
return float(self.query(f"PERD:{level.upper()}?"))
# -- Load ON/OFF Voltage --
def set_load_on_voltage(self, volts: float) -> None:
"""Set the voltage at which load turns ON (0.25-62.5V for 150V range)."""
self.write(f"LDONV {volts:.5f}")
def get_load_on_voltage(self) -> float:
"""Query load ON voltage."""
return float(self.query("LDONV?"))
def set_load_off_voltage(self, volts: float) -> None:
"""Set the voltage at which load turns OFF."""
self.write(f"LDOFFV {volts:.5f}")
def get_load_off_voltage(self) -> float:
"""Query load OFF voltage."""
return float(self.query("LDOFFV?"))
# -- Short Circuit --
def set_short(self, on: bool) -> None:
"""Enable/disable short-circuit test mode."""
self.write(f"SHOR {'ON' if on else 'OFF'}")
def get_short(self) -> bool:
"""Query short-circuit mode state."""
resp = self.query("SHOR?")
return resp.strip() == "1"
# -- Voltage Sense --
def set_sense(self, mode: str) -> None:
"""Set voltage sense mode: 'ON' (V-sense), 'OFF' (input), or 'AUTO'."""
self.write(f"SENSe {mode.upper()}")
def get_sense(self) -> str:
"""Query voltage sense mode."""
resp = self.query("SENSe?")
m = {"0": "OFF", "1": "ON", "2": "AUTO"}
return m.get(resp.strip(), resp)
# -- CC Range --
def set_cc_range(self, mode: str) -> None:
"""Set CC mode range: 'AUTO' or 'R2' (force Range II)."""
self.write(f"CC {mode.upper()}")
# -- Preset Display --
def set_preset_display(self, on: bool) -> None:
"""Toggle LCD between current setting (ON) and DWM (OFF)."""
self.write(f"PRES {'ON' if on else 'OFF'}")
# -- Protection Status --
def get_protection_status(self) -> dict[str, bool]:
"""Query protection flags.
Returns dict with keys: OPP, OTP, OVP, OCP.
"""
resp = self.query("PROT?")
bits = int(resp.strip())
return {
"OPP": bool(bits & 0x01),
"OTP": bool(bits & 0x02),
"OVP": bool(bits & 0x04),
"OCP": bool(bits & 0x08),
}
def clear_protection(self) -> None:
"""Clear error/protection flags."""
self.write("CLR")
# -- NG Status --
def get_ng_status(self) -> bool:
"""Query NG (No Good) flag. True = NG condition."""
resp = self.query("NG?")
return resp.strip() == "1"
def set_ng_enable(self, on: bool) -> None:
"""Enable/disable the GO/NG check function."""
self.write(f"NGENABLE {'ON' if on else 'OFF'}")
# -- Polarity --
def set_polarity(self, positive: bool) -> None:
"""Set voltage display polarity: True=POS, False=NEG."""
self.write(f"POLAR {'POS' if positive else 'NEG'}")
# -- Test Configuration --
def set_tconfig(self, mode: str) -> None:
"""Set test configuration: 'NORMAL', 'OCP', 'OPP', or 'SHORT'."""
self.write(f"TCONFIG {mode.upper()}")
def get_tconfig(self) -> str:
"""Query test configuration."""
resp = self.query("TCONFIG?")
m = {"1": "NORMAL", "2": "OCP", "3": "OPP", "4": "SHORT"}
return m.get(resp.strip(), resp)
# -- OCP Test Settings --
def set_ocp(self, start: float, step: float, stop: float) -> None:
"""Configure OCP test parameters (all in Amps)."""
self.write(f"OCP:START {start}")
self.write(f"OCP:STEP {step}")
self.write(f"OCP:STOP {stop}")
# -- OPP Test Settings --
def set_opp(self, start: float, step: float, stop: float) -> None:
"""Configure OPP test parameters (all in Watts)."""
self.write(f"OPP:START {start}")
self.write(f"OPP:STEP {step}")
self.write(f"OPP:STOP {stop}")
# -- VTH (Threshold Voltage) --
def set_vth(self, volts: float) -> None:
"""Set threshold voltage for OCP/OPP test."""
self.write(f"VTH {volts}")
def get_vth(self) -> float:
"""Query threshold voltage."""
return float(self.query("VTH?"))
# -- Short-circuit Test Time --
def set_stime(self, ms: float) -> None:
"""Set short-circuit test time in ms (0 = continuous)."""
self.write(f"STIME {ms}")
def get_stime(self) -> float:
"""Query short-circuit test time."""
return float(self.query("STIME?"))
# -- Test Start/Stop --
def start_test(self) -> None:
"""Start the configured test (OCP/OPP/SHORT)."""
self.write("START")
def stop_test(self) -> None:
"""Stop the current test."""
self.write("STOP")
def is_testing(self) -> bool:
"""Query whether a test is currently running."""
resp = self.query("TESTING?")
return resp.strip() == "1"
# -- Measurement Averaging --
def set_averaging(self, count: int) -> None:
"""Set measurement averaging count (1-64)."""
self.write(f"AVG {count}")
def get_averaging(self) -> int:
"""Query measurement averaging count."""
return int(self.query("AVG?"))
# -- Turbo Mode --
def set_turbo(self, on: bool) -> None:
"""Enable/disable turbo mode (1.5x current & power rating)."""
self.write(f"TURBO {'ON' if on else 'OFF'}")
# -- Limit Settings (GO/NG Judgment) --
def set_current_limits(self, high: float, low: float) -> None:
"""Set current high/low limits for GO/NG judgment (Amps)."""
self.write(f"IH {high}")
self.write(f"IL {low}")
def set_voltage_limits(self, high: float, low: float) -> None:
"""Set voltage high/low limits for GO/NG judgment (Volts)."""
self.write(f"VH {high}")
self.write(f"VL {low}")
def set_power_limits(self, high: float, low: float) -> None:
"""Set power high/low limits for GO/NG judgment (Watts)."""
self.write(f"WH {high}")
self.write(f"WL {low}")
# -- Battery Discharge Test --
def set_battery_test(
self,
uvp: float | None = None,
time_s: int | None = None,
ah: float | None = None,
wh: float | None = None,
) -> None:
"""Configure battery discharge test parameters.
Args:
uvp: Under-voltage protection cutoff (Volts).
time_s: Discharge time limit in seconds (0 = unlimited).
ah: AH capacity limit (0 = OFF, 0.1-19999.9).
wh: WH capacity limit (0 = OFF, 0.1-19999.9).
"""
if uvp is not None:
self.write(f"BATT:UVP {uvp}")
if time_s is not None:
self.write(f"BATT:TIME {time_s}")
if ah is not None:
self.write(f"BATT:AH {ah}")
if wh is not None:
self.write(f"BATT:WH {wh}")
def battery_test_start(self) -> None:
"""Start battery discharge test."""
self.write("BATT:TEST ON")
def battery_test_stop(self) -> None:
"""Stop battery discharge test."""
self.write("BATT:TEST OFF")
def get_battery_results(self) -> dict[str, str]:
"""Read battery test results (AH, WH, time, voltage)."""
return {
"AH": self.query("BATT:RAH?"),
"WH": self.query("BATT:RWH?"),
"TIME": self.query("BATT:RTIME?"),
"VOLT": self.query("BATT:RVOLT?"),
}
# -- Measurement Queries (THE KEY METHODS) --
def measure_current(self) -> float:
"""Read the actual load current in Amperes."""
resp = self.query("MEAS:CURR?")
return float(resp)
def measure_voltage(self) -> float:
"""Read the actual load voltage in Volts."""
resp = self.query("MEAS:VOLT?")
return float(resp)
def measure_power(self) -> float:
"""Read the actual load power in Watts."""
resp = self.query("MEAS:POW?")
return float(resp)
def measure_all(self) -> MeasurementResult:
"""Read voltage, current, and power in one call.
Uses MEAS:VC? which returns voltage,current in one response.
Power is queried separately.
"""
vc_resp = self.query("MEAS:VC?")
parts = vc_resp.split(",")
voltage = float(parts[0].strip())
current = float(parts[1].strip())
power = self.measure_power()
return MeasurementResult(
voltage=voltage,
current=current,
power=power,
)
# -- Send Raw Command --
def send(self, command: str) -> str | None:
"""Send a raw command. If it contains '?', returns the response."""
if "?" in command:
return self.query(command)
self.write(command)
return None