# Copyright (C) 2026 Bence Göblyös
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, version 3.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
# PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# this program. If not, see https://www.gnu.org/licenses/.
"""
This module contains the SourceMeter2400 class for interacting with Keithley 2400 SourceMeter series devices.
Current functionality focuses on using it as a source only, so 4-wire mode is not implemented yet.
Examples
--------
Below is a minimum working example showing how to use a Keithley 2400 series SourceMeter.
.. code-block:: python
import pyvisa
from Devices.Keithley import SourceMeter2400
rm = pyvisa.ResourceManager()
# Automatically find device based on serial number.
# A matching USB device will be used if it's found.'
# Otherwise, the hostname defined in the config is used to connect over the network.
sm = SourceMeter2400(rm, sn='04085563')
# Set the device to constant current mode at 100 uA with a voltage limit of 1 V
sm.setCC(100e-6, 1)
# Read back current and voltage
print("Current: {sm.I*1e6} uA")
print("Voltage: {sm.V*1e3} mV")
"""
from pathlib import Path
import logging
import json
import time
import sys
if "sphinx" in sys.modules:
PROJECT_ROOT = Path(".")
else:
PROJECT_ROOT = Path(__file__).resolve().parents[1]
[docs]
class SourceMeter2400():
"""
Driver class for Keithley 2400 SourceMeter series devices.
"""
def __init__(self, rm, sn = None, addressOverride=None, config = (PROJECT_ROOT / "Config" / "SourceMeter.json")):
"""
Parameters
----------
rm: pyvisa.ResourceManager
Resource manager instance with which to open the instrument
sn: str, optional
Serial number of the instrument. Used for grabbing the hostname from the config or finding the USB device.
If this is None, addressOverride should be specified.
addressOverride: str, optional
Manually specified VISA resource locator. Overrides sn.
If this is None, sn should be specified.
config: pathlib.Path, default: "Config/SourceMeter.json"
Config file path.
"""
self.logger = logging.getLogger('instrumpy.SourceMeter2400')
self.logger.propagate = True
self.logger.setLevel(logging.NOTSET)
self.logger.debug("Logger initialized.")
self.config = config
self.addr = None
if addressOverride is not None:
self.addr = addressOverride
elif sn is not None:
# try USB
candidates = rm.list_resources(f'?*{sn}?*::INSTR')
if len(candidates) >= 1:
if len(candidates) > 1:
self.logger.warning(f'Multiple instruments found ({candidates}), picking first option.')
self.addr = candidates[0]
elif config is not None:
# try TCPIP
try:
with open(config, "r") as file:
d = json.load(file)
hostname = d.get('devices', {}).get(sn, {}).get('hostname', None)
if hostname is not None:
self.addr = f'TCPIP::{hostname}::INSTR'
else:
self.logger.warning(f'Key devices.{sn}.hostname is not present in the config.')
self.addr = None
except Exception as e:
self.logger.warning(f"Configuration could not be loaded: {e}")
self.addr = None
else:
self.logger.warning(f'Either sn or addressOverride needs to be set.')
self.addr = None
if self.addr is not None:
self.device = rm.open_resource(self.addr)
else:
self.device = None
self.logger.error(f'No valid address could be found, device will not be opened.')
self.device.read_termination = '\n'
[docs]
def beep(self, f, t):
"""
Plays a beep of the given frequency for the given time.
Blocks until note is played.
Parameters
----------
f: float
Frequency of beep in Hz. Use 0 for silence.
t: float
Duration of beep in seconds.
"""
start = time.time()
if f > 0:
self.device.write(f'SYST:BEEP {f}, {t}')
# wait however long it takes to play the note
while (time.time() - start) < t:
pass
[docs]
def chime(self, name):
"""
Plays a chime defined in the config file.
See the 'chimes' key of the config.
Parameters
----------
name: str
Name of chime in the config file.
"""
if self.config is None:
self.logger.warning('Config file is not defined, cannot find predefined chimes')
return
chime = []
try:
with open(self.config, "r") as file:
d = json.load(file)
chime = d.get('chimes', {}).get(name, [])
except Exception as e:
self.logger.warning(f"Configuration could not be loaded: {e}")
return
if len(chime) == 0:
self.logger.warning(f"Chime could not be loaded, check that the chimes.{name} key exists.")
return
for note in chime:
self.beep(
note.get('f', 0),
note.get('t', 0)
)
# Output toggle
[docs]
def getOutput(self):
"""
Get the output state.
Returns
-------
output: bool
If True, the outputs are enabled.
If False, the outputs are disabled.
"""
resp = self.device.query('OUTP:STAT?')
return int(resp) == 1
[docs]
def setOutput(self, output):
"""
Set the output state.
Parameters
----------
output: bool
If True, the outputs are enabled.
If False, the outputs are disabled.
"""
cmd = f'OUTP:STAT {'1' if output else '0'}'
self.device.write(cmd)
#: bool: Gets or sets whther the output is active.
output = property(fget=getOutput, fset=setOutput)
[docs]
def on(self):
"""
Turns the ouptut on.
"""
self.setOutput(True)
[docs]
def off(self):
"""
Turns the ouptut off.
"""
self.setOutput(False)
# Select front or back terminals
[docs]
def getFront(self):
"""
Get the front panel ports as active.
Returns
----------
front: bool
If True, the front panel ports are active.
If False, the back panel ports are active.
"""
resp = self.device.query('ROUT:TERM?')
return resp == 'FRON'
[docs]
def setFront(front = True):
"""
Set the front panel ports as active.
Parameters
----------
front: bool, default: True
If True, the front panel ports are active.
If False, the back panel ports are active.
"""
cmd = f'ROUT:TERM {'FRON' if front else 'REAR'}'
self.device.write(cmd)
#: bool: Gets or sets the active status of the front panel ports. True corresponds to the front, while False means back panel.
front = property(fget=getFront, fset=setFront)
# CV and CC modes
[docs]
def setCV(self, V, I_lim = None):
"""
Set the device to constant voltage mode.
Parameters
----------
V: float
Target voltage in volts.
I_lim: float, optional
Current limit in amperes.
"""
self.device.write('SOUR:FUNC VOLT')
self.device.write(f'SOUR:VOLT {V}')
if I_lim is not None:
self.device.write(f'SOUR:VOLT:ILIM {I_lim}')
[docs]
def setCC(self, I, V_lim = None):
"""
Set the device to constant current mode.
Parameters
----------
I: float
Target current in amperes.
V_lim: float, optional
Voltage limit in volts.
"""
self.device.write('SOUR:FUNC CURR')
self.device.write(f'SOUR:CURR {I}')
if V_lim is not None:
self.device.write(f'SOUR:CURR:VLIM {V_lim}')
[docs]
def getV(self):
return float(self.device.query('MEAS:VOLT?'))
#: float: Measure voltage in volts on the force or sense pins depending on mode.
V = property(fget=getV)
[docs]
def getI(self):
return float(self.device.query('MEAS:CURR?'))
#: float: Measure current in amperes on the force or sense pins depending on mode.
I = property(fget=getI)
[docs]
def getR(self):
return float(self.device.query('MEAS:RES?'))
#: float: Measure resistance in Ohms in 2 or 4-point configuration depending on mode.
R = property(fget=getR)