Skip to content
5 changes: 3 additions & 2 deletions docs/release-notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ Release Notes
*************
Version 0.6.5
*************
Release Date xx-xx-xxxx
Release Date XX/XX/XXXX

Major Changes
################
- Support for Python 3.8 to 3.11 has been dropped. Now only Python >3.12 is supported.

New Features
############
- New DCLoad driver added for the Rigol DL3021 DC electronic load.

Improvements
############
Expand All @@ -25,7 +26,7 @@ Improvements
- Created a new FixateError base class for all exceptions raised by fixate to use. It inherits from Exception instead of BaseExcepetion to improve error handling.
- DSO Driver function 'waveform_values' now returns a single channels x and y data as two separate lists, without re-acquiring the signal. This function should
now be called after performing signal acquisition.
- Invert channel and vtime funcitons implemented in the DSO driver.
- Invert channel and vtime functions implemented in the DSO driver.

*************
Version 0.6.4
Expand Down
2 changes: 1 addition & 1 deletion src/fixate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@

from fixate.main import run_main_program as run

__version__ = "0.6.4"
__version__ = "0.6.5"
42 changes: 42 additions & 0 deletions src/fixate/drivers/dcload/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""
DC ELectronic Load driver
=========================

Use `DCLoad.open()` to connect to a DC electronic load.
Functions are dictated by the abstract superclass ``DCLoad`` in helper.py
"""

import pyvisa

import fixate.drivers
from fixate.config import find_instrument_by_id
from fixate.drivers import InstrumentNotFoundError, InstrumentOpenError
from fixate.drivers.dcload.rigol_dl3021 import RigolDL3021
from fixate.drivers.dcload.helper import DCLoad


def open() -> DCLoad:
"""
Connect to a DC electronic load.

Searches for a configured instrument and returns the first one found.

Returns:
DCLoad: open connection to the DCLoad
"""
for DCLoad in (RigolDL3021,):
instrument = find_instrument_by_id(DCLoad.REGEX_ID)
if instrument is not None:
# We've found a configured instrument so try to open it
rm = pyvisa.ResourceManager()
try:
resource = rm.open_resource(instrument.address)
except pyvisa.VisaIOError as e:
raise InstrumentOpenError(
f"Unable to open DCLoad: {instrument.address}"
) from e
# Instantiate driver with connected instrument
driver = DCLoad(resource)
fixate.drivers.log_instrument_open(driver)
return driver
raise InstrumentNotFoundError
49 changes: 49 additions & 0 deletions src/fixate/drivers/dcload/helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from typing import Protocol, Literal
from fixate.drivers import DriverProtocol

# List of modes that an electronic load can be set to
Mode = Literal[
"constant_current",
"constant_voltage",
"constant_resistance",
"constant_power",
]

CurrentRange = Literal[
"low",
"high",
"default",
]


class DCLoad(DriverProtocol, Protocol):
"""Abstract class for DC electronic load drivers."""

REGEX_ID: str

def __init__(self, instrument) -> None:
...

def reset(self) -> None:
"""Reset the instrument to a known state."""
...

def get_identity(self) -> str:
"""Return instrument identity string."""
...

def set_mode(self, mode: Mode) -> None:
"""Set the mode of the load (for example, constant current, constant voltage, etc.)."""
...

def set_enabled(self, enable: bool) -> None:
"""Enable (TRUE) or disable (FALSE) the load."""
...

def set_current(self, current: float) -> None:
"""Set the load current to the specified value in Amps."""
...

def set_current_range(self, current_range: CurrentRange) -> None:
"""Set the current range to the specified value in Amps."""
...
102 changes: 102 additions & 0 deletions src/fixate/drivers/dcload/rigol_dl3021.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from typing import Any
from fixate.core.exceptions import InstrumentError, ParameterError
from fixate.drivers.dcload.helper import DCLoad, Mode, CurrentRange


class RigolDL3021(DCLoad):
"""Driver for Rigol DL3021 DC electronic load."""

REGEX_ID = "DL3021"
INSTR_TYPE = "VISA"

def __init__(self, instrument: Any) -> None:
self.instrument = instrument
self.instrument.timeout = 1000
self._set_current_range: float | None = None

def _write(self, command: str) -> None:
"""Write a command to the instrument."""
try:
self.instrument.write(command)
except Exception as e:
raise InstrumentError(f"Error writing to instrument: {e}") from e

def _query(self, command: str) -> str:
"""Query the instrument and return the response."""
try:
return self.instrument.query(command)
except Exception as e:
raise InstrumentError(f"Error querying instrument: {e}") from e

def reset(self) -> None:
"""Reset the instrument to default factory settings."""
self._write("*RST")

def get_identity(self) -> str:
"""Finds the instrument's manufacturer, model number, serial number, and firmware version."""
return self._query("*IDN?").strip()

def set_mode(self, mode: Mode) -> None:
"""Set the mode of the load (for example, constant current, constant voltage, etc.)."""
if mode == "constant_current":
scpi_mode = "CURR"
elif mode == "constant_voltage":
scpi_mode = "VOLT"
elif mode == "constant_resistance":
scpi_mode = "RES"
elif mode == "constant_power":
scpi_mode = "POW"
else:
raise ParameterError(f"Invalid mode: {mode}")

return self._write(f":SOUR:FUNC {scpi_mode}")

def _get_mode(self) -> str:
"""Get the mode of the load."""
return self._query(":SOUR:FUNC?").strip()

def set_enabled(self, enable: bool) -> None:
"""Enable (TRUE) or disable (FALSE) the load."""
value = "ON" if enable else "OFF"
return self._write(f":SOUR:INP:STAT {value}")

