Source code for semi_cr.core.lab.drivers.keithley.keithley_2636b

"""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", ]