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:
5
prodigit3366g/__init__.py
Normal file
5
prodigit3366g/__init__.py
Normal 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
343
prodigit3366g/cli.py
Normal 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
557
prodigit3366g/driver.py
Normal 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
|
||||
Reference in New Issue
Block a user