diff --git a/README.rst b/README.rst index 0557657..bb331ca 100644 --- a/README.rst +++ b/README.rst @@ -27,6 +27,7 @@ Contributors * Nicolas Bruyant * Loïc Guilmard (loic.guilmard@insa-lyon.fr) * Anthony Buthod (anthony.buthod@insa-lyon.fr) +* Arnaud Meyer (arnaud.meyer@univ-st-etienne.fr) Instruments =========== @@ -37,6 +38,7 @@ Actuator ++++++++ * **Keithley2400**: Sourcemeter Keithley 2400 (using pymeasure intermediate package) +* **Keithley2600**: Keithley 2600 series Sourcemeter Viewer0D ++++++++ @@ -44,6 +46,7 @@ Viewer0D * **Keithley_Pico**: Pico-Amperemeter Keithley 648X Series, 6430 and 6514 * **Keithley2100**: Multimeter Keithley 2100 * **Keithley2110**: Multimeter Keithley 2110 +* **Keithley2600**: Keithley 2600 series Sourcemeter * **Keithley2700**: Keithley 2700 Multimeter/Switch System -- RS-232/GPIB -- 2 slots (7700 series modules) * **Keithley2701**: Keithley 2701 Ethernet Multimeter/Switch System -- Ethernet/RS-232 -- 2 slots (7700 series modules) -* **Keithley2750**: Keithley 2750 Multimeter/Switch System -- RS-232/GPIB -- 2 slots (7700 series modules) \ No newline at end of file +* **Keithley2750**: Keithley 2750 Multimeter/Switch System -- RS-232/GPIB -- 2 slots (7700 series modules) diff --git a/plugin_info.toml b/plugin_info.toml index ddbdee2..7505293 100644 --- a/plugin_info.toml +++ b/plugin_info.toml @@ -11,7 +11,7 @@ license = 'MIT' [plugin-install] #packages required for your plugin: -packages-required = ["pyvisa", "pyvisa-py", "pymeasure", "zeroconf",'pymodaq>=4.0'] +packages-required = ["pyvisa", "pyvisa-py", "pymeasure", "pyusb", "zeroconf",'pymodaq>=4.0'] [features] # defines the plugin features contained into this plugin instruments = true # true if plugin contains instrument classes (else false, notice the lowercase for toml files) diff --git a/src/pymodaq_plugins_keithley/daq_move_plugins/daq_move_Keithley2600.py b/src/pymodaq_plugins_keithley/daq_move_plugins/daq_move_Keithley2600.py new file mode 100644 index 0000000..d2c7c23 --- /dev/null +++ b/src/pymodaq_plugins_keithley/daq_move_plugins/daq_move_Keithley2600.py @@ -0,0 +1,238 @@ +from typing import Union, List, Dict +from pymodaq.control_modules.move_utility_classes import (DAQ_Move_base, comon_parameters_fun, + main, DataActuatorType, DataActuator) + +from pymodaq_utils.utils import ThreadCommand # object used to send info back to the main thread +from pymodaq_gui.parameter import Parameter +from collections.abc import Callable + +import pyvisa +from pymodaq_plugins_keithley.hardware.keithley2600.keithley2600_VISADriver import Keithley2600VISADriver, Keithley2600Channel, get_VISA_resources + + +# Helper functions +def _build_param(name, title, type, value, limits=None, unit=None, **kwargs): + params = {} + params["name"] = name + params["title"] = title + params["type"] = type + params["value"] = value + if limits is not None: + params["limits"] = limits + if unit is not None: + params["suffix"] = unit + params["siPrefix"] = True + for argn, argv in kwargs.items(): + params[argn] = argv + return params + + +class DAQ_Move_Keithley2600(DAQ_Move_base): + """ Instrument plugin class for an actuator. + + This object inherits all functionalities to communicate with PyMoDAQ’s DAQ_Move module through inheritance via + DAQ_Move_base. It makes a bridge between the DAQ_Move module and the Python wrapper of a particular instrument. + + Compatible devices: Keithley 2600 series sourcemeters + Tested on: Keithley 2636B / PyMoDAQ 5.1.1 / Ubuntu 24.04 LTS + Installation instructions: no special drivers other than PyVISA + + Attributes: + ----------- + controller: object + The particular object that allow the communication with the hardware, in general a python wrapper around the + hardware library. + """ + + # General parameters + is_multiaxes = False + _axis_names: Union[List[str], Dict[str, int]] = ["Source"] + _controller_units: Union[str, List[str]] = "V" + _epsilon: Union[float, List[float]] = 0.01 + data_actuator_type = DataActuatorType.DataActuator + + # Parameter tree + params = comon_parameters_fun(is_multiaxes, axis_names=_axis_names, epsilon=_epsilon) + [ + _build_param("resource_name", "VISA resource", "list", "", limits=get_VISA_resources()), + _build_param("channel", "Channel", "str", "A"), + _build_param("autorange", "Autorange", "bool", True), + _build_param("type", "Source type", "list", "", limits=["Voltage", "Current"]), + ] + + + def ini_attributes(self): + # For autocompletion + self.controller: Keithley2600VISADriver = None + self.channel: Keithley2600Channel = None + self._meas_function: Callable = None + self._move_function: Callable[float] = None + + + @property + def v_source(self): + """Returns True if source type is set to voltage.""" + return self.settings["type"] == "Voltage" + + + @property + def i_source(self): + """Returns True if source is type set to current.""" + return self.settings["type"] == "Current" + + + def _set_source_type(self): + """Adjust units and control function according to the selected source type.""" + + # Voltage source + if self.v_source: + self.axis_unit = "V" + self._meas_function = self.channel.measureV + self._move_function = self.channel.sourceV + + # Current source + elif self.i_source: + self.axis_unit = "A" + self._meas_function = self.channel.measureI + self._move_function = self.channel.sourceI + + # Unknown source type + else: + source_type = self.settings["type"] + raise ValueError(f"Unknown source type: {source_type}") + + # Update displayed value + self.current_value = self._meas_function() + self.emit_value(self.current_value) + + + def get_actuator_value(self): + """Get the current value from the hardware with scaling conversion. + + Returns + ------- + float: The position obtained after scaling conversion. + """ + pos = DataActuator(data=self._meas_function(), units=self.axis_unit) + pos = self.get_position_with_scaling(pos) + return pos + + + def user_condition_to_reach_target(self) -> bool: + """ Implement a condition for exiting the polling mechanism and specifying that the + target value has been reached + + Returns + ------- + bool: if True, PyMoDAQ considers the target value has been reached + """ + return True + + + def close(self): + """Terminate the communication protocol""" + if self.is_master: + self.controller.close() + + + def commit_settings(self, param: Parameter): + """Apply the consequences of a change of value in the detector settings + + Parameters + ---------- + param: Parameter + A given parameter (within detector_settings) whose value has been changed by the user + """ + # Dispatch arguments + name = param.name() + val = param.value() + unit = param.opts.get("suffix") + + # Change parameters in function of source type + if name == "type": + self._set_source_type() + + + def ini_stage(self, controller=None): + """Actuator communication initialization + + Parameters + ---------- + controller: (object) + custom object of a PyMoDAQ plugin (Slave case). None if only one actuator by controller (Master case) + + Returns + ------- + info: str + initialized: bool + False if initialization failed otherwise True + """ + # Get initialization parameters + resource_name = self.settings["resource_name"] + channel = self.settings["channel"] + autorange = self.settings["autorange"] + + # If stand-alone device, initialize controller object + if self.is_master: + + # Initialize device + self.controller = Keithley2600VISADriver(resource_name) + initialized = True + + # If slave device, retrieve controller object + else: + self.controller = controller + initialized = True + + # Initialize channel + self.channel = self.controller.create_channel(channel_name=channel, + autorange=autorange) + + # Change parameters in function of source type + self._set_source_type() + + # Initialization successful + info = "Keithey 2600 initialization finished." + return info, initialized + + + def move_abs(self, value: DataActuator): + """ Move the actuator to the absolute target defined by value + + Parameters + ---------- + value: (float) value of the absolute target positioning + """ + value = self.check_bound(value) #if user checked bounds, the defined bounds are applied here + self.target_value = value + value = self.set_position_with_scaling(value) # apply scaling if the user specified one + self._move_function(value.value(self.axis_unit)) + self.emit_status(ThreadCommand('Update_Status', [f"Moving abs to {value}"])) + + + def move_rel(self, value: DataActuator): + """ Move the actuator to the relative target actuator value defined by value + + Parameters + ---------- + value: (float) value of the relative target positioning + """ + value = self.check_bound(self.current_position + value) - self.current_position + self.target_value = value + self.current_position + value = self.set_position_relative_with_scaling(value) + self._move_function(self.target_value.value(self.axis_unit)) + self.emit_status(ThreadCommand('Update_Status', [f"Moving ref by {value}"])) + + + def move_home(self): + """Call the reference method of the controller""" + pass + + + def stop_motion(self): + """Stop the actuator and emits move_done signal""" + self.channel.off() + self.emit_status(ThreadCommand('Update_Status', [f"Stopping movement"])) + + +if __name__ == '__main__': + main(__file__) diff --git a/src/pymodaq_plugins_keithley/daq_viewer_plugins/plugins_1D/daq_1Dviewer_Keithley2600.py b/src/pymodaq_plugins_keithley/daq_viewer_plugins/plugins_1D/daq_1Dviewer_Keithley2600.py new file mode 100644 index 0000000..cf72078 --- /dev/null +++ b/src/pymodaq_plugins_keithley/daq_viewer_plugins/plugins_1D/daq_1Dviewer_Keithley2600.py @@ -0,0 +1,200 @@ +import numpy as np + +from pymodaq_utils.utils import ThreadCommand +from pymodaq_data.data import DataToExport, Axis, Q_ +from pymodaq_gui.parameter import Parameter + +from pymodaq.control_modules.viewer_utility_classes import DAQ_Viewer_base, comon_parameters, main +from pymodaq.utils.data import DataFromPlugins + +import pyvisa +from pymodaq_plugins_keithley.hardware.keithley2600.keithley2600_VISADriver import Keithley2600VISADriver, Keithley2600Channel, get_VISA_resources + +import datetime +from qtpy.QtCore import QDateTime + + +# Helper functions +def _build_param(name, title, type, value, limits=None, unit=None, **kwargs): + params = {} + params["name"] = name + params["title"] = title + params["type"] = type + params["value"] = value + if limits is not None: + params["limits"] = limits + if unit is not None: + params["suffix"] = unit + params["siPrefix"] = True + for argn, argv in kwargs.items(): + params[argn] = argv + return params + + +def _emit_xy_data(self, x, y): + x_axis = Axis(data=x, label="Voltage", units="V", index=0) + self.dte_signal.emit(DataToExport("Keithley2600", + data=[DataFromPlugins(name="Keithley2600", + data=[y], + units="A", + dim="Data1D", labels=["I-V"], + axes=[x_axis])])) + + +class DAQ_1DViewer_Keithley2600(DAQ_Viewer_base): + """ Instrument plugin class for a Keithley 2600 sourcemeter. + + Attributes: + ----------- + controller: object + The particular object that allow the communication with the hardware, in general a python wrapper around the + hardware library. + + # TODO add your particular attributes here if any + + """ + params = comon_parameters+[ + _build_param("resource_name", "VISA resource", "list", "", limits=get_VISA_resources()), + _build_param("channel", "Channel", "str", "A"), + _build_param("startv", "Sweep start voltage", "float", 0, unit="V"), + _build_param("stopv", "Sweep stop voltage", "float", 1, unit="V"), + _build_param("stime", "Sweep stabilization time", "float", 1e-3, unit="s"), + _build_param("npoints", "Sweep points", "int", 101), + _build_param("ilimit", "Current limit", "float", 0.1, unit="A"), + _build_param("autorange", "Autorange", "bool", True), + _build_param("idle_pol_on", "Keep polarized after scan", "bool", False), + _build_param("idle_pol_v", "Polarization voltage after scan", "float", 0, unit="V"), + _build_param("meas_start", "Last measurement start time", "date_time", + QDateTime(datetime.datetime.now()), readonly=True), + _build_param("meas_end", "Last measurement end time", "date_time", + QDateTime(datetime.datetime.now()), readonly=True), + ] + + + def ini_attributes(self): + # Type declaration of the controller + self.controller: Keithley2600VISADriver = None + self.channel: Keithley2600Channel = None + + + def commit_settings(self, param: Parameter): + """Apply the consequences of a change of value in the detector settings + + Parameters + ---------- + param: Parameter + A given parameter (within detector_settings) whose value has been changed by the user + """ + # Dispatch arguments + name = param.name() + val = param.value() + unit = param.opts.get("suffix") + qty = Q_(val, unit) + + # No argument processing for now + + + def ini_detector(self, controller=None): + """Detector communication initialization + + Parameters + ---------- + controller: (object) + custom object of a PyMoDAQ plugin (Slave case). None if only one actuator/detector by controller + (Master case) + + Returns + ------- + info: str + initialized: bool + False if initialization failed otherwise True + """ + + # Get initialization parameters + resource_name = self.settings["resource_name"] + channel = self.settings["channel"] + autorange = self.settings["autorange"] + + # If stand-alone device, initialize controller object + if self.is_master: + + # Initialize device + self.controller = Keithley2600VISADriver(resource_name) + initialized = True + + # If slave device, retrieve controller object + else: + self.controller = controller + initialized = True + + # Initialize channel + self.channel = self.controller.create_channel(channel_name=channel, + autorange=autorange) + + # Initialize viewers panel with the future type of data + mock_x = np.linspace(0, 1, 101) + mock_y = np.zeros(101) + _emit_xy_data(self, mock_x, mock_y) + + # Initialization successful + info = "Keithey 2600 initialization finished." + return info, initialized + + + def close(self): + """Terminate the communication protocol""" + if self.is_master: + self.controller.close() + self.controller = None + + + def grab_data(self, Naverage=1, **kwargs): + """Start a grab from the detector + + Parameters + ---------- + Naverage: int + Number of hardware averaging (if hardware averaging is possible, self.hardware_averaging should be set to + True in class preamble and you should code this implementation) + kwargs: dict + others optionals arguments + """ + + # Retrieve parameters + startv = self.settings["startv"] + stopv = self.settings["stopv"] + stime = self.settings["stime"] + npoints = self.settings["npoints"] + ilimit = self.settings["ilimit"] + idle_pol_on = self.settings["idle_pol_on"] + idle_pol_v = self.settings["idle_pol_v"] + + # Apply current limit + self.channel.Ilimit = ilimit + + # Get timestamp before acquisition + start_time = datetime.datetime.now() + + # Sweep and retrieve x and y axes + x, y = self.channel.sweepV_measureI(startv, stopv, stime, npoints) + + # Get timestamp after acquisition and update timestamp parameters + end_time = datetime.datetime.now() + self.settings["meas_start"] = QDateTime(start_time) + self.settings["meas_end"] = QDateTime(end_time) + + # Emit data to PyMoDAQ + _emit_xy_data(self, x, y) + + # If "keep polarized after scan" is selected, apply selected voltage + if idle_pol_on: + self.channel.sourceV(idle_pol_v) + + + def stop(self): + """Stop the current grab hardware wise if necessary""" + pass + + +if __name__ == '__main__': + main(__file__) diff --git a/src/pymodaq_plugins_keithley/hardware/keithley2600/keithley2600_VISADriver.py b/src/pymodaq_plugins_keithley/hardware/keithley2600/keithley2600_VISADriver.py new file mode 100644 index 0000000..d6b8153 --- /dev/null +++ b/src/pymodaq_plugins_keithley/hardware/keithley2600/keithley2600_VISADriver.py @@ -0,0 +1,291 @@ +import numpy as np +import pyvisa +from pymodaq_plugins_keithley import config +from pymodaq.utils.logger import set_logger, get_module_name +logger = set_logger(get_module_name(__file__)) + + +# Helper functions +def get_VISA_resources(pyvisa_backend="@py"): + + # Get list of VISA resources + resourceman = pyvisa.ResourceManager(pyvisa_backend) + resources = list(resourceman.list_resources()) + + # Move the first USB connection to the top + for i, val in enumerate(resources): + if val.startswith("USB0"): + resources.remove(val) + resources.insert(0, val) + break + + # Return list of resources + return resources + + +def table_to_np(table): + """Convert sequence of ASCII-encoded, comma-separated values to NumPy array.""" + split = table.split(", ") + floats = [float(x) for x in split] + array = np.array(floats) + return array + + +class Keithley2600VISADriver: + """VISA class driver for Keithley 2600 sourcemeters. + + Communication with the device is performed in text mode (TSP). Detailed instructions can be found in: + https://download.tek.com/manual/2600BS-901-01_C_Aug_2016_2.pdf + """ + + + def __init__(self, resource_name, pyvisa_backend="@py"): + """Initialize KeithleyVISADriver class. + + Parameters + ---------- + resource_name: str + VISA resource name. (ex: "USB0::1510::9782::1234567::0::INSTR") + pyvisa_backend: str, optional + pyvisa backend identifier or path to the visa backend dll (ref. to pyvisa) + (default: "@py") + """ + resourceman = pyvisa.ResourceManager(pyvisa_backend) + self._instr = resourceman.open_resource(resource_name) + + + def close(self): + """Terminate connection with the instrument.""" + self._instr.close() + self._instr = None + + + def create_channel(self, channel_name="A", autorange=True): + """Create an object for driving an SMU channel connected to this device. + + Parameters + ---------- + channel_name: str, optional + Channel name. (default: "A") + autorange: bool, optional + Enable I and V autorange. (default: True) + """ + return Keithley2600Channel(self, channel_name, autorange) + + + def _write(self, cmd): + """Convenience methode to send a TSP command to the device.""" + self._instr.write(cmd) + + + def _read(self): + """Convenience methode to get response from the device.""" + return self._instr.read() + + +class Keithley2600Channel: + """Class for handling a single SMU channel on a Keithley 2600 sourcemeter.""" + + def __init__(self, parent, channel, autorange): + """Initialize class. + + Parameters + ---------- + parent: Keithley2600 + Parent class. + channel: str + Identifier of the channel. (ex: "A") + autorange: bool + Enable I and V autorange. + """ + + # Initialize variables + self.channel = channel + self.smu = f"smu{channel.lower()}" + self.parent = parent + + # Set autorange if enabled + if autorange: + self.autorange() + + + def _write(self, cmd): + """Convenience methode to send a TSP command to the device.""" + self.parent._write(cmd) + + + def _read(self): + """Convenience methode to get response from the device.""" + return self.parent._read() + + + @property + def Ilimit(self): + """Get current limit [A] of the channel. + + Returns + ------- + ilimit: float + Current limit [A] of the selected channel. + """ + self._write(f"print({self.smu}.source.limiti)") + ilimit = self._read() + return float(ilimit) + + + @Ilimit.setter + def Ilimit(self, ilimit): + """Set current limit [A] of the channel. + + Parameters + ---------- + ilimit: float + Current limit [A] to set. + """ + ilimit = f"{ilimit:.6e}" + self._write(f"{self.smu}.source.limiti = {ilimit}") + + + @property + def Vlimit(self): + """Get voltage limit [A] of the channel. + + Returns + ------- + vlimit: float + Voltage limit [A] of the selected channel. + """ + self._write(f"print({self.smu}.source.limitv)") + vlimit = self._read() + return float(vlimit) + + + @Vlimit.setter + def Vlimit(self, vlimit): + """Set voltage limit [A] of the channel. + + Parameters + ---------- + vlimit: float + Voltage limit [A] to set. + """ + vlimit = f"{vlimit:.6e}" + self._write(f"{self.smu}.source.limitv = {vlimit}") + + + def autorange(self): + """Set current and voltage measurements to autorange.""" + self._write(f"{self.smu}.measure.autorangei = {self.smu}.AUTORANGE_ON") + self._write(f"{self.smu}.measure.autorangev = {self.smu}.AUTORANGE_ON") + + + def measureI(self): + """Measure current [A].""" + self._write(f"print({self.smu}.measure.i())") + meas = self._read() + return float(meas) + + + def measureV(self): + """Measure voltage [V].""" + self._write(f"print({self.smu}.measure.v())") + meas = self._read() + return float(meas) + + + def measureIV(self): + """Measure simultaneously current [A] and voltage [V].""" + self._write(f"print({self.smu}.measure.iv())") + ret = self._read() + i, v = ret.split() + return float(i), float(v) + + + def off(self, highz=False): + """Switch off channel output. + + Parameters + ---------- + highz: bool, optional + Set output to high impedance mode in addition to switching off. + (default: False) + """ + offmode = 2 if highz else 0 + self._write(f"{self.smu}.source.output = {offmode}") + + + def sourceI(self, isetpoint): + """Set channel output to constant current with the specified setpoint. + + Parameters + ---------- + isetpoint: float + Current [A] to set. + """ + isetpoint = f"{isetpoint:.6e}" + self._write(f"{self.smu}.source.func = 0") + self._write(f"{self.smu}.source.output = 1") + self._write(f"{self.smu}.source.leveli = {isetpoint}") + + + def sourceV(self, vsetpoint): + """Set channel output to constant voltage with the specified setpoint. + + Parameters + ---------- + vsetpoint: float + Voltage [V] to set. + """ + vsetpoint = f"{vsetpoint:.6e}" + self._write(f"{self.smu}.source.func = 1") + self._write(f"{self.smu}.source.output = 1") + self._write(f"{self.smu}.source.levelv = {vsetpoint}") + + + def sweepV_measureI(self, startv=0, stopv=1, stime=1e-3, npoints=100): + """Perform a linear voltage sweep and measure current. This version is called with arguments. + + Parameters + ---------- + startv: float + Starting voltage [V] of the sweep. + stopv: float + Stopping voltage [V] of the sweep. + stime: float + Stabilization time [s]. The device waits for this amount of time at each measurement + step, once voltage has reached the setpoint. In practice, actual step time is longer + than this value because of the time needed to reach the voltage setpoint. + npoints: int + Number of points to be acquired. Must be >2. + + Returns + ------- + x: np.ndarray + Voltage values [V]. + y: np.ndarray + Current (intensity) values [A]. + """ + + # Convert channel and step time + startv = f"{startv:.6e}" + stopv = f"{stopv:.6e}" + stime = f"{stime:.6e}" + npoints = str(npoints) + + # Send request to sweep + self._write(f"SweepVLinMeasureI({self.smu}, {startv}, {stopv}, {stime}, {npoints})") + self._write(f"print(status.measurement.buffer_available.{self.smu.upper()})") + ret = self._read() + if not int(float(ret)) == 2: + raise ValueError(f"Return data {ret} != 2") + + # Retrieve applied voltages + self._write(f"printbuffer(1, {npoints}, {self.smu}.nvbuffer1.sourcevalues)") + x = table_to_np(self._read()) + + # Retrieve measured currents + self._write(f"printbuffer(1, {npoints}, {self.smu}.nvbuffer1.readings)") + y = table_to_np(self._read()) + + # Return x and y vectors + return x, y