diff --git a/labscript_devices/KeysightScope/KeysightScope.py b/labscript_devices/KeysightScope/KeysightScope.py new file mode 100644 index 00000000..f9433588 --- /dev/null +++ b/labscript_devices/KeysightScope/KeysightScope.py @@ -0,0 +1,703 @@ +import pyvisa +import numpy as np +from labscript.labscript import LabscriptError + + +class KeysightScopeDevice: + def __init__(self, + address, + verbose = False + ): + + self.verbose = verbose + + self.unit_conversion = { + 's' : 1 , + 'ns': 1e-9, # nanoseconds to seconds + 'us': 1e-6, # microseconds to seconds + 'ms': 1e-3 # milliseconds to seconds + } + + # --------------------------------- Connecting to device + self.dev = pyvisa.ResourceManager().open_resource(address) + print(f'Initialized: {self.dev.query("*IDN?")}') + + # --------------------------------- Initialize device + self.reset_device() + + ####################################################################################### + # Saving and Recalling # + ####################################################################################### + def get_settings_dict(self,value): + """ + Returns a configuration dictionary for the oscilloscope memory slot specified by 'value'. + Args: + value (int or str): The memory slot number of the oscilloscope from which to retrieve the configuration. + + """ + osci_shot_configuration = { + "configuration_number" : str(value) , + + # Channel unrelated + "trigger_source" : str(self.get_trigger_source()).strip(), + "trigger_level" : str(self.get_trigger_level()).strip(), + "trigger_level_unit" : "V", + "trigger_type" : str(self.get_trigger_type()).strip(), + "trigger_edge_slope" : str(self.get_trigger_edge_slope()).strip(), + + "acquire_type" : str(self.get_acquire_type()).strip(), + "acquire_count" : str(self.get_acquire_count()).strip(), + "waveform_format" : str(self.get_waveform_format()).strip(), + + "time_reference" : str(self.get_time_reference()).strip(), + "time_division" : str(self.get_time_division()).strip(), + "time_division_unit" : "s", + "time_delay" : str(self.get_time_delay()).strip(), + "time_delay_unit" : "s", + + # Channel related + # ------------------------ Channel 1 + "channel_display_1" : str(self.get_channel_display(channel=1)).strip(), + "voltage_division_1" : str(self.get_voltage_division()).strip(), + "voltage_division_unit_1" : "V", + "voltage_offset_1" : str(self.get_voltage_offset()).strip(), + "voltage_offset_unit_1" : "V", + "probe_attenuation_1" : str(self.get_probe_attenuation(channel=1)).strip(), + + # ------------------------ Channel 2 + "channel_display_2" : str(self.get_channel_display(channel=2)).strip(), + "voltage_division_2" : str(self.get_voltage_division(channel=2)).strip(), + "voltage_division_unit_2" : "V", + "voltage_offset_2" : str(self.get_voltage_offset(channel=2)).strip(), + "voltage_offset_unit_2" : "V", + "probe_attenuation_2" : str(self.get_probe_attenuation(channel=2)).strip() + } + return osci_shot_configuration + + def save_start_setup(self, location = 0): + """ + Saves the oscilloscope current configuration to a specified location. + + This method sends a command to the oscilloscope to save the current configuration + to the specified location. The default location is "0", but any valid location + index can be provided. + + Args: + location (str or int, optional): The index of the configuration location to recall. + Defaults to "0" if not provided. + """ + self.dev.write(f":SAVE:SETup:STARt {location}") + + + def recall_start_setup(self, location = 0): + """ + Sets the oscilloscope configuration to a specified location. + + This method sends a command to the oscilloscope to recall and apply the configuration + stored at the specified location. The default location is "0", but any valid location + index can be provided. + + Args: + location (str or int, optional): The index of the configuration location to recall. + Defaults to "0" if not provided. + """ + return self.dev.query(f":RECall:SETup:STARt {location};*OPC?") + + + + + def get_saving_register(self): + """ + Retrieves the configuration settings for all 10 available saving slots of the device. + + Returns: + dict: A dictionary containing the settings for each of the 10 saving slots. + The keys are the slot indices (0 to 9), and the values are dictionaries + with the configuration settings for the corresponding slot. If an error + occurs during the retrieval of a slot's settings, for example because the slot is empty, the value will be an + empty dictionary. + + Example: + saving_register = device.get_saving_register() + # saving_register will be a dictionary, e.g.: + # {0: {'setting1': value1, 'setting2': value2}, + # 1: {}, + # 2: {'setting1': value1}, + # ...} + """ + saving_register = {} + for i in range(10): + self.recall_start_setup(f"{i}") + if "250" in self.dev.query(":SYSTem:ERRor?"): + saving_register[i] = {} + else: saving_register[i] = self.get_settings_dict(i) + return saving_register + + ####################################################################################### + # Basic Commands # + ####################################################################################### + + def get_acquire_state(self): + """Determine wether the oscilloscope is running. + Returns: ``True`` if running, ``False`` otherwise + """ + reg = int(self.dev.query(':OPERegister:CONDition?')) # The third bit of the operation register is 1 if the instrument is running + return int((reg & 8) == 8) + + def set_acquire_state(self, running=True): + '''RUN / STOP ''' + self.dev.write(':RUN' if running else 'STOP') + if self.verbose: + print("Done running") + + def reset_device(self): + self.dev.write("*RST") + if self.verbose: + print("Done reset") + + def digitize(self): + ''' Specialized RUN command. + acquires a single waveforms according to the settings of the :ACQuire commands subsystem. + When the acquisition is complete, the instrument is stopped. + ''' + self.dev.write(":DIG") + + def autoscale(self): + self.dev.write(":AUToscale") + + def clear_status(self): + """ + clears: the status data structures, + the device-defined error queue, + and the Request-for-OPC flag + """ + self.dev.write("*CLS") + + def close(self): + self.dev.close() + + def lock(self): + self.dev.write(':SYSTem:LOCK 1') + + def unlock(self): + self.dev.write(':SYSTem:LOCK 0') + + ####################################################################################### + # Setting Axes (Voltage & Time) # + ####################################################################################### + + # ----------------------------------------------- Set Voltage + def set_voltage_range(self, range, channel=1, unit="V"): + """ unit : V or mV """ + if unit =="V": + self.dev.write(f":CHANnel{channel}:RANGe {range}") + elif unit =="mV": + self.dev.write(f":CHANnel{channel}:RANGe {range}mV") + + def set_voltage_division(self, division, channel=1, unit="V"): + """ unit : V or mV """ + if unit in ["V","mV"]: + self.dev.write(f":CHANnel{channel}:SCALe {division}{unit}") + if self.verbose: + print("Done voltage division") + + def set_voltage_offset(self,offset,channel=1,unit="V"): + """ unit : V or mV """ + if unit in ["V","mV"]: + self.dev.write(f":CHANnel{channel}:OFFSet {offset}{unit}") + if self.verbose: + print("Done voltage offset") + + # ----------------------------------------------- Get Voltage + def get_voltage_range(self, channel=1): + """ + Retrieves the voltage range of the channel in volts (V). + + Returns: + str: The voltage range in volts (V). + """ + return self.dev.query(f":CHANnel{channel}:RANGe?") + + def get_voltage_division(self, channel=1): + """ Get Voltage division of channel in V. """ + return float(self.dev.query(f":CHANnel{channel}:SCALe?")) + + def get_voltage_offset(self,channel=1): + """ Get Voltage offset of channel in V. """ + return float(self.dev.query(f":CHANnel{channel}:OFFSet?")) + + # ----------------------------------------------- Set Time + def set_time_range(self, range, unit): + """Set the time range of the oscilloscope. + Args: + time_range (str or float):: The time range value. (50ns - 500s) + unit: The unit for the time range, 'ms', 'us', 'ns'. + + Raises: + LabscriptError: If the time range is outside the valid range or if the unit is invalid. + """ + + # Validate unit + if unit not in self.unit_conversion: + raise LabscriptError(f"Invalid unit '{unit}'. Supported units are 'ns', 'us', 'ms'.") + + # Convert to seconds + try: + converted_time_range = float(range) * self.unit_conversion[unit] + + # Check if the converted time range is within the allowed bounds + if not (50e-9 <= converted_time_range <= 500): + raise LabscriptError(f"Range not supported. Valid range is between 50 ns and 500 s.") + + except Exception as e: + raise LabscriptError(f"Invalid time range or unit: {e}") + + # Send the command to the oscilloscope + self.dev.write(f":TIMebase:RANGe {converted_time_range}") + + def set_time_division(self,division,unit): + """ Set the time per division of the oscilloscope. + Args: + time_division (str or float): The time division value. (min 5ns - max 50s) + unit: The unit for the time division, 'ms', 'us', 'ns'. + + Raises: + LabscriptError: If the time division is outside the valid division or if the unit is invalid. + """ + # Validate unit + if unit not in self.unit_conversion: + raise LabscriptError(f"Invalid unit '{unit}'. Supported units are 'ns', 'us', 'ms'.") + + # Convert to seconds + try: + converted_time_division = float(division) * self.unit_conversion[unit] + + # Check if the converted time division is within the allowed bounds + if not (5e-9 <= converted_time_division <= 50): + raise LabscriptError(f"Time division not supported. Valid division is between 50 ns and 500 s.") + + except Exception as e: + raise LabscriptError(f"Invalid time division or unit: {e}") + + self.dev.write(f":TIMebase:SCALe {converted_time_division}") + if self.verbose: + print("Done time division") + + def set_time_delay(self,delay,unit="us"): + """ Set the time delay. + Args: + time_delay (str or foat): The time delay value. + unit: The unit for the time delay, 'ms', 'us', 'ns'. + + Raises: + LabscriptError: If the time delay is outside the valid range or if the unit is invalid. + """ + + # Validate unit + if unit not in self.unit_conversion: + raise LabscriptError(f"Invalid unit '{unit}'. Supported units are 'ns', 'us', 'ms'.") + + # Convert to seconds + try: + converted_time_delay = float(delay) * self.unit_conversion[unit] + + # Check if the converted time range is within the allowed bounds + if converted_time_delay >= 500: + raise LabscriptError(f"Time delay not supported. Valid division is between 100ps and 500 s.") + + except Exception as e: + raise LabscriptError(f"Invalid time delay or unit: {e}") + + self.dev.write(f":TIMebase:DELay {converted_time_delay}") + if self.verbose: + print("Done time delay") + + def set_time_reference(self,reference = "CENTer"): + """ accepted args: LEFT , CENTer , RIGHt """ + self.dev.write(f":TIMebase:REFerence {reference}") + if self.verbose: + print("Done time reference") + + def set_time_mode(self,mode= "MAIN"): + """ Set the time mode of the oscilloscope. + Args: + mode (str): + - MAIN : This is the primary mode used in an oscilloscope, + delivering a real-time graph of voltage (Y-axis) versus time (X-axis). + + - WINDow : In the WINDow (zoomed or delayed) time base mode, + measurements are made in the zoomed time base if possible; otherwise, the + measurements are made in the main time base. + If chosen, we still need to set : position, Range, scale + (Not adequate for retrieving data.) + + - XY: The X-Y mode plots one voltage against another. + Therefore :TIMebase:RANGe, :TIMebase:POSition, and :TIMebase:REFerence commands are not available in this mode + + - Roll : Idea for low frequeny signals. In this mode, the waveform scrolls from right to left across the display + + """ + self.dev.write(f":TIMebase:MODE {mode}") + + # ----------------------------------------------- Get Time + def get_time_range(self): + """ Retrieves the global time range in s""" + return self.dev.query(":TIMebase:RANGe?") + + def get_time_division(self): + """ Retrieves the time division in s. """ + return self.dev.query(":TIMebase:SCALe?") + + def get_time_delay(self): + """ Retrieves the time delay in s. """ + return self.dev.query(":TIMebase:DELay?") + + def get_time_reference(self): + """ + Retrieves the time reference. + + Returns: + str: One of the following time reference positions: + - 'LEFT' + - 'CENTER' + - 'RIGHT' + """ + return self.dev.query(":TIMebase:REFerence?") + + ####################################################################################### + # Triggering # + ####################################################################################### + + def single(self): + ''' Single Button ''' + self.dev.write(":SINGle") + + def get_trigger_event(self): # Not used yet + """ + whether the osci was triggered or not + return: + 0 : No trigger event was registred + 1 : trigger event was registred + """ + return int(self.dev.query(':TER?')) + + # ----------------------------------------------- Trigger Type + def set_trigger_type(self, type): + """ valid types : EDGE, GLITch, PATTern, SHOLd, TRANsition, TV, SBUS1 """ + valid_types = {"EDGE", "GLITch", "PATTern", "SHOLd", "TRANsition", "TV", "SBUS1"} + + if type not in valid_types: + raise LabscriptError(f"Invalid trigger mode. Valid modes are: {', '.join(valid_types)}") + + self.dev.write(f":TRIGger:MODE {type}") + + def get_trigger_type(self): + """Get the current trigger type.""" + return self.dev.query(":TRIGger:MODE?") + + # ----------------------------------------------- Trigger source + def set_trigger_source(self, source): + """ + Valid source : CHANnel , EXTernal , LINE" , WGEN} + with n = channel number + """ + try: + self.dev.write(f":TRIGger:SOURce {source} ") + if self.verbose: + print("Done trigger source") + except Exception as e: + raise LabscriptError("trigger_source: "+ e) + + def get_trigger_source(self): + self.trigger_source = self.dev.query(":TRIGger:SOURce?") + return self.trigger_source + + # ----------------------------------------------- Trigger Level + def set_trigger_level(self, level,unit="V"): + """ unit : V or mV """ + assert unit in ["V","mV"], LabscriptError("unit must be V or mV") + + """Set the trigger level in V""" + if self.trigger_source == "EXTernal": + self.dev.write(f":EXTernal:LEVel {level}{unit}") + if self.verbose: + print("Done trigger level EXternal") + else: + self.dev.write(f":TRIGger:LEVel {level}{unit}") + if self.verbose: + print("Done trigger level") + + def get_trigger_level(self): + """Get the current trigger level.""" + + if self.trigger_source == "EXTernal": + return self.dev.query(":EXTernal:LEVel?") + else: + return self.dev.query(":TRIGger:LEVel?") + + # ----------------------------------------------- Trigger Edge slope + def set_trigger_edge_slope(self,slope): + """ + Args: + slope : POSitive, NEGative , EITHer , ALTernate + """ + if self.trigger_type != "EDGE": + raise LabscriptError("Trigger type must be \"EDGE\" ") + self.dev.write(f":TRIGger:EDGE:SLOPe {slope}") + if self.verbose: + print("Done trigger slope") + + def get_trigger_edge_slope(self): + """ + Returns: + slope : POSitive, NEGative , EITHer , ALTernate + """ + return self.dev.query(":TRIGger:EDGE:SLOPe?") + + ####################################################################################### + # Channel Configurations # + ####################################################################################### + + # ----------------------------------------------- Probe Attenuation + def set_probe_attenuation(self,attenuation, channel): + """ + Sets the probe attenuation factor for the selected channel. + Allowed range: 0.1 - 10000 + """ + try: + assert ( 0.1<= float(attenuation) <= 1e4) + self.dev.write(f":CHANnel{channel}:PROBe {attenuation}") + if self.verbose: + print("Done probe attenuation") + + except Exception as e: + raise LabscriptError("Probe attenuation ration not in range 0.1 - 10000") + + def get_probe_attenuation(self,channel): + return self.dev.query(f":CHANnel{channel}:PROBe?") + + # ----------------------------------------------- Display a channel + def set_channel_display(self,channel,display): + """"display a channel + args: + channel: str the channel number + display: str 0: OFF, 1:ON + """ + self.dev.write(f":CHANnel{channel}:DISPlay {display}") + + def get_channel_display(self,channel): + return self.dev.query(f":CHANnel{channel}:DISPlay?") + + # ----------------------------------------------- Displayed channels + def channels(self, all=True): + ''' + Returns: dictionary {str supported channels : bool currently displayed } + If "all" is False, only visible channels are returned + ''' + # List with all Channels + all_channels = self.dev.query(":MEASure:SOURce?").rstrip().split(",") + + # We have to change all_channels a little bit for the oscii (e.g CHAN1 -> CHANnel1) + all_channels = [ch.replace("CHAN", "CHANnel") for ch in all_channels] + + # Create a dictionary {channel : visibility} + vals = {} + for index , chan in enumerate(all_channels): + try: + visible = bool(int(self.dev.query(f":CHANnel{index + 1 }:DISPlay?"))) + except: + continue + if all or visible: + vals[chan] = visible + return vals + + ####################################################################################### + # Acquiring # + ####################################################################################### + + # ----------------------------------------------- Acquire type + def set_acquire_type(self,type="NORMal"): + ''' + The :ACQuire:TYPE command selects the type of data acquisition that is to take + place. The acquisition types are: + + • NORMal — sets the oscilloscope in the normal mode. + + • AVERage — sets the oscilloscope in the averaging mode. You can set the count + by sending the :ACQuire:COUNt command followed by the number of averages. + In this mode, the value for averages is an integer from 1 to 65536. The COUNt + value determines the number of averages that must be acquired. + The AVERage type is not available when in segmented memory mode + (:ACQuire:MODE SEGMented). + + • HRESolution — sets the oscilloscope in the high-resolution mode (also known + as smoothing). This mode is used to reduce noise at slower sweep speeds + where the digitizer samples faster than needed to fill memory for the displayed + time range. + For example, if the digitizer samples at 200 MSa/s, but the effective sample + rate is 1 MSa/s (because of a slower sweep speed), only 1 out of every 200 + samples needs to be stored. Instead of storing one sample (and throwing others + away), the 200 samples are averaged together to provide the value for one + display point. The slower the sweep speed, the greater the number of samples + that are averaged together for each display point. + + • PEAK — sets the oscilloscope in the peak detect mode. In this mode, + :ACQuire:COUNt has no meaning. + + The AVERage and HRESolution types can give you extra bits of vertical resolution. + See the User's Guide for an explanation. When getting waveform data acquired + using the AVERage and HRESolution types, be sure to use the WORD or ASCii + waveform data formats to get the extra bits of vertical resolution. + ''' + self.dev.write(":ACQuire:TYPE "+ type) + if self.verbose: + print("Done acquire type") + + def get_acquire_type(self): + return self.dev.query(":ACQuire:TYPE?") + # ----------------------------------------------- Acquire count + def set_acquire_count(self,count): + ''' In averaging and Normal mode, specifies the number of values + to be averaged for each time bucket before the acquisition is considered to be + complete for that time bucket. + - count: 2 - 65536. + ''' + if self.acquire_type not in ["HRESolution","PEAK","NORMal" ]: + self.dev.write(f":ACQuire:COUNT {count}") + if self.verbose: + print("Done trigger count") + + def get_acquire_count(self): + return self.dev.query(":ACQuire:COUNT?") + # ----------------------------------------------- Acquire source + def set_waveform_source(self,channel): + ''' Set the location of the data transferred by WAVeform + ARGS: + channel (str or int): the channel number + ''' + self.dev.write(f":WAVeform:SOURce {channel}") + + def get_waveform_source(self): + return self.dev.query(":WAVeform:SOURce?") + + ####################################################################################### + # Reading # + ####################################################################################### + + def get_waveform_format(self): + return self.dev.query(":WAVeform:FORMat?") + + # ----------------------------------------------- Waveform Preample + def get_preample_as_dict(self): + """ + return dict with the following keys + keys = [ + "format", # for BYTE format, 1 for WORD format, 4 for ASCii format; an integer in NR1 format (format set by :WAVeform:FORMat) + "type", # 2 for AVERage type, 0 for NORMal type, 1 for PEAK detect type; an integer in NR1 format (type set by :ACQuire:TYPE). + "points", # points 32-bit NR1 + "count", # Average count or 1 if PEAK or NORMal; an integer in NR1 format (count set by :ACQuire:COUNt) + "xincrement", # 64-bit floating point NR3>, + "xorigin" , # 64-bit floating point NR3>, + "xreference", # 32-bit NR1>, + "yincrement", # 32-bit floating point NR3>, + "yorigin" , # 32-bit floating point NR3>, + "yreference", # 32-bit NR1. + ] + """ + keys = ["format","type","points","count","xincrement","xorigin","xreference","yincrement","yorigin","yreference"] + preamble = self.dev.query(":WAVeform:PREamble?") + preamble_val = [float(i) for i in preamble.split(",")] + return dict(zip(keys, preamble_val)) + + # ----------------------------------------------- Waveform function (BYTE or WORD(int16)) + def waveform(self, waveform_format="BYTE" ,channel='CHANnel1' ): + """ + returns: dict + * Waveform preample : List, as set by the 'Record length' setting in the 'Acquisition' menu. + * Times: List of acquired times + * Values: List of acquired voltages + """ + # configure the data type transfer + self.set_waveform_source(channel) + + # Sets the data transmission mode for waveform data points. + # WORD: formatted data transfers 16-bit data as two bytes. + # BYTE: formatted data is transferred as 8-bit bytes. + if waveform_format in ["WORD", "BYTE"]: + self.dev.write(f":WAVeform:FORMat {waveform_format}") + datatype = "H" if waveform_format == "WORD" else "B" + + if self.verbose: + print(f"Done Waveform {waveform_format}") + else : + raise KeyError("Waveform format must be WORD or BYTE") + + + # transfer the data and format into a sequence of strings + raw = self.dev.query_binary_values( + ':WAVeform:DATA?', + datatype=datatype, # 'B' and 'H' are for unassigned , if you want signed use h and b + #is_big_endian=True, # In case we shift to signed + container=np.array + ) + + # Create a dictionary of the waveform preamble + wfmp = self.get_preample_as_dict() + + # (see Page 667 , Keysight manual for programmer ) + n = np.arange(wfmp['points'] ) + t = ( n - wfmp['xreference']) * wfmp['xincrement'] + wfmp['xorigin'] # time = [( data point number - xreference) * xincrement] + xorigin + data = ( raw - wfmp['yreference']) * wfmp['yincrement'] + wfmp['yorigin'] # voltage = [( data value - yreference) * yincrement] + yorigin + + return wfmp, t, data + + + def waveform_ascii(self): + self.dev.write(":WAVeform:FORMat ASCii") + data_ascii = self.dev.query(":WAVeform:DATA?") + data = np.array([float(x) for x in data_ascii.split(", ")[1:]]) + len_data = len(data) + len_x_achse = self.get_time_range() + t = np.linspace(0,len_x_achse , len_data) + wfmp = self.get_preample_as_dict() + return wfmp, t, data + + + ####################################################################################### + # Wave Generator # + ####################################################################################### + + def wgen_on(self, on = True): + ''' Wave generator''' + if on: + self.dev.write(":WGEN:OUTPut 1") # 1 is on + else: + self.dev.write(":WGEN:OUTPut 0") # 0 is off + + def set_wgen_freq(self, freq = 1): + """ in Hz """ + self.dev(f":WGEN:FREQuency {freq}") + + def set_wgen_form(self, form = "SQUare" ): + """ + Possible forms: + {SINusoid | SQUare | RAMP | PULSe | NOISe | DC} + """ + self.dev(f":WGEN:FUNCtion {form}") + + def set_wgen_voltage(self, voltage= 1): + "in V , possible 1mv" + self.dev(f":WGEN:VOLTage {voltage}") + + def set_wgen_high(self, voltage_high= 1): + """ + in V : High level of the signal + """ + self.dev(f":WGEN:VOLTage:HIGH {voltage_high}") + + def set_wgen_low(self, voltage_low= 0): + """ + in V : Low level of the signal + """ + self.dev(f":WGEN:VOLTage:LOW {voltage_low}") + diff --git a/labscript_devices/KeysightScope/README.md b/labscript_devices/KeysightScope/README.md new file mode 100644 index 00000000..9ffb8757 --- /dev/null +++ b/labscript_devices/KeysightScope/README.md @@ -0,0 +1,58 @@ +# Keysight oscilloscope implementation + +## Supported models +* Currently supported models: EDUX1052A, EDUX1052G, DSOX1202A, DSOX1202G, DSOX1204A, DSOX1204G + +## Current possible utilization + +### Triggering +* The oscilloscope is to be used in trigger mode (Single mode). +* Triggering must be performed via the external trigger input. +* Data is read from the channels currently displayed on the oscilloscope. + +### Oscilloscope configuration +* You can configure the oscilloscope manually and then upload the configuration to labscript using the BLACS GUI interface, as shown in the following example: + +![alt text]() + +* **1** By pressing `activate` on a spot index (in this example, Spot 0), the oscilloscope loads the configuration state saved in that spot, and the tab number lights up red. + +* **2** Once a specific spot is activated,it becomes editable. You can either: + * Manually change the oscilloscope settings directly on the device, then click `load and save` in the BLACS tab to import and save the updated configuration for that spot. + * Or, click `reset to default` to load the default oscilloscope configuration. + +* **3** This zone gives an overview of the most important setting parameters for the currently selected (green highleted, not necessarly activated) tab. + + +## Example Script + +### In the Python connection table + +```python +KeysightScope( + name="osci_keysight", + serial_number="CN61364200", + parent_device=osci_Trigger # parent_device must be a digital output initialized as Trigger(...) +) +``` + +### In the python experiment file +There are two main functions to use in the experiment scipt: + +* `set_config( spot_index : int or string )` : The oscilloscope has ten different spots where it saves its global settings. set_config(spot_index) must be called at the beginning of the experiment script with the desired spot_index to initialize the oscilloscope with the corresponding configuration for the shot. + + +* `trigger_at( t=t, duration=trigger_duration )` : This function allows the parent device (of type Trigger()) to trigger the oscilloscope for the specified trigger_duration. During this short period, data will be read from the displayed channels. + +```python +start() +t = 0 + +osci_keysight.set_config(3) # Must be called once at the start of each experiment shot + +trigger_duration = 1e-4 # Example trigger duration +osci_keysight.trigger_at(t=t, duration=trigger_duration) + +t += trigger_duration +stop(t) +``` \ No newline at end of file diff --git a/labscript_devices/KeysightScope/Screenshot_BLACS_tab.png b/labscript_devices/KeysightScope/Screenshot_BLACS_tab.png new file mode 100644 index 00000000..715776e6 Binary files /dev/null and b/labscript_devices/KeysightScope/Screenshot_BLACS_tab.png differ diff --git a/labscript_devices/KeysightScope/__init__.py b/labscript_devices/KeysightScope/__init__.py new file mode 100644 index 00000000..174d82a2 --- /dev/null +++ b/labscript_devices/KeysightScope/__init__.py @@ -0,0 +1,29 @@ + +from labscript import Device, LabscriptError, set_passed_properties ,LabscriptError, AnalogIn + +class ScopeChannel(AnalogIn): + """Subclass of labscript.AnalogIn that marks an acquiring scope channel. + """ + description = 'Scope Acquisition Channel Class' + + def __init__(self, name, parent_device, connection): + """This instantiates a scope channel to acquire during a buffered shot. + + Args: + name (str): Name to assign channel + parent_device (obj): Handle to parent device + connection (str): Which physical scope channel is acquiring. + Generally of the form \'Channel n\' where n is + the channel label. + """ + Device.__init__(self,name,parent_device,connection) + self.acquisitions = [] + + def acquire(self): + """Inform BLACS to save data from this channel. + Note that the parent_device controls when the acquisition trigger is sent. + """ + if self.acquisitions: + raise LabscriptError('Scope Channel {0:s}:{1:s} can only have one acquisition!'.format(self.parent_device.name,self.name)) + else: + self.acquisitions.append({'label': self.name}) \ No newline at end of file diff --git a/labscript_devices/KeysightScope/blacs_tabs.py b/labscript_devices/KeysightScope/blacs_tabs.py new file mode 100644 index 00000000..41f6176a --- /dev/null +++ b/labscript_devices/KeysightScope/blacs_tabs.py @@ -0,0 +1,236 @@ +from blacs.device_base_class import DeviceTab +from labscript import LabscriptError +from blacs.tab_base_classes import define_state,Worker +from blacs.tab_base_classes import MODE_MANUAL, MODE_TRANSITION_TO_BUFFERED, MODE_TRANSITION_TO_MANUAL, MODE_BUFFERED +from blacs.device_base_class import DeviceTab + +import os +import sys +from PyQt5.QtWidgets import * +from PyQt5.QtCore import QSize +from PyQt5 import uic + + + +class KeysightScopeTab(DeviceTab): + '''The device class handles the creation + interaction with the GUI ~ QueueManager''' + + def initialise_workers(self): + # Here we can change the initialization properties in the connection table + worker_initialisation_kwargs = self.connection_table.find_by_name(self.device_name).properties + + # Adding porperties as follows allows the blacs worker to access them + # This comes in handy for the device initialization + worker_initialisation_kwargs['address'] = self.BLACS_connection + + # Create the device worker + self.create_worker( + 'main_worker', + 'labscript_devices.KeysightScope.blacs_workers.KeysightScopeWorker', + worker_initialisation_kwargs, + ) + self.primary_worker = 'main_worker' + + + def initialise_GUI(self): + + # The osci widget + self.osci_widget = OsciTab() + self.get_tab_layout().addWidget(self.osci_widget) + + # Connect radio buttons (activate slot) + for i in range(10): + self.radio_button = self.osci_widget.findChild(QRadioButton, f"activeRadioButton_{i}") + self.radio_button.clicked.connect(lambda checked, i=i: self.activate_radio_button(i)) + + # Connect load buttons (load current) + self.load_button = self.osci_widget.findChild(QPushButton, f"loadButton_{i}") + self.load_button.clicked.connect(lambda clicked, i=i: self.load_current_config(i)) + + # Connect reset buttons (default buttons) + self.default_button = self.osci_widget.findChild(QPushButton, f"defaultButton_{i}") + self.default_button.clicked.connect(lambda clicked, i=i: self.default_config(i)) + + # Loads the Osci Configurations + self.init_osci() + + return + + @define_state(MODE_MANUAL|MODE_BUFFERED|MODE_TRANSITION_TO_BUFFERED|MODE_TRANSITION_TO_MANUAL,True,True) + def init_osci(self, widget=None ): + list_dict_config = yield(self.queue_work(self._primary_worker,'init_osci')) + + for key,value in list_dict_config.items(): + if value: + self.osci_widget.load_parameters(current_dict=value , table_index= key) + else: + self.default_config( button_id=key) + + @define_state(MODE_MANUAL|MODE_BUFFERED|MODE_TRANSITION_TO_BUFFERED|MODE_TRANSITION_TO_MANUAL,True,True) + def activate_radio_button(self,buttton_id, widget=None ): + yield(self.queue_work(self._primary_worker,'activate_radio_button',buttton_id)) + + @define_state(MODE_MANUAL|MODE_BUFFERED|MODE_TRANSITION_TO_BUFFERED|MODE_TRANSITION_TO_MANUAL,True,True) + def load_current_config(self,button_id, widget=None ): + dict_config = yield(self.queue_work(self._primary_worker,'load_current_config',button_id)) + self.osci_widget.load_parameters(current_dict=dict_config , table_index= button_id) + + @define_state(MODE_MANUAL|MODE_BUFFERED|MODE_TRANSITION_TO_BUFFERED|MODE_TRANSITION_TO_MANUAL,True,True) + def default_config(self,button_id, widget=None ): + dict_config = yield(self.queue_work(self._primary_worker,'default_config',button_id)) + self.osci_widget.load_parameters(current_dict=dict_config , table_index= button_id) + + +class TabTemplate(QWidget): + """ A Tab template class that defines the oscilloscope configuration in a table format, + designed to describe the most important settings for the ten available storage slots of the oscilloscope.""" + def __init__(self,parent=None): + super().__init__(parent) + tab_template_name = 'tab_template.ui' + tab_template_path = os.path.join(os.path.dirname(os.path.realpath(__file__)),tab_template_name) + uic.loadUi(tab_template_path,self) + + +class OsciTab(QWidget): + """ The oscilloscope Widget """ + def __init__(self, parent=None): + super().__init__(parent) + + tabs_name = 'tabs.ui' + tabs_path = os.path.join(os.path.dirname(os.path.realpath(__file__)),tabs_name) + uic.loadUi(tabs_path,self) + + # --- Children + self.tabWidget = self.findChild(QTabWidget,"tabWidget") + self.previous_checked_index = None + + self.label_active_setup = self.findChild(QLabel,"label_active_setup") + self.button_group = QButtonGroup(self) + + reset_icon = self.style().standardIcon(QStyle.SP_BrowserReload) + load_icon = self.style().standardIcon(QStyle.SP_DialogOpenButton) + + for i in range(self.tabWidget.count()): + + # --- Promote Tabs + self.tabWidget.removeTab(i) + self.tabWidget.insertTab(i, TabTemplate(), f"s{i}") # Add the new widget to the layout + tab = self.tabWidget.widget(i) + + # --- LoadButtons + self.load_button = tab.findChild(QPushButton , "loadButton") + self.load_button.setObjectName(f"loadButton_{i}") + self.load_button.setIcon(load_icon) + self.load_button.setIconSize(QSize(16,16)) + if i !=0: + self.load_button.setEnabled(False) # init condition + + # --- resetButtons + self.default_button = tab.findChild(QPushButton , "defaultButton") + self.default_button.setObjectName(f"defaultButton_{i}") + self.default_button.setIcon(reset_icon) + self.default_button.setIconSize(QSize(16,16)) + if i !=0: + self.default_button.setEnabled(False) # init condition + + + for i in range(self.tabWidget.count()): + + tab = self.tabWidget.widget(i) + + # --- RadioButtons + radio_button = tab.findChild(QRadioButton, "activeRadioButton") + radio_button.setObjectName(f"activeRadioButton_{i}") + self.button_group.addButton(radio_button) + self.button_group.setId(radio_button, i ) + radio_button.toggled.connect(self.radio_toggled ) + + # --- TableWidgets + self.tableWidget = tab.findChild(QTableWidget, "tableWidget") + self.tableWidget.setObjectName(f"table_{i}") + self.tableWidget.setRowCount(30) + self.tableWidget.setColumnCount(2) + self.tableWidget.setHorizontalHeaderLabels(["Parameter", "Value"]) + self.tableWidget.setEditTriggers(QTableWidget.NoEditTriggers) + header = self.tableWidget.horizontalHeader() + header.setSectionResizeMode(QHeaderView.Stretch) # Stretch all columns to fill the space + + # --- Style Sheet + self.sh_tab = """ + QTabBar::tab { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #ffffff, stop:1 #dddddd); + border: 1px solid #aaa; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + padding: 10px 20px; + color: #333; + font-weight: bold; + } + """ + self.sh_selected_tab = """ + QTabBar::tab:selected { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #7BD77B, stop:1 #68C068); + color: white; + border-bottom: 2px solid #339933; + } + """ + self.tabWidget.setStyleSheet(self.sh_tab + self.sh_selected_tab) + + # --- init + self.button_group.button(0).setChecked(True) + self.tabWidget.setCurrentIndex(0) + + # --- Connecting the radio buttons + def radio_toggled (self): + selected_button = self.sender() + + if self.previous_checked_index is not None: + self.tabWidget.setTabText(self.previous_checked_index, f"s{self.previous_checked_index}" ) + + tab = self.tabWidget.widget(self.previous_checked_index) + self.load_button = tab.findChild(QPushButton, f"loadButton_{self.previous_checked_index}") + self.default_button = tab.findChild(QPushButton , f"defaultButton_{self.previous_checked_index}") + + if self.load_button: + self.load_button.setEnabled(False) + + if self.default_button: + self.default_button.setEnabled(False) + + + if selected_button.isChecked(): + index = self.button_group.id(selected_button) + self.label_active_setup.setText("Active setup : " + self.tabWidget.tabText(index) ) + self.tabWidget.setTabText(index,f"🔴") + + tab = self.tabWidget.widget(index) + self.load_button = tab.findChild(QPushButton,f"loadButton_{index}") + self.default_button = tab.findChild(QPushButton , f"defaultButton_{index}") + + if self.load_button: + self.load_button.setEnabled(True) + + if self.default_button: + self.default_button.setEnabled(True) + + self.previous_checked_index = index + + + # --- Fill TableWidget + def load_parameters(self, current_dict , table_index): + for i, (key, value) in enumerate(current_dict.items()): + self.tableWidget= self.findChild(QTableWidget, f"table_{table_index}") + self.tableWidget.setItem(i, 0, QTableWidgetItem(str(key))) + self.tableWidget.setItem(i, 1, QTableWidgetItem(str(value))) + + +# ------------------------------------------------- Tests +if __name__ == "__main__": + app = QApplication(sys.argv) + window = OsciTab() + window.show() + sys.exit(app.exec()) + + diff --git a/labscript_devices/KeysightScope/blacs_workers.py b/labscript_devices/KeysightScope/blacs_workers.py new file mode 100644 index 00000000..459df3d5 --- /dev/null +++ b/labscript_devices/KeysightScope/blacs_workers.py @@ -0,0 +1,173 @@ +from labscript_devices.KeysightScope.KeysightScope import KeysightScopeDevice +import numpy as np +import h5py +from zprocess import rich_print +from blacs.tab_base_classes import Worker +from labscript_utils import properties + +# from matplotlib.ticker import MaxNLocator +# from matplotlib import pyplot as plt + + +class KeysightScopeWorker(Worker): + """ + Defines the software control interface to the hardware. + The BLACS_tab spawns a process that uses this class to communicate with the hardware. + """ + def init(self): + # ----------------------------------------- Initialize osci + global KeysightScopeDevice + from .KeysightScope import KeysightScopeDevice + self.scope = KeysightScopeDevice( + address= self.address, + verbose = False) + + # ----------------------------------------- Configurations attributes + self.configuration_register = {} # Docu : KeysightScope.get_saving_register() + self.activated_configuration = 0 + + # ----------------------------------------- Buffered/Manuel flags + self.buffered_mode = False + + + def transition_to_buffered( self, device_name, h5file , front_panel_values, refresh): + rich_print(f"====== Begin transition to Buffered: ======", color='#66D9EF') + + self.h5file = h5file + self.device_name = device_name + + with h5py.File(self.h5file, 'r+') as f: + + # ----------------------------------------- Get device properties + self.activated_configuration = properties.get(f, device_name, 'device_properties')["configuration_number"] + + print("====== Activated configuration : " , self.activated_configuration , "=======") + + self.triggered = properties.get(f, device_name, 'device_properties')["triggered"] + self.timeout = properties.get(f, device_name, 'device_properties')["timeout"] + + self.current_configuration = self.configuration_register[int(self.activated_configuration)] + + # ----------------------------------------- Error handling + # # --- Finish if no trigger + if not self.triggered: + return {} + + # --- Trigger source must be external + trigger_source = self.current_configuration["trigger_source"] + if not (trigger_source == "EXT" or trigger_source == "EXTernal"): + raise KeyError("Trigger source must be an external trigger source (EXT), not channel") + + # ----------------------------------------- Update configuration dictionary + self.current_configuration["triggered"] = self.triggered + self.current_configuration["timeout"] = self.timeout + + + # ----------------------------------------- Setting the oscilloscope + self.scope.dev.timeout = 1e3 *float(self.timeout) # Set Timeout + + self.scope.recall_start_setup(self.activated_configuration) + + self.buffered_mode = True # confirm that we buffered + self.scope.lock() # Lock The oscilloscope + + self.scope.single() + + rich_print(f"====== End transition to Buffered: ======", color='#66D9EF') + return {} + + def transition_to_manual(self, abort = False): + rich_print(f"====== Begin transition to manual: ======", color='#A6E22E') + self.scope.unlock() # Unlocks The oscilloscope + + if (not self.buffered_mode) or (not self.triggered) or abort : # In case we didn't take a shot or we didn't trigger + return True + self.buffered_mode = False # reset the Flag to False + + channels = self.scope.channels() # Get the dispayed channels + preamble = {} # data_dict + vals = {} + wtype = [('t', 'float')] + for ch, enabled in channels.items(): + if enabled: + preamble[ch], t, vals[ch] = self.scope.waveform(waveform_format= "BYTE", channel= ch ) + # preamble[ch], t, vals[ch] = self.scope.waveform_ascii() + wtype.append((ch, 'float')) + + data = np.empty(len(t), dtype=wtype) # Collect all data in a structured array + data['t'] = t + for ch in vals: + data[ch] = vals[ch] + # plt.xlabel('Time') # For debugging purposes + # plt.ylabel('Voltage') + # plt.plot(data['t'],vals[ch]) + # plt.gca().xaxis.set_major_locator(MaxNLocator(integer=True, prune='both')) + # plt.xticks(rotation=45) + # plt.show(block=False) + + with h5py.File(self.h5file, 'r+') as hdf_file: # r+ : Read/write, file must already exist + grp = hdf_file.create_group('/data/traces') + dset = grp.create_dataset(self.device_name , data=data) + dset.attrs.update(preamble[ch]) # This saves the preamble of the waveform + dset.attrs.update(self.current_configuration) + + #self.scope.set_acquire_state(True) # run on + rich_print(f"====== End transition to manual: ======", color='#A6E22E') + return True + + # ----------------------------------------- Aborting + def abort_transition_to_buffered(self): + """Special abort shot configuration code belongs here. + """ + return self.transition_to_manual(True) + + def abort_buffered(self): + """Special abort shot code belongs here. + """ + return self.transition_to_manual(True) + + # ----------------------------------------- Override for remote + def program_manual(self,front_panel_values): + """Over-ride this method if remote programming is supported. + + Returns: + :obj:`check_remote_values()` + """ + return self.check_remote_values() + + def check_remote_values(self): + # over-ride this method if remote value check is supported + return {} + + # ------------------------------------------ Blacs Tabs functions + def shutdown(self): + rich_print(f"====== transition to manual: ======", color= '#AE81FF') + return self.scope.close() + + # ------------------------------------------ New + def init_osci(self): + self.configuration_register = self.scope.get_saving_register() + self.scope.recall_start_setup() + return self.configuration_register + + def activate_radio_button(self,value): + self.scope.recall_start_setup(location= value) + + + def load_current_config(self,value): + self.scope.save_start_setup(value) + new_configuration = self.scope.get_settings_dict(value) + self._refresh_configuration_register(value , new_configuration) + return new_configuration + + def default_config(self, value): + self.scope.reset_device() + self.scope.save_start_setup(value) + new_configuration = self.scope.get_settings_dict(value) + self._refresh_configuration_register(value , new_configuration) + return new_configuration + + def _refresh_configuration_register(self , slot , new_configuration): + self.configuration_register[slot] = new_configuration + + \ No newline at end of file diff --git a/labscript_devices/KeysightScope/labscript_devices.py b/labscript_devices/KeysightScope/labscript_devices.py new file mode 100644 index 00000000..66c7ed44 --- /dev/null +++ b/labscript_devices/KeysightScope/labscript_devices.py @@ -0,0 +1,97 @@ +from labscript import TriggerableDevice, LabscriptError, set_passed_properties ,LabscriptError,set_passed_properties +from re import sub +from pyvisa import ResourceManager + + +class KeysightScope(TriggerableDevice): + """ + A labscript_device for Keysight oscilloscopes (1200 X-Series and EDUX1052A/G) using a visa interface. + - connection_table_properties (set once) + - device_properties (set per shot) + """ + + @set_passed_properties( + property_names = { + 'device_properties' : ["configuration_number","triggered", "timeout"] + } + ) + def __init__(self, + name, + serial_number, + parent_device, + connection = "trigger", + timeout = 5, + **kwargs): + TriggerableDevice.__init__(self, name, parent_device, connection, **kwargs) + + self.BLACS_connection = self.get_adress_from_serial_number(serial_number) + + # --------------------------------- Class attributes + self.name = name + self.timeout = timeout + self.triggered = False # Device can only be triggered zero or one time + self.configuration_number = None # Sets the configuraton slot + + + def get_adress_from_serial_number(self,serial_number): + rm = ResourceManager() + devs = rm.list_resources() + is_right_model= False + is_right_serial_number = False + supported_models = ["EDUX1052A", "EDUX1052G" ,"DSOX1202A" , "DSOX1202G","DSOX1204A" , "DSOX1204G"] + + for idx, item in enumerate(devs): + try: + scope = rm.open_resource(devs[idx], timeout=500) # opens the device + osci_model = scope.query("*IDN?") # asks about the identity + + for supported_model in supported_models: # checks if it is a supported model + if supported_model in osci_model: + is_right_model= True + + # Check the serial number + scope_serial_number = sub(r'\s+', '', scope.query(":SERial?")) # gets the device serial number + is_right_serial_number = (scope_serial_number == serial_number) # checks the conformity between the given and the read serial number + + if is_right_serial_number and is_right_model: + return item + elif not is_right_model and is_right_serial_number: + raise ValueError(f"The device model {osci_model} is not supported. Supported models are EDUX1052A, EDUX1052G, DSOX1202A, DSOX1202G, DSOX1204A, DSOX1204G.") + except: + continue + + if not is_right_serial_number: + raise ValueError(f"No Device with the serial number {serial_number} was found. Please check connection") + + + def set_config(self,configuration_number): + """ + Change the configuration of the oscilloscope to the specified configuration number. + + Args: + configuration_number (str or int): The number of the configuration to switch to (0-9). + + Raises: + LabscriptError: If the configuration number is not between 0 and 9. + """ + if not (0 <= configuration_number <= 9): + raise LabscriptError("Value must be between 0 and 9") + + self.configuration_number = configuration_number + + + def trigger_at(self, t, duration ): + if self.triggered: + raise LabscriptError("Cannot trigger Keysight Oscilloscope twice") + self.triggered = True + self.trigger(t, duration) + + + def generate_code(self, hdf5_file, *args): + TriggerableDevice.generate_code(self, hdf5_file) + + if self.configuration_number is not None: + self.set_property('configuration_number', self.configuration_number, location='device_properties', overwrite=True) + + if self.triggered: + self.set_property('triggered', self.triggered , location='device_properties', overwrite=True) \ No newline at end of file diff --git a/labscript_devices/KeysightScope/register_classes.py b/labscript_devices/KeysightScope/register_classes.py new file mode 100644 index 00000000..cadc8cf9 --- /dev/null +++ b/labscript_devices/KeysightScope/register_classes.py @@ -0,0 +1,7 @@ +import labscript_devices + +labscript_devices.register_classes( + 'KeysightScope', + BLACS_tab='labscript_devices.KeysightScope.blacs_tabs.KeysightScopeTab', + runviewer_parser=None +) diff --git a/labscript_devices/KeysightScope/tab_template.ui b/labscript_devices/KeysightScope/tab_template.ui new file mode 100644 index 00000000..5a76453a --- /dev/null +++ b/labscript_devices/KeysightScope/tab_template.ui @@ -0,0 +1,78 @@ + + + Form + + + + 0 + 0 + 642 + 370 + + + + Form + + + + + + QLayout::SetDefaultConstraint + + + + + + 0 + 0 + + + + Activate + + + + + + + + + + Qt::Horizontal + + + + + + + reset to default + + + + + + + Load and save + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + diff --git a/labscript_devices/KeysightScope/tabs.ui b/labscript_devices/KeysightScope/tabs.ui new file mode 100644 index 00000000..aeaabc32 --- /dev/null +++ b/labscript_devices/KeysightScope/tabs.ui @@ -0,0 +1,127 @@ + + + Form + + + + 0 + 0 + 814 + 556 + + + + Form + + + + + + QLayout::SetDefaultConstraint + + + + + + 0 + 0 + + + + + 7 + true + + + + <html><head/><body><p><span style=" font-size:12pt; font-weight:600;">Setup tabs</span></p></body></html> + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + + 9 + 75 + true + + + + Active setup : + + + + + + + + 0 + 0 + + + + 0 + + + + s0 + + + + + s1 + + + + + s2 + + + + + s3 + + + + + s4 + + + + + s5 + + + + + s6 + + + + + s7 + + + + + s8 + + + + + s9 + + + + + + + + + + +