"""PyVISA driver for the two-channel Keithley 2636B SourceMeter."""
from __future__ import annotations
import math
from typing import Any
import pyvisa
[docs]
class KeithleyError(RuntimeError):
"""Base error raised by the Keithley 2636B driver."""
[docs]
class KeithleyConnectionError(KeithleyError):
"""Raised when the VISA session is unavailable or communication fails."""
[docs]
class KeithleyDisconnectionError(KeithleyConnectionError):
"""Raised when the VISA resource manager or session cannot be closed."""
[docs]
class KeithleyMeasurementError(KeithleyError):
"""Raised when a response from the instrument cannot be interpreted."""
[docs]
class Keithley2636b: # ruff: ignore[too-many-public-methods]
"""Control both SMU channels on a Keithley 2636B over VISA/TSP.
Source and measurement methods configure the selected channel but do not
silently change its output state, except `set_output` and `source_off`.
"""
DEFAULT_TIMEOUT_MS = 10_000
CHANNELS = frozenset({"a", "b"})
FILTER_TYPES = frozenset({"FILTER_REPEAT_AVG", "FILTER_MOVING_AVG", "FILTER_MEDIAN"})
MIN_NPLC = 0.001
MAX_NPLC = 25.0
MIN_FILTER_COUNT = 1
MAX_FILTER_COUNT = 100
def __init__(
self,
*,
resource_manager: Any | None = None,
timeout_ms: int = DEFAULT_TIMEOUT_MS,
) -> None:
"""Create a driver and its VISA resource manager.
Args:
resource_manager: Optional PyVISA-compatible manager, useful when
the caller shares a manager or supplies a test double.
timeout_ms: Read/write timeout in milliseconds for the VISA session.
Raises:
ValueError: If `timeout_ms` is not a positive integer.
"""
if isinstance(timeout_ms, bool) or not isinstance(timeout_ms, int) or timeout_ms <= 0:
raise ValueError("timeout_ms must be a positive integer")
self.rm = resource_manager if resource_manager is not None else pyvisa.ResourceManager()
self._owns_resource_manager = resource_manager is None
self._resource_manager_closed = False
self.timeout_ms = timeout_ms
self.device: Any | None = None
@property
def connected(self) -> bool:
"""Return whether a live VISA session is open."""
return self.device is not None and bool(self.device.session)
[docs]
def connect(self, address: str) -> bool:
"""Open a VISA resource and verify that it identifies as a 2636B.
Args:
address: VISA resource name, for example `TCPIP0::192.168.0.2::INSTR`.
Returns:
`True` after the instrument identity has been verified.
Raises:
KeithleyConnectionError: If the resource cannot be opened, queried, or
does not identify as a Keithley 2636B.
"""
if not address or not address.strip():
raise ValueError("address must be a non-empty VISA resource name")
if self.connected:
return True
if self._owns_resource_manager and self._resource_manager_closed:
self.rm = pyvisa.ResourceManager()
self._resource_manager_closed = False
device = None
try:
device = self.rm.open_resource(address.strip())
device.read_termination = "\n" # type: ignore
device.write_termination = "\n"
device.timeout = self.timeout_ms
identity = str(device.query("*IDN?")).strip() # pyright: ignore[reportAttributeAccessIssue]
if "2636B" not in identity.upper():
raise KeithleyConnectionError(f"VISA resource {address!r} did not identify as a Keithley 2636B: {identity!r}")
self.device = device
return True
except KeithleyConnectionError:
if device is not None:
device.close()
raise
except pyvisa.VisaIOError as error:
if device is not None:
try:
device.close()
except pyvisa.VisaIOError:
pass
raise KeithleyConnectionError(f"Could not connect to Keithley at {address!r}") from error
[docs]
def disconnect(self) -> bool:
"""Close the current VISA session and any resource manager owned here.
Returns:
`True` when the session and owned resource manager are closed.
Raises:
KeithleyDisconnectionError: If a session or owned manager fails to close.
"""
device, self.device = self.device, None
errors: list[Exception] = []
if device is not None:
try:
device.close()
except pyvisa.VisaIOError as error:
errors.append(error)
if self._owns_resource_manager:
try:
self.rm.close()
self._resource_manager_closed = True
except pyvisa.VisaIOError as error:
errors.append(error)
if errors:
raise KeithleyDisconnectionError("Could not close the Keithley VISA connection") from errors[0]
return True
[docs]
def check_connection(self) -> bool:
"""Query `*IDN?` and return whether this session reaches a 2636B.
Raises:
KeithleyConnectionError: If there is no open session or the query fails.
"""
identity = str(self._require_device().query("*IDN?")).strip()
if "2636B" not in identity.upper():
raise KeithleyConnectionError(f"Unexpected Keithley identity: {identity!r}")
return True
[docs]
def get_IDN(self) -> str:
"""Return the instrument's raw `*IDN?` response."""
return str(self._require_device().query("*IDN?")).strip()
[docs]
def set_source(
self,
channel: str,
source: str,
level: float,
compliance: float | None = None,
) -> None:
"""Set voltage or current source mode, level, and optional compliance.
The channel output is not enabled by this method. When `compliance` is
omitted, the instrument's existing limit for the selected source mode is
read and written back unchanged.
Args:
channel: SMU channel, `"a"` or `"b"` (case-insensitive).
source: Source mode, `"voltage"` or `"current"`.
level: Source level in volts or amperes, according to `source`.
compliance: Optional positive current limit in voltage mode or voltage
limit in current mode.
Raises:
ValueError: If a channel, mode, level, or compliance value is invalid.
KeithleyConnectionError: If the VISA session or TSP command fails.
"""
selected_channel = self._validate_channel(channel)
if not isinstance(source, str):
raise TypeError("source must be 'voltage' or 'current'")
mode = source.lower()
if mode not in {"voltage", "current"}:
raise ValueError("source must be 'voltage' or 'current'")
selected_level = self._finite_number(level, "level")
mode_constant = "OUTPUT_DCVOLTS" if mode == "voltage" else "OUTPUT_DCAMPS"
level_suffix = "v" if mode == "voltage" else "i"
compliance_suffix = "limiti" if mode == "voltage" else "limitv"
limit = (
self._finite_number(compliance, "compliance")
if compliance is not None
else self._query_float(f"print(smu{selected_channel}.source.{compliance_suffix})")
)
if limit <= 0:
raise ValueError("compliance must be greater than zero")
self._write(
f"smu{selected_channel}.source.func = smu{selected_channel}.{mode_constant}\n"
f"smu{selected_channel}.source.autorange{level_suffix} = smu{selected_channel}.AUTORANGE_ON\n"
f"smu{selected_channel}.source.level{level_suffix} = {selected_level:.12g}\n"
f"smu{selected_channel}.source.{compliance_suffix} = {limit:.12g}"
)
[docs]
def set_compliance(self, channel: str, compliance: float) -> None:
"""Set the active mode's current or voltage compliance limit.
Args:
channel: SMU channel, `"a"` or `"b"` (case-insensitive).
compliance: Positive limit in amperes for voltage mode or volts for
current mode.
Raises:
ValueError: If the channel or limit is invalid.
KeithleyConnectionError: If the session or TSP command fails.
"""
selected_channel = self._validate_channel(channel)
source = self.get_source(selected_channel)["source"]
suffix = "limiti" if source == "voltage" else "limitv"
limit = self._finite_number(compliance, "compliance")
if limit <= 0:
raise ValueError("compliance must be greater than zero")
self._write(f"smu{selected_channel}.source.{suffix} = {limit:.12g}")
[docs]
def set_filter(self, channel: str, filter_type: str) -> None:
"""Set the digital measurement filter algorithm for one channel.
Args:
channel: SMU channel, `"a"` or `"b"` (case-insensitive).
filter_type: One of `FILTER_REPEAT_AVG`, `FILTER_MOVING_AVG`, or
`FILTER_MEDIAN`.
Raises:
ValueError: If the channel or filter type is unsupported.
KeithleyConnectionError: If the session or TSP command fails.
"""
selected_channel = self._validate_channel(channel)
if filter_type not in self.FILTER_TYPES:
raise ValueError(f"filter_type must be one of {sorted(self.FILTER_TYPES)}")
self._write(f"smu{selected_channel}.measure.filter.type = smu{selected_channel}.{filter_type}")
[docs]
def set_nplc(self, channel: str, nplc: float) -> None:
"""Set the measurement integration time in power-line cycles.
Args:
channel: SMU channel, `"a"` or `"b"` (case-insensitive).
nplc: Integration time from 0.001 through 25 power-line cycles.
Raises:
ValueError: If the channel or NPLC is outside the supported range.
KeithleyConnectionError: If the session or TSP command fails.
"""
selected_channel = self._validate_channel(channel)
value = self._finite_number(nplc, "nplc")
if not self.MIN_NPLC <= value <= self.MAX_NPLC:
raise ValueError(f"nplc must be between {self.MIN_NPLC} and {self.MAX_NPLC}")
self._write(f"smu{selected_channel}.measure.nplc = {value:.12g}")
[docs]
def set_settling_time(self, channel: str, time: float) -> None:
"""Set the source delay in seconds; use `-1` for automatic delay.
Args:
channel: SMU channel, `"a"` or `"b"` (case-insensitive).
time: Non-negative delay in seconds, or `-1` for range-dependent delay.
Raises:
ValueError: If the channel or delay is invalid.
KeithleyConnectionError: If the session or TSP command fails.
"""
selected_channel = self._validate_channel(channel)
value = self._finite_number(time, "time")
if value < 0 and value != -1:
raise ValueError("time must be non-negative or -1 for automatic delay")
delay = f"smu{selected_channel}.DELAY_AUTO" if value == -1 else f"{value:.12g}"
self._write(f"smu{selected_channel}.source.delay = {delay}")
[docs]
def get_source(self, channel: str) -> dict[str, str | float]:
"""Read source mode and active source level from one channel.
Args:
channel: SMU channel, `"a"` or `"b"` (case-insensitive).
Returns:
A dictionary with `source` (`"voltage"` or `"current"`) and `level`.
Raises:
KeithleyMeasurementError: If the source mode cannot be decoded.
KeithleyConnectionError: If a query fails.
"""
selected_channel = self._validate_channel(channel)
mode_code = self._query_float(f"print(smu{selected_channel}.source.func)")
voltage_code = self._query_float(f"print(smu{selected_channel}.OUTPUT_DCVOLTS)")
current_code = self._query_float(f"print(smu{selected_channel}.OUTPUT_DCAMPS)")
if mode_code == voltage_code:
return {
"source": "voltage",
"level": self._query_float(f"print(smu{selected_channel}.source.levelv)"),
}
if mode_code == current_code:
return {
"source": "current",
"level": self._query_float(f"print(smu{selected_channel}.source.leveli)"),
}
raise KeithleyMeasurementError(f"Unexpected source mode on channel {selected_channel}: {mode_code}")
[docs]
def get_compliance(self, channel: str) -> float:
"""Read the compliance limit for the channel's active source mode.
Args:
channel: SMU channel, `"a"` or `"b"` (case-insensitive).
Returns:
Current limit in amperes for voltage mode, or voltage limit in volts
for current mode.
"""
selected_channel = self._validate_channel(channel)
source = self.get_source(selected_channel)["source"]
suffix = "limiti" if source == "voltage" else "limitv"
return self._query_float(f"print(smu{selected_channel}.source.{suffix})")
[docs]
def get_filter(self, channel: str) -> str:
"""Read the configured digital measurement filter type.
Args:
channel: SMU channel, `"a"` or `"b"` (case-insensitive).
Returns:
The filter constant name.
Raises:
KeithleyMeasurementError: If the instrument returns an unknown filter.
"""
selected_channel = self._validate_channel(channel)
value = self._query_float(f"print(smu{selected_channel}.measure.filter.type)")
for filter_type in sorted(self.FILTER_TYPES):
code = self._query_float(f"print(smu{selected_channel}.{filter_type})")
if value == code:
return filter_type
raise KeithleyMeasurementError(f"Unexpected filter type on channel {selected_channel}: {value}")
[docs]
def get_nplc(self, channel: str) -> float:
"""Read the measurement integration time in power-line cycles.
Args:
channel: SMU channel, `"a"` or `"b"` (case-insensitive).
Returns:
The configured NPLC value.
"""
selected_channel = self._validate_channel(channel)
return self._query_float(f"print(smu{selected_channel}.measure.nplc)")
[docs]
def get_settling_time(self, channel: str) -> float:
"""Read source delay in seconds, returning `-1` for automatic delay.
Args:
channel: SMU channel, `"a"` or `"b"` (case-insensitive).
Returns:
The configured delay in seconds or `-1.0` when delay is automatic.
"""
selected_channel = self._validate_channel(channel)
value = self._query_float(f"print(smu{selected_channel}.source.delay)")
auto_code = self._query_float(f"print(smu{selected_channel}.DELAY_AUTO)")
return -1.0 if value == auto_code else value
[docs]
def measure_resistance(self, channel: str) -> float:
"""Take and return one resistance reading without changing output state."""
return self._measure(channel, "r")
[docs]
def measure_voltage(self, channel: str) -> float:
"""Take and return one voltage reading without changing output state."""
return self._measure(channel, "v")
[docs]
def measure_current(self, channel: str) -> float:
"""Take and return one current reading without changing output state."""
return self._measure(channel, "i")
[docs]
def measure_power(self, channel: str) -> float:
"""Take and return one power reading without changing output state."""
return self._measure(channel, "p")
[docs]
def set_output(self, channel: str, enabled: bool) -> bool:
"""Set and verify whether one channel's source output is enabled.
Args:
channel: SMU channel, `"a"` or `"b"` (case-insensitive).
enabled: `True` to enable output, `False` to disable it.
Returns:
The output state read back from the instrument.
"""
selected_channel = self._validate_channel(channel)
if not isinstance(enabled, bool):
raise TypeError("enabled must be a bool")
output_state = "OUTPUT_ON" if enabled else "OUTPUT_OFF"
self._write(f"smu{selected_channel}.source.output = smu{selected_channel}.{output_state}")
actual = self.get_output_enabled(selected_channel)
if actual is not enabled:
raise KeithleyMeasurementError(f"Output state readback for channel {selected_channel} did not match {enabled}")
return enabled
[docs]
def get_output_enabled(self, channel: str) -> bool:
"""Read and decode the source output state for one channel.
Args:
channel: SMU channel, `"a"` or `"b"` (case-insensitive).
Returns:
Whether the channel output is enabled.
Raises:
KeithleyMeasurementError: If the instrument returns an unknown state.
"""
selected_channel = self._validate_channel(channel)
actual = self._query_float(f"print(smu{selected_channel}.source.output)")
on_code = self._query_float(f"print(smu{selected_channel}.OUTPUT_ON)")
off_code = self._query_float(f"print(smu{selected_channel}.OUTPUT_OFF)")
if actual == on_code:
return True
if actual == off_code:
return False
raise KeithleyMeasurementError(f"Unexpected output state on channel {selected_channel}: {actual}")
[docs]
def source_off(self) -> None:
"""Disable and verify both SMU outputs."""
errors: list[Exception] = []
for channel in sorted(self.CHANNELS):
try:
self.set_output(channel, False)
except Exception as error:
errors.append(error)
if errors:
raise KeithleyConnectionError("Could not verify both Keithley outputs off") from errors[0]
[docs]
def set_measure(
self,
channel: str,
*,
nplc: float,
filter_enabled: bool,
filter_type: str,
filter_count: int,
voltage_range: float | None = None,
current_range: float | None = None,
) -> None:
"""Configure integration time, digital filtering, and autorange/ranges.
Args:
channel: SMU channel, `"a"` or `"b"` (case-insensitive).
nplc: Measurement integration time, from 0.001 to 25 PLC.
filter_enabled: Whether to enable the selected digital filter.
filter_type: Supported Keithley filter constant.
filter_count: Filter count from 1 to 100.
voltage_range: Fixed voltage measurement range; `None` enables autorange.
current_range: Fixed current measurement range; `None` enables autorange.
Raises:
ValueError: If any supplied setting is outside the supported range.
KeithleyConnectionError: If a TSP command fails.
"""
selected_channel = self._validate_channel(channel)
if not isinstance(filter_enabled, bool):
raise TypeError("filter_enabled must be a bool")
if isinstance(filter_count, bool) or not isinstance(filter_count, int):
raise TypeError("filter_count must be an integer")
if not self.MIN_FILTER_COUNT <= filter_count <= self.MAX_FILTER_COUNT:
raise ValueError(f"filter_count must be between {self.MIN_FILTER_COUNT} and {self.MAX_FILTER_COUNT}")
self.set_nplc(selected_channel, nplc)
self.set_filter(selected_channel, filter_type)
self._write(f"smu{selected_channel}.measure.filter.count = {filter_count}")
enabled_constant = "FILTER_ON" if filter_enabled else "FILTER_OFF"
self._write(f"smu{selected_channel}.measure.filter.enable = smu{selected_channel}.{enabled_constant}")
self._set_measure_range(selected_channel, "v", voltage_range)
self._set_measure_range(selected_channel, "i", current_range)
def _measure(self, channel: str, quantity: str) -> float:
selected_channel = self._validate_channel(channel)
return self._query_float(f"print(smu{selected_channel}.measure.{quantity}())")
def _set_measure_range(self, channel: str, quantity: str, value: float | None) -> None:
if value is None:
self._write(f"smu{channel}.measure.autorange{quantity} = smu{channel}.AUTORANGE_ON")
return
selected = self._finite_number(value, f"{quantity}_range")
if selected < 0:
raise ValueError(f"{quantity}_range must be non-negative")
if selected == 0:
self._write(f"smu{channel}.measure.autorange{quantity} = smu{channel}.AUTORANGE_ON")
return
self._write(f"smu{channel}.measure.autorange{quantity} = smu{channel}.AUTORANGE_OFF")
self._write(f"smu{channel}.measure.range{quantity} = {selected:.12g}")
def _require_device(self) -> Any:
if not self.connected:
raise KeithleyConnectionError("Keithley is not connected; call connect() first")
return self.device
def _write(self, command: str) -> None:
try:
self._require_device().write(command)
except pyvisa.VisaIOError as error:
raise KeithleyConnectionError(f"Keithley command failed: {command!r}") from error
def _query_float(self, command: str) -> float:
try:
response = self._require_device().query(command)
except pyvisa.VisaIOError as error:
raise KeithleyConnectionError(f"Keithley query failed: {command!r}") from error
try:
result = float(str(response).strip())
except (TypeError, ValueError) as error:
raise KeithleyMeasurementError(f"Expected numeric response to {command!r}, got {response!r}") from error
if not math.isfinite(result):
raise KeithleyMeasurementError(f"Instrument returned a non-finite value for {command!r}: {response!r}")
return result
@classmethod
def _validate_channel(cls, channel: str) -> str:
if not isinstance(channel, str):
raise TypeError("channel must be 'a' or 'b'")
selected = channel.lower()
if selected not in cls.CHANNELS:
raise ValueError("channel must be 'a' or 'b'")
return selected
@staticmethod
def _finite_number(value: float, name: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
raise ValueError(f"{name} must be a finite number")
return float(value)
__all__ = [
"Keithley2636b",
"KeithleyConnectionError",
"KeithleyDisconnectionError",
"KeithleyError",
"KeithleyMeasurementError",
]