The manual's pipe symbol (|) means OR - e.g. CC|CURR:HIGH means use either "CC" or "CURR:HIGH" as the command. The combined form "CC CURR:HIGH" was not being parsed correctly by the device for queries. Now using the second form (CURR:HIGH, RES:HIGH, VOLT:HIGH) which works for both set and query operations. Verified readback works: set 1A -> reads 0.9999A. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
558 lines
17 KiB
Python
558 lines
17 KiB
Python
"""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 115200.
|
|
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 = 115200,
|
|
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"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"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"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"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"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"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
|