def _get_enabled(self) -> bool:
"""Get the enabled state of the load."""
state = self._query(":SOUR:INP:STAT?").strip()
return state == "1"

def _get_current(self) -> float:
"""Get the current value in Amps."""
return float(self._query(":SOUR:CURR:LEV:IMM?").strip())

def set_current_range(self, current_range: CurrentRange) -> None:
"""Set the current range to low (4A) or high (40A) or default (40A)."""
if current_range == "low":
scpi_mode = "MIN"
self._set_current_range = 4.0
elif current_range == "high":
scpi_mode = "MAX"
self._set_current_range = 40.0
elif current_range == "default":
self._set_current_range = 40.0
scpi_mode = "DEF"
else:
raise ParameterError(f"Invalid current range: {current_range}")

return self._write(f":SOUR:CURR:RANG {scpi_mode}")

def _get_current_range(self) -> float:
"""Get the current range in Amps. 4 or 40 for the DL3021."""
return float(self._query(":SOUR:CURR:RANG?").strip())

def set_current(self, current: float) -> None:
"""Set the current to the specified value in Amps."""
if self._set_current_range is None:
raise ParameterError("Current range must be set before setting current.")

if current > self._set_current_range:
raise ParameterError(
f"Current {current}A exceeds set current range of {self._set_current_range}A"
)

return self._write(f":SOUR:CURR:LEV:IMM {current}")
12 changes: 12 additions & 0 deletions test/conftest.py
Comment thread
daniel-montanari marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ def dmm():
yield dmm


@pytest.fixture()
def dcload():
from drivers.J413 import dm

try:
dcload = dm.dcload
except VisaIOError:
assert False, "Could not open DCLoad."

yield dcload


@pytest.fixture()
def pps():
from drivers.J413 import dm
Expand Down
9 changes: 8 additions & 1 deletion test/drivers/J413.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from fixate.drivers import pps, dmm, ftdi, dso, funcgen
from fixate.drivers import pps, dmm, ftdi, dso, funcgen, dcload
from fixate.core.jig_mapping import AddressHandler, JigDriver, RelayMatrixMux


class DriverManager:
def __init__(self):
self._funcgen = None
self._dmm = None
self._dcload = None
self._ftdi_J413 = None
self._ftdi_mux = None
self._dso = None
Expand Down Expand Up @@ -34,6 +35,12 @@ def dmm(self):
self._dmm.instrument.timeout = 7000
return self._dmm

@property
def dcload(self):
if self._dcload is None:
self._dcload = dcload.open()
return self._dcload

@property
def pps(self):
if self._pps is None:
Expand Down
105 changes: 105 additions & 0 deletions test/drivers/test_rigol_dl3021.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import time
from typing import Literal
import pytest
from fixate.config import load_config
import fixate.drivers.dcload
from fixate.drivers.dcload.rigol_dl3021 import RigolDL3021

load_config() # Load fixate config file


@pytest.mark.drivertest
def test_open_dcload():
"""Test that we can open a connection to the DC load."""
opened = fixate.drivers.dcload.open()
assert opened, "Could not open DCLoad"


@pytest.mark.drivertest
def test_get_identity(dcload: RigolDL3021):
"""Test that we can get the identity string from the DC load."""
iden = dcload.get_identity()
assert "DL3021" in iden


@pytest.mark.drivertest
def test_enable_load(dcload: RigolDL3021):
"""Test that we can enable and disable the load."""
# Turn load ON
dcload.set_enabled(True)
assert dcload._get_enabled() is True, "Load should be ON"
# Turn load OFF
dcload.set_enabled(False)
assert dcload._get_enabled() is False, "Load should be OFF"


@pytest.mark.drivertest
@pytest.mark.parametrize(
"mode, expected",
[
("constant_current", "CC"),
("constant_voltage", "CV"),
("constant_resistance", "CR"),
("constant_power", "CP"),
],
)
def test_set_mode(
dcload: RigolDL3021,
mode: Literal["constant_current"]
Comment thread
daniel-montanari marked this conversation as resolved.
| Literal["constant_voltage"]
| Literal["constant_resistance"]
| Literal["constant_power"],
expected: Literal["CC"] | Literal["CV"] | Literal["CR"] | Literal["CP"],
):
"""Verify that set_mode correctly sets the instrument mode."""
dcload.set_mode(mode)
actual = dcload._get_mode()

assert actual == expected, f"Expected {expected}, got {actual}"

dcload.set_enabled(False)


@pytest.mark.drivertest
@pytest.mark.parametrize(
"min_current, max_current, num_steps, duration",
[
(0.0, 1.0, 6, 2),
],
)
def test_set_current(
dcload: RigolDL3021,
min_current: float,
max_current: float,
num_steps: Literal[6],
duration: Literal[2],
):
"""Test setting the current on the DC load."""
# Generate evenly spaced current values (inclusive)
if num_steps == 1:
currents = [min_current]
else:
step = (max_current - min_current) / (num_steps - 1)
currents = [min_current + i * step for i in range(num_steps)]

# Set mode and range
dcload.set_mode("constant_current")
dcload.set_current_range("high")
dcload.set_enabled(True)

for current in currents:
dcload.set_current(current)
time.sleep(duration)
setpoint = float(dcload._get_current())

assert abs(setpoint - current) < 1e-3, f"Expected {current}A, got {setpoint}A"

dcload.set_enabled(False)


@pytest.mark.drivertest
def test_reset(dcload: RigolDL3021):
"""Test that resetting the instrument clears errors."""
dcload.reset()
query = dcload._query("SYST:ERR?")
assert '0,"No error"' == query.strip()
Loading