From 049a5b771435726ff2f27eccfe9b63ab8ca76d36 Mon Sep 17 00:00:00 2001 From: ATOMICS-Lab Date: Fri, 23 May 2025 10:10:34 +0200 Subject: [PATCH 01/13] Add BS 34-1A --- labscript_devices/BS_341A/BLACS_tabs.py | 110 ++++++++ labscript_devices/BS_341A/BLACS_workers.py | 243 ++++++++++++++++++ labscript_devices/BS_341A/BS_341A.md | 16 ++ labscript_devices/BS_341A/__init__.py | 0 labscript_devices/BS_341A/emulateSerPort.py | 49 ++++ .../BS_341A/labscript_devices.py | 84 ++++++ labscript_devices/BS_341A/logger_config.py | 24 ++ labscript_devices/BS_341A/register_classes.py | 7 + .../BS_341A/test_bs341A_queries.py | 90 +++++++ labscript_devices/BS_341A/voltage_source.py | 220 ++++++++++++++++ 10 files changed, 843 insertions(+) create mode 100644 labscript_devices/BS_341A/BLACS_tabs.py create mode 100644 labscript_devices/BS_341A/BLACS_workers.py create mode 100644 labscript_devices/BS_341A/BS_341A.md create mode 100644 labscript_devices/BS_341A/__init__.py create mode 100644 labscript_devices/BS_341A/emulateSerPort.py create mode 100644 labscript_devices/BS_341A/labscript_devices.py create mode 100644 labscript_devices/BS_341A/logger_config.py create mode 100644 labscript_devices/BS_341A/register_classes.py create mode 100644 labscript_devices/BS_341A/test_bs341A_queries.py create mode 100644 labscript_devices/BS_341A/voltage_source.py diff --git a/labscript_devices/BS_341A/BLACS_tabs.py b/labscript_devices/BS_341A/BLACS_tabs.py new file mode 100644 index 00000000..4368f8be --- /dev/null +++ b/labscript_devices/BS_341A/BLACS_tabs.py @@ -0,0 +1,110 @@ +from qtutils.qt.QtWidgets import QPushButton, QSizePolicy, QHBoxLayout, QSpacerItem, QSizePolicy as QSP +from blacs.tab_base_classes import Worker, define_state +from blacs.device_base_class import DeviceTab +from .logger_config import logger +from blacs.tab_base_classes import MODE_MANUAL + +class BS_341ATab(DeviceTab): + def initialise_GUI(self): + + connection_table = self.settings['connection_table'] + properties = connection_table.find_by_name(self.device_name).properties + + # Capabilities + self.base_units = 'V' + self.base_min = -5 # TODO: What is the range? + self.base_max = 5 + self.base_step = 1 + self.base_decimals = 3 + self.num_AO = 4 + + # Create AO Output objects + ao_prop = {} + for i in range(self.num_AO): + ao_prop['channel %d' % i] = { + 'base_unit': self.base_units, + 'min': self.base_min, + 'max': self.base_max, + 'step': self.base_step, + 'decimals': self.base_decimals, + } + + # Create the output objects + self.create_analog_outputs(ao_prop) + + # Create widgets for output objects + widgets, ao_widgets,_ = self.auto_create_widgets() + self.auto_place_widgets(("Analog Outputs", ao_widgets)) + + # Add button to reprogramm device from manual mode + self.send_button = QPushButton("Send to device") + self.send_button.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.send_button.adjustSize() + self.send_button.setStyleSheet(""" + QPushButton { + border: 1px solid #B8B8B8; + border-radius: 3px; + background-color: #F0F0F0; + padding: 4px 10px; + font-weight: light; + } + QPushButton:hover { + background-color: #E0E0E0; + } + QPushButton:pressed { + background-color: #D0D0D0; + } + """) + self.send_button.clicked.connect(lambda: self.send_to_BS()) + + # Add centered layout to center the button + center_layout = QHBoxLayout() + center_layout.addStretch() + center_layout.addWidget(self.send_button) + center_layout.addStretch() + + # Add center layout on device layout + self.get_tab_layout().addLayout(center_layout) + + self.supports_remote_value_check(False) + self.supports_smart_programming(False) + + + def initialise_workers(self): + # Get properties from connection table. + device = self.settings['connection_table'].find_by_name(self.device_name) + if device is None: + raise ValueError(f"Device '{self.device_name}' not found in the connection table.") + + # look up the port and baud in the connection table + port = device.properties["port"] + baud_rate = device.properties["baud_rate"] + num_AO = device.properties['num_AO'] + worker_kwargs = {"name": self.device_name + '_main', + "port": port, + "baud_rate": baud_rate, + "num_AO": num_AO + } + + # Start a worker process + self.create_worker( + 'main_worker', + 'labscript_devices.BS_341A.BLACS_workers.BS_341AWorker', + worker_kwargs, + ) + self.primary_worker = "main_worker" + + @define_state(MODE_MANUAL, True) + def send_to_BS(self): + """Queue a manual send-to-device operation from the GUI. + + This function is triggered from the BLACS tab (by pressing a button) + and runs in the main thread. It queues the `send2BS()` function to be + executed by the worker. + + Used to reprogram the BS-1-10 device based on current front panel values. + """ + try: + yield(self.queue_work(self.primary_worker, 'send_to_BS', [])) + except Exception as e: + logger.debug(f"Error by send work to worker(send_to_BS): \t {e}") diff --git a/labscript_devices/BS_341A/BLACS_workers.py b/labscript_devices/BS_341A/BLACS_workers.py new file mode 100644 index 00000000..251cad79 --- /dev/null +++ b/labscript_devices/BS_341A/BLACS_workers.py @@ -0,0 +1,243 @@ +from blacs.tab_base_classes import Worker +from labscript import LabscriptError +import serial +from .logger_config import logger +import time +import h5py +import numpy as np +from labscript_utils import properties +from zprocess import rich_print +from datetime import datetime + +class BS_341AWorker(Worker): + def init(self): + """Initialises communication with the device. When BLACS (re)starts""" + self.final_values = {} # [[channel_nums(ints)],[voltages(floats)]] + self.verbose = True + + try: + # Try to establish a serial connection + from .voltage_source import VoltageSource + self.voltage_source = VoltageSource(self.port, self.baud_rate) + + # Get device information + self.device_serial = self.voltage_source.device_serial # For example, 'HV023' + self.device_voltage_range = self.voltage_source.device_voltage_range or 5 # For example, '50' # TODO: 5 volts for safety + self.device_channels = self.voltage_source.device_channels # For example, '10' + self.device_output_type = self.voltage_source.device_output_type # For example, 'b' (bipolar, unipolar, quadrupole, steerer supply) + + logger.info( + f"Connected to BS-34-1A on {self.port} with baud rate {self.baud_rate}\n" + f"Device Serial: {self.device_serial}, Voltage Range: {self.device_voltage_range}, " + f"Channels: {self.device_channels}, Output Type: {self.device_output_type}" + ) + + except LabscriptError as e: + raise RuntimeError(f"BS-1-10 identification failed: {e}") + except Exception as e: + raise RuntimeError(f"An error occurred during BS_341AWorker initialization: {e}") + + + def shutdown(self): + # Should be done when Blacs is closed + self.connection.close() + + def program_manual(self, front_panel_values): + """Allows for user control of the device via the BLACS_tab, + setting outputs to the values set in the BLACS_tab widgets. + Runs at the end of the shot.""" + + rich_print(f"---------- Manual MODE start: ----------", color=PINK) + self.front_panel_values = front_panel_values + + if self.verbose is True: + print("Front panel values (before shot):") + for ch_name, voltage in front_panel_values.items(): + print(f" {ch_name}: {voltage:.2f} V") + + # Restore final values from previous shot, if available + if self.final_values and not getattr(self, 'restored_from_final_values', False): + for ch_num, value in self.final_values.items(): + front_panel_values[f'channel {int(ch_num)}'] = value + self.restored_from_final_values = True + + if self.verbose is True: + print("\nFront panel values (after shot):") + for ch_num, voltage in self.final_values.items(): + print(f" {ch_num}: {voltage:.2f} V") + + self.final_values = {} # Empty after restoring + + return front_panel_values + + def check_remote_values(self): # reads the current settings of the device, updating the BLACS_tab widgets + return + + def transition_to_buffered(self, device_name, h5_file, initial_values, fresh): + """transitions the device to buffered shot mode, + reading the shot h5 file and taking the saved instructions from + labscript_device.generate_code and sending the appropriate commands + to the hardware. + Runs at the start of each shot.""" + rich_print(f"---------- Begin transition to Buffered: ----------", color=BLUE) + self.restored_from_final_values = False # Drop flag + self.initial_values = initial_values # Store the initial values in case we have to abort and restore them + self.final_values = {} # Store the final values to update GUI during transition_to_manual + self.h5file = h5_file # Store path to h5 to write back from front panel + self.device_name = device_name + + with h5py.File(h5_file, 'r') as hdf5_file: + group = hdf5_file['devices'][device_name] + AO_data = group['AO'][:] + # self.device_prop = properties.get(hdf5_file, device_name, 'device_properties') + # print("======== Device Properties : ", self.device_prop, "=========") + + for row in AO_data: + if self.verbose is True: + time = row["time"] + print(f"\n time = {time}") + logger.info(f"Programming the device from buffered at time {time} with following values") + + for channel_name in row.dtype.names: + if channel_name.lower() == 'time': # Skip the time column + continue + + voltage = row[channel_name] + channel_num = self._get_channel_num(channel_name) + self.voltage_source.set_voltage(channel_num, voltage) + + if self.verbose is True: + print(f"→ Channel: {channel_name} (#{channel_num}), Voltage: {voltage}") + + # Store the values + self.final_values[channel_num] = voltage + + rich_print(f"---------- End transition to Buffered: ----------", color=BLUE) + return + + + def transition_to_manual(self): + """transitions the device from buffered to manual mode to read/save measurements from hardware + to the shot h5 file as results. + Runs at the end of the shot.""" + return True + + def abort_transition_to_buffered(self): + return self.transition_to_manual() + + def _program_manual(self, front_panel_values): + """Sends voltage values to the device for all channels using VoltageSource. + """ + if self.verbose is True: + print("\nProgramming the device with the following values:") + logger.info("Programming the device from manual with the following values:") + + for channel_num in range(int(self.num_AO)): + channel_name = f'channel {channel_num}' + voltage = front_panel_values.get(channel_name, 0.0) + if self.verbose is True: + print(f"→ {channel_name}: {voltage:.2f} V") + # logger.info(f"Setting {channel_name} to {voltage:.2f} V (manual mode)") + self.voltage_source.set_voltage(channel_num, voltage) + + def _get_channel_num(self, channel): + """Gets channel number with leading zeros 'XX' from strings like 'AOX' or 'channel X'. + Args: + channel (str): The name of the channel, e.g. 'AO0', 'AO12', or 'channel 3'. + + Returns: + str: Two-digit channel number as string, e.g. '01', '12'.""" + ch_lower = channel.lower() + if ch_lower.startswith("ao"): + channel_num = channel[2:] # 'ao3' -> '3' + elif ch_lower.startswith("channel"): + _, channel_num = channel.split() # 'channel 1' -> '1' + else: + raise LabscriptError(f"Unexpected channel name format: '{channel}'") + + channel_int = int(channel_num) + return f"{channel_int:02d}" + + def send_to_BS(self, kwargs): + """Sends manual values from the front panel to the BS-1-10 device. + This function is executed in the worker process. It uses the current + front panel values to reprogram the device in manual mode by clicking the button 'send to device'. + Args: + kwargs (dict): Not used currently. + """ + self._program_manual(self.front_panel_values) + current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + self._append_front_panel_values_to_manual(self.front_panel_values, current_time) + + def _append_front_panel_values_to_manual(self, front_panel_values, current_time): + """ + Append front-panel voltage values to the 'AO_manual' dataset in the HDF5 file. + + This method records the current manual voltage settings (from the front panel) + along with a timestamp into the 'AO_manual' table inside the device's HDF5 group. + It assumes that `self.h5file` and `self.device_name` have been set + (in `transition_to_buffered`). If not, a RuntimeError is raised. + + Parameters + ---------- + front_panel_values : dict + Dictionary mapping channel names (e.g., 'channel 0') to voltage values (float). + current_time : str + The timestamp (formatted as a string) when the values were recorded + + Raises + ------ + RuntimeError + If `self.h5file` is not set (i.e., manual values are being saved before + the system is in buffered mode). + """ + # Check if h5file is set (transition_to_buffered must be called first) + if not hasattr(self, 'h5file') or self.h5file is None: + raise RuntimeError( + "Cannot save manual front-panel values: " + "`self.h5file` is not set. Make sure `transition_to_buffered()` has been called before sending to the device." + ) + + with h5py.File(self.h5file, 'r+') as hdf5_file: + group = hdf5_file['devices'][self.device_name] + # print("Keys in group:", list(group.keys())) + + dset = group['AO_manual'] + old_shape = dset.shape[0] + dtype = dset.dtype + connections = [name for name in dset.dtype.names if name != 'time'] #'ao1' + + # Create new data row + new_row = np.zeros((1,), dtype=dtype) + new_row['time'] = current_time + for conn in connections: + channel_name = self._ao_to_channel_name(conn) + new_row[conn] = front_panel_values.get(channel_name, 0.0) + + # Add new row to table + dset.resize(old_shape + 1, axis=0) + dset[old_shape] = new_row[0] + + @staticmethod + def _ao_to_channel_name(ao_name: str) -> str: + """ Convert 'ao0' to 'channel 0' """ + try: + channel_index = int(ao_name.replace('ao', '')) + return f'channel {channel_index}' + except ValueError: + raise ValueError(f"Impossible to convert from '{ao_name}'") + + @staticmethod + def _channel_name_to_ao(channel_name: str) -> str: + """ Convert 'channel 0' to 'ao0' """ + try: + channel_index = int(channel_name.replace('channel ', '')) + return f'ao{channel_index}' + except ValueError: + raise ValueError(f"Impossible to convert from '{channel_name}'") + + + +# --------------------contants +PINK = 'ff52fa' +BLUE = '#66D9EF' \ No newline at end of file diff --git a/labscript_devices/BS_341A/BS_341A.md b/labscript_devices/BS_341A/BS_341A.md new file mode 100644 index 00000000..2bb8d8b5 --- /dev/null +++ b/labscript_devices/BS_341A/BS_341A.md @@ -0,0 +1,16 @@ +# Precision multichannel voltage source for Spectroscopy. + +[Manual](https://www.manualslib.com/manual/1288197/Stahl-Electronics-Bs-Series.html?page=28#manual) + +Interface: USB +- standard setting: 9600 baud, no handshake +- fast-mode settings: 115200 baud +Output: BNC (voltage analog output) + +--- +## Remote commands +IDN | Identify +DDDDD CHXX Y.YYYYY | Set voltage +DDDDD TEMP | Read Temperature +DDDDD LOCK | Check lock status of all channels +DDDDD DIS [message] | Send string to LCD-display diff --git a/labscript_devices/BS_341A/__init__.py b/labscript_devices/BS_341A/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/labscript_devices/BS_341A/emulateSerPort.py b/labscript_devices/BS_341A/emulateSerPort.py new file mode 100644 index 00000000..fc323c65 --- /dev/null +++ b/labscript_devices/BS_341A/emulateSerPort.py @@ -0,0 +1,49 @@ +""" +Simulate the serial port for the BS 34-1A, as I don't have access to the real device. + +You will create a virtual serial port using this script. This script will act as if it’s the BS 34-1A device. When you run the script, it will open a serial port (for example, /dev/pts/1) and allow other programs (such as your BLACS worker) to communicate with it. + +The virtual serial port should stay open while the simulation is running, so other code that expects to interact with the serial device can do so just as if the actual device were connected. + +Run following command + cd labscript-suite/labscript-devices/labscript_devices + python3 -m BS_341A.emulateSerPort + +""" + +import os, pty +import time + +def test_serial(): + """ + Initialise the serial port. + prints the serial port to use. + """ + master, slave = pty.openpty() + port_name = os.ttyname(slave) + print(f"For BS 34-1A use: {port_name}") + + while True: + device_identity = "HV341 14 4 b\r" + command = read_command(master).decode().strip() + if command: + print("command {}".format(command)) + if command == "IDN": + response = device_identity.encode() + os.write(master, response) + elif command.startswith("HV341 CH"): + device, channel, voltage = command.split()[:3] + response = f"{channel} {voltage}\r" + os.write(master, response.encode()) + else: + response = f"err\r" + os.write(master, response.encode()) + + time.sleep(0.1) + +def read_command(master): + """ Reads the command until the '\r' character is encountered. """ + return b"".join(iter(lambda: os.read(master, 1), b"\r")) + +if __name__ == "__main__": + test_serial() \ No newline at end of file diff --git a/labscript_devices/BS_341A/labscript_devices.py b/labscript_devices/BS_341A/labscript_devices.py new file mode 100644 index 00000000..77cbd347 --- /dev/null +++ b/labscript_devices/BS_341A/labscript_devices.py @@ -0,0 +1,84 @@ +from labscript_devices import register_classes +from labscript import Device, set_passed_properties, IntermediateDevice, AnalogOut, config +from labscript import IntermediateDevice +import h5py +import numpy as np +from labscript_devices.NI_DAQmx.utils import split_conn_DO, split_conn_AO +from .logger_config import logger + +class BS_341A(IntermediateDevice): # no pseudoclock IntermediateDevice --> Device + description = 'BS_341A' + + @set_passed_properties({"connection_table_properties": ["port", "baud_rate", "num_AO"]}) + def __init__(self, name, port='', baud_rate=115200, parent_device=None, connection=None, num_AO=0, **kwargs): + IntermediateDevice.__init__(self, name, parent_device, **kwargs) + # self.start_commands = [] + self.BLACS_connection = '%s,%s' % (port, str(baud_rate)) + + def add_device(self, device): + Device.add_device(self, device) + + def generate_code(self, hdf5_file): + """Convert the list of commands into numpy arrays and save them to the shot file.""" + logger.info("generate_code for BS 34-1A is called") + IntermediateDevice.generate_code(self, hdf5_file) + group = self.init_device_group(hdf5_file) + + clockline = self.parent_device + pseudoclock = clockline.parent_device + times = pseudoclock.times[clockline] + + # create dataset + analogs = {} + for child_device in self.child_devices: + if isinstance(child_device, AnalogOut): + analogs[child_device.connection] = child_device + + AO_table = self._make_analog_out_table(analogs, times) + logger.info(f"Times in generate_code AO table: {times}") + logger.info(f"AO table for BS-34-1A is: {AO_table}") + AO_manual_table = self._make_analog_out_table_from_manual(analogs) + + group.create_dataset("AO", data=AO_table, compression=config.compression) + group.create_dataset("AO_manual", shape=AO_manual_table.shape, maxshape=(None,), dtype=AO_manual_table.dtype, + compression=config.compression, chunks=True) + + + def _make_analog_out_table(self, analogs, times): + """Create a structured numpy array with first column as 'time', followed by analog channel data. + Args: + analogs (dict): Mapping of connection names to AnalogOut devices. + times (array-like): Array of time points. + Returns: + np.ndarray: Structured array with time and analog outputs. + """ + if not analogs: + return None + + n_timepoints = len(times) + connections = sorted(analogs, key=split_conn_AO) # sorted channel names + dtypes = [('time', np.float64)] + [(c, np.float32) for c in connections] # first column = time + + analog_out_table = np.empty(n_timepoints, dtype=dtypes) + + analog_out_table['time'] = times + for connection, output in analogs.items(): + analog_out_table[connection] = output.raw_output + + return analog_out_table + + def _make_analog_out_table_from_manual(self, analogs): + """Create a structured empty numpy array with first column as 'time', followed by analog channel data. + Args: + times (array-like): Array of timestamps. + ... + Returns: + np.ndarray: Structured empty array with time and analog outputs.""" + + str_dtype = h5py.string_dtype(encoding='utf-8', length=19) + + connections = sorted(analogs, key=split_conn_AO) # sorted channel names + dtypes = [('time', str_dtype)] + [(c, np.float32) for c in connections] + + analog_out_table = np.empty(0, dtype=dtypes) + return analog_out_table diff --git a/labscript_devices/BS_341A/logger_config.py b/labscript_devices/BS_341A/logger_config.py new file mode 100644 index 00000000..d5446466 --- /dev/null +++ b/labscript_devices/BS_341A/logger_config.py @@ -0,0 +1,24 @@ +import os +import logging + +# Configure the logger +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +LOG_FILE = os.path.join(BASE_DIR, 'logs.log') + +# Create logger +logger = logging.getLogger("logs") +logger.setLevel(logging.DEBUG) + +# Create file handler and set level to debug +handler = logging.FileHandler(LOG_FILE) +handler.setLevel(logging.DEBUG) + +# Create formatter and set it for the handler +formatter = logging.Formatter('%(asctime)s %(levelname)s %(name)s: %(message)s') +handler.setFormatter(formatter) + +# Add handler to the logger +logger.addHandler(handler) + +# Test the logger in the config file +logger.info("Logger initialized successfully") diff --git a/labscript_devices/BS_341A/register_classes.py b/labscript_devices/BS_341A/register_classes.py new file mode 100644 index 00000000..dda2de57 --- /dev/null +++ b/labscript_devices/BS_341A/register_classes.py @@ -0,0 +1,7 @@ +from labscript_devices import register_classes + +register_classes( + "BS_341A", + BLACS_tab='labscript_devices.BS_341A.BLACS_tabs.BS_341ATab', + runviewer_parser=None, +) \ No newline at end of file diff --git a/labscript_devices/BS_341A/test_bs341A_queries.py b/labscript_devices/BS_341A/test_bs341A_queries.py new file mode 100644 index 00000000..07527176 --- /dev/null +++ b/labscript_devices/BS_341A/test_bs341A_queries.py @@ -0,0 +1,90 @@ +import os +import pty +import time +import threading +import unittest +import serial + +# Simulated serial device logic (used for testing) +def test_serial_emulator(master_fd): + while True: + command = read_command(master_fd).decode().strip() + if command: + print(f"[EMULATOR] Received command: {command}") + if command == "IDN": + response = "HV341 14 4 b\r" + elif command.startswith("HV341 U"): + response = "+12,345 V\r" + elif command.startswith("HV341 I"): + response = "-00,123 mA\r" + elif command.startswith("HV341 Q"): + response = "+12,345 V -00,123 mA\r" + elif command.startswith("HV341 TEMP"): + response = "TEMP 45.3°C\r" + else: + response = "err\r" + os.write(master_fd, response.encode()) + time.sleep(0.1) + +def read_command(master_fd): + return b"".join(iter(lambda: os.read(master_fd, 1), b"\r")) + +class TestBS110DeviceQueries(unittest.TestCase): + @classmethod + def setUpClass(cls): + master_fd, slave_fd = pty.openpty() + cls.slave_name = os.ttyname(slave_fd) + cls.master_fd = master_fd + + cls.emulator_thread = threading.Thread(target=test_serial_emulator, args=(master_fd,), daemon=True) + cls.emulator_thread.start() + + cls.serial_conn = serial.Serial(cls.slave_name, baudrate=9600, timeout=1) + + class DummyWorker: + device_serial = "HV341" + port = cls.slave_name + connection = cls.serial_conn + device_voltage_range = 60.0 + def _scale_to_normalized(self, val, rng): return val / rng + + cls.worker = DummyWorker() + + def test_identify_query(self): + self.worker.connection.write(b"IDN\r") + response = self.worker.connection.readline().decode().strip() + self.assertEqual(response, "HV341 14 4 b") + + def test_voltage_query(self): + self.worker.connection.write(b"HV341 U01\r") + response = self.worker.connection.readline().decode().strip() + self.assertTrue(response.endswith("V")) + voltage = float(response[:-1].replace(",", ".").strip()) + self.assertAlmostEqual(voltage, 12.345) + + def test_current_query(self): + self.worker.connection.write(b"HV341 I01\r") + response = self.worker.connection.readline().decode().strip() + self.assertTrue(response.endswith("mA")) + current = float(response[:-2].replace(",", ".").strip()) + self.assertAlmostEqual(current, -0.123) + + def test_vol_curr_query(self): + self.worker.connection.write(b"HV341 Q01\r") + response = self.worker.connection.readline().decode().strip() + self.assertTrue(response.endswith("mA") and "V" in response) + parts = response.split("V") + voltage = float(parts[0].replace(",", ".").strip()) + current = float(parts[1].replace("mA", "").replace(",", ".").strip()) + self.assertAlmostEqual(voltage, 12.345) + self.assertAlmostEqual(current, -0.123) + + def test_temperature_query(self): + self.worker.connection.write(b"HV341 TEMP\r") + response = self.worker.connection.readline().decode().strip() + self.assertTrue(response.endswith("°C")) + temperature = float(response.split()[1].replace("°C", "")) + self.assertAlmostEqual(temperature, 45.3) + +if __name__ == "__main__": + unittest.main() diff --git a/labscript_devices/BS_341A/voltage_source.py b/labscript_devices/BS_341A/voltage_source.py new file mode 100644 index 00000000..53041bdd --- /dev/null +++ b/labscript_devices/BS_341A/voltage_source.py @@ -0,0 +1,220 @@ +import serial +import numpy as np +from labscript.labscript import LabscriptError +from .logger_config import logger + +class VoltageSource: + """ Voltage Source for ST BS 34-1/BS 1-8 class to establish and maintain the communication with the connection. + """ + def __init__(self, + port, + baud_rate, + verbose=False + ): + logger.debug(f"") + self.verbose = verbose + self.port = port + self.baud_rate = baud_rate + + # connecting to connectionice + self.connection = serial.Serial(self.port, self.baud_rate, timeout=1) + device_info = self.identify_query() + self.device_serial = device_info[0] # For example, 'HV023' + self.device_voltage_range = device_info[1] # For example, '50' + self.device_channels = device_info[2] # For example, '10' + self.device_output_type = device_info[3] # For example, 'b' (bipolar, unipolar, quadrupole, steerer supply) + + def identify_query(self): + """Send identification instruction through serial connection, receive response. + Returns: + list[str]: Parsed identity response split by whitespace. + Raises: + LabscriptError: If identity format is incorrect. + """ + self.connection.write("IDN\r".encode()) + raw_response = self.connection.readline().decode() + identity = raw_response.split() + + if len(identity) == 4: + logger.debug(f"Device initialized with identity: {identity}") + return identity + else: + raise LabscriptError( + f"Device identification failed.\n" + f"Raw identity: {raw_response!r}\n" + f"Parsed identity: {identity!r}\n" + f"Expected format: ['BSXXX', 'RRR', 'CC', 'b']\n" + f"Device: BS-1-10 at port {self.port!r}\n" + ) + + def set_voltage(self, channel_num, value): + """ Send set voltage command to device. + Args: + channel_num (int): Channel number. + value (float): Voltage value to set. + Raises: + LabscriptError: If the response from BS-1-10 is incorrect. + """ + try: + channel = f"CH{int(channel_num):02d}" + scaled_voltage = self._scale_to_normalized(float(value), float(self.device_voltage_range)) + send_str = f"{self.device_serial} {channel} {scaled_voltage:.6f}\r" + + self.connection.write(send_str.encode()) + response = self.connection.readline().decode().strip() #'CHXX Y.YYYYY' + + logger.debug(f"Sent to BS-34/BS-1-8: {send_str.strip()} | Received: {response!r}") + + expected_response = f"{channel} {scaled_voltage:.6f}" + if response != expected_response: + raise LabscriptError( + f"Voltage setting failed.\n" + f"Sent command: {send_str.strip()!r}\n" + f"Expected response: {expected_response!r}\n" + f"Actual response: {response!r}\n" + f"Device: BS-1-10 at port {self.port!r}" + ) + except Exception as e: + raise LabscriptError(f"Error in set_voltage: {e}") + + def read_temperature(self): + """ + Query the device for temperature. + Returns: + float: Temperature in Celsius. + Raises: + LabscriptError: If the response format is invalid or parsing fails. + """ + send_str = f"{self.device_serial} TEMP\r" + self.connection.write(send_str.encode()) + + response = self.connection.readline().decode().strip() #'TEMP XXX.X°C' + + if response.endswith("°C"): + try: + # Remove the degree symbol and parse the number + _, temperature_str_raw = response.split() # 'TEMP' 'XXX.X°C' + temperature_str = temperature_str_raw.replace("°C", "").strip() + temperature = float(temperature_str) + return temperature + except ValueError: + raise LabscriptError(f"Failed to parse temperature from response.\n") + else: + raise LabscriptError( + f"Temperature query failed.\n" + f"Unexpected response format: {response!r}\n" + f"Expected a value ending in '°C'." + ) + + def voltage_query(self, channel_num): + """ + Query voltage on the channel. + Args: + channel_num (int): Channel number. + Returns: + float: voltage in Volts. + Raises: + LabscriptError: If the response format is invalid or parsing fails. + """ + + channel = f"{int(channel_num):02d}" # 1 -> '01' + send_str = f"{self.device_serial} U{channel}\r" # 'DDDDD UXX' + self.connection.write(send_str.encode()) + + response = self.connection.readline().decode().strip() # '+/-yy,yyy V' + + if response.endswith("V"): + try: + numeric_part = response[:-1].strip() # remove 'V' and whitespace + numeric_part = numeric_part.replace(',', '.') # convert to Python-style float + voltage = float(numeric_part) + return voltage + except ValueError: + raise LabscriptError(f"Failed to parse voltage from response.\n") + else: + raise LabscriptError( + f"Voltage query failed.\n" + f"Unexpected response format: {response!r}\n" + f"Expected a value ending in 'V'." + ) + + def current_query(self, channel_num): + """ + Query current on the channel. + Args: + channel_num (int): Channel number. + Returns: + float: current in milliAmpere + Raises: + LabscriptError: If the response format is invalid or parsing fails. + """ + + channel = f"{int(channel_num):02d}" # 1 -> '01' + send_str = f"{self.device_serial} I{channel}\r" # 'DDDDD IXX' #TODO: is it I or l or 1? + self.connection.write(send_str.encode()) + + response = self.connection.readline().decode().strip() # '+/-yy,yyy mA' + + if response.endswith("mA"): + try: + numeric_part = response[:-1].strip() # remove 'mA' and whitespace + numeric_part = numeric_part.replace(',', '.') # convert to Python-style float + current = float(numeric_part) + return current + except ValueError: + raise LabscriptError(f"Failed to parse current from response.\n") + else: + raise LabscriptError( + f"Current query failed.\n" + f"Unexpected response format: {response!r}\n" + f"Expected a value ending in 'mA'." + ) + + def vol_curr_query(self, channel_num): + """ + Query voltage and current on the channel. + Args: + channel_num (int): Channel number. + Returns: + float: voltage in Volts. + float: cuurent in milliAmpere + Raises: + LabscriptError: If the response format is invalid or parsing fails. + """ + channel = f"{int(channel_num):02d}" # 1 -> '01' + send_str = f"{self.device_serial} Q{channel}\r" # 'DDDDD QXX' + self.connection.write(send_str.encode()) + + response = self.connection.readline().decode().strip() # '+/-yy,yyy V +/-z,zzz mA' + + if response.endswith("mA"): + try: + parts = response.split("V") + numeric_vol = parts[0].strip() # e.g., '+12,345' + numeric_curr = parts[1].replace("mA", "").strip() # e.g., '-00,123' + numeric_vol = numeric_vol.replace(',', '.') # convert to Python-style float + numeric_curr = numeric_curr.replace(',', '.') + voltage = float(numeric_vol) + current = float(numeric_curr) + return voltage, current + + except (ValueError, IndexError) as e: + raise LabscriptError( + f"Failed to parse voltage and current from response: {response!r}" + ) from e + else: + raise LabscriptError( + f"Voltage and Current query failed.\n" + f"Unexpected response format: {response!r}\n" + f"Expected format like '+12,345 V -00,123 mA'." + ) + + def _scale_to_range(self, normalized_value, max_range): + """Convert a normalized value (0 to 1) to the specified range (-max_range to +max_range)""" + max_range = float(max_range) + return 2 * max_range * normalized_value - max_range + + def _scale_to_normalized(self, actual_value, max_range): + """Convert an actual value (within -max_range to +max_range) to a normalized value (0 to 1)""" + max_range = float(max_range) + return (actual_value + max_range) / (2 * max_range) From f22d2c055a3275799e72788be1fbfd8d7007f8f9 Mon Sep 17 00:00:00 2001 From: ATOMICS-Lab Date: Fri, 23 May 2025 12:55:14 +0200 Subject: [PATCH 02/13] enable voltage setting --- labscript_devices/BS_341A/first_connection.py | 43 +++++++++++++++++++ labscript_devices/BS_341A/voltage_source.py | 9 ++-- 2 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 labscript_devices/BS_341A/first_connection.py diff --git a/labscript_devices/BS_341A/first_connection.py b/labscript_devices/BS_341A/first_connection.py new file mode 100644 index 00000000..f5ee9629 --- /dev/null +++ b/labscript_devices/BS_341A/first_connection.py @@ -0,0 +1,43 @@ +import serial +import time +import serial.tools.list_ports + +# dmesg | grep tty +# ls /dev/tty* +# lsusb + +def check_ports(): + ports = serial.tools.list_ports.comports() + for port in ports: + print(f"Port: {port.device}, Description: {port.description}") + + +port = '/dev/ttyUSB0' +baud_rate = 9600 +timeout = 2 + +try: + ser = serial.Serial(port, baud_rate, timeout=timeout) + print(f"Connected to {port} at {baud_rate} baud") + + while True: + cmd = input("Enter command (or type 'exit' to quit): ") + cms = cmd + '\r' + if cmd.lower() == 'exit': + print("Exiting...") + break + + # Send the command with carriage return, modify if your device uses something else + ser.write((cmd + "\r").encode()) + time.sleep(0.5) # Wait a bit for the device to respond + + # Read all available lines (or just one line if you prefer) + response = ser.readline().decode().strip() + print("Device response:", response) + + ser.close() + +except serial.SerialException as e: + print(f"Serial error: {e}") +except Exception as e: + print(f"Unexpected error: {e}") \ No newline at end of file diff --git a/labscript_devices/BS_341A/voltage_source.py b/labscript_devices/BS_341A/voltage_source.py index 53041bdd..0991bf23 100644 --- a/labscript_devices/BS_341A/voltage_source.py +++ b/labscript_devices/BS_341A/voltage_source.py @@ -50,22 +50,23 @@ def identify_query(self): def set_voltage(self, channel_num, value): """ Send set voltage command to device. Args: - channel_num (int): Channel number. + channel_num (str): Channel number. value (float): Voltage value to set. Raises: LabscriptError: If the response from BS-1-10 is incorrect. """ try: + channel_num = f"{int(channel_num) + 1}" channel = f"CH{int(channel_num):02d}" scaled_voltage = self._scale_to_normalized(float(value), float(self.device_voltage_range)) - send_str = f"{self.device_serial} {channel} {scaled_voltage:.6f}\r" + send_str = f"{self.device_serial} {channel} {scaled_voltage:.5f}\r" self.connection.write(send_str.encode()) response = self.connection.readline().decode().strip() #'CHXX Y.YYYYY' - logger.debug(f"Sent to BS-34/BS-1-8: {send_str.strip()} | Received: {response!r}") + logger.debug(f"Sent to BS-34/BS-1-8: {send_str!r} | Received: {response!r}") - expected_response = f"{channel} {scaled_voltage:.6f}" + expected_response = f"{channel} {scaled_voltage:.5f}" if response != expected_response: raise LabscriptError( f"Voltage setting failed.\n" From 792596fdc0a2fcb906b302531138255de37c0819 Mon Sep 17 00:00:00 2001 From: OljaKhor Date: Mon, 26 May 2025 16:47:47 +0200 Subject: [PATCH 03/13] Add threading in transition_to_buffered --- labscript_devices/BS_341A/BLACS_tabs.py | 6 +- labscript_devices/BS_341A/BLACS_workers.py | 57 ++++++++---- labscript_devices/BS_341A/BS_341A.md | 8 +- labscript_devices/BS_341A/emulateSerPort.py | 6 +- labscript_devices/BS_341A/first_connection.py | 4 +- .../BS_341A/labscript_devices.py | 2 +- .../BS_341A/test_bs341A_queries.py | 90 ------------------- labscript_devices/BS_341A/voltage_source.py | 13 +-- 8 files changed, 57 insertions(+), 129 deletions(-) delete mode 100644 labscript_devices/BS_341A/test_bs341A_queries.py diff --git a/labscript_devices/BS_341A/BLACS_tabs.py b/labscript_devices/BS_341A/BLACS_tabs.py index 4368f8be..d1cd97cc 100644 --- a/labscript_devices/BS_341A/BLACS_tabs.py +++ b/labscript_devices/BS_341A/BLACS_tabs.py @@ -12,11 +12,11 @@ def initialise_GUI(self): # Capabilities self.base_units = 'V' - self.base_min = -5 # TODO: What is the range? - self.base_max = 5 + self.base_min = -24 + self.base_max = 24 self.base_step = 1 self.base_decimals = 3 - self.num_AO = 4 + self.num_AO = 8 # or properties['num_AO'] # Create AO Output objects ao_prop = {} diff --git a/labscript_devices/BS_341A/BLACS_workers.py b/labscript_devices/BS_341A/BLACS_workers.py index 251cad79..209eb733 100644 --- a/labscript_devices/BS_341A/BLACS_workers.py +++ b/labscript_devices/BS_341A/BLACS_workers.py @@ -1,6 +1,7 @@ +import threading + from blacs.tab_base_classes import Worker from labscript import LabscriptError -import serial from .logger_config import logger import time import h5py @@ -12,6 +13,9 @@ class BS_341AWorker(Worker): def init(self): """Initialises communication with the device. When BLACS (re)starts""" + self.thread = None + # self.thread_stop_event = threading.Event() + self.final_values = {} # [[channel_nums(ints)],[voltages(floats)]] self.verbose = True @@ -22,7 +26,7 @@ def init(self): # Get device information self.device_serial = self.voltage_source.device_serial # For example, 'HV023' - self.device_voltage_range = self.voltage_source.device_voltage_range or 5 # For example, '50' # TODO: 5 volts for safety + self.device_voltage_range = self.voltage_source.device_voltage_range # For example, '50' self.device_channels = self.voltage_source.device_channels # For example, '10' self.device_output_type = self.voltage_source.device_output_type # For example, 'b' (bipolar, unipolar, quadrupole, steerer supply) @@ -71,6 +75,7 @@ def program_manual(self, front_panel_values): return front_panel_values def check_remote_values(self): # reads the current settings of the device, updating the BLACS_tab widgets + return def transition_to_buffered(self, device_name, h5_file, initial_values, fresh): @@ -92,25 +97,17 @@ def transition_to_buffered(self, device_name, h5_file, initial_values, fresh): # self.device_prop = properties.get(hdf5_file, device_name, 'device_properties') # print("======== Device Properties : ", self.device_prop, "=========") + # prepare events + events = [] for row in AO_data: - if self.verbose is True: - time = row["time"] - print(f"\n time = {time}") - logger.info(f"Programming the device from buffered at time {time} with following values") - - for channel_name in row.dtype.names: - if channel_name.lower() == 'time': # Skip the time column - continue + t = row['time'] + voltages = {ch: row[ch] for ch in row.dtype.names if ch != 'time'} + events.append((t, voltages)) - voltage = row[channel_name] - channel_num = self._get_channel_num(channel_name) - self.voltage_source.set_voltage(channel_num, voltage) - - if self.verbose is True: - print(f"→ Channel: {channel_name} (#{channel_num}), Voltage: {voltage}") - - # Store the values - self.final_values[channel_num] = voltage + # Create and launch thread + # self.thread_stop_event.clear() + self.thread = threading.Thread(target=self._playback_thread, args=(events,)) + self.thread.start() rich_print(f"---------- End transition to Buffered: ----------", color=BLUE) return @@ -120,6 +117,10 @@ def transition_to_manual(self): """transitions the device from buffered to manual mode to read/save measurements from hardware to the shot h5 file as results. Runs at the end of the shot.""" + #Stop the thread + rich_print(f"---------- Begin transition to Manual: ----------", color=BLUE) + self.thread.join() + rich_print(f"---------- End transition to Manual: ----------", color=BLUE) return True def abort_transition_to_buffered(self): @@ -236,6 +237,24 @@ def _channel_name_to_ao(channel_name: str) -> str: except ValueError: raise ValueError(f"Impossible to convert from '{channel_name}'") + def _playback_thread(self, events): + for t, voltages in events: + if self.verbose: + # print(f"stop event flag: {self.thread_stop_event.is_set()}") + print(f"time: {t} \t voltage: {voltages} \n") + #if self.thread_stop_event.is_set(): + #break + + time.sleep(t) + + for ch_name, voltage in voltages.items(): + ch_num = self._get_channel_num(ch_name) + self.voltage_source.set_voltage(ch_num, voltage) + self.final_values[ch_num] = voltage + if self.verbose: + print(f"[{t:.3f}s] --> Set {ch_num} (#{ch_num}) = {voltage}") + + print(f"[Thread] finished all events !") # --------------------contants diff --git a/labscript_devices/BS_341A/BS_341A.md b/labscript_devices/BS_341A/BS_341A.md index 2bb8d8b5..cb9ac890 100644 --- a/labscript_devices/BS_341A/BS_341A.md +++ b/labscript_devices/BS_341A/BS_341A.md @@ -3,14 +3,16 @@ [Manual](https://www.manualslib.com/manual/1288197/Stahl-Electronics-Bs-Series.html?page=28#manual) Interface: USB -- standard setting: 9600 baud, no handshake -- fast-mode settings: 115200 baud -Output: BNC (voltage analog output) +- standard setting: 9600 baud --- ## Remote commands IDN | Identify + DDDDD CHXX Y.YYYYY | Set voltage + DDDDD TEMP | Read Temperature + DDDDD LOCK | Check lock status of all channels + DDDDD DIS [message] | Send string to LCD-display diff --git a/labscript_devices/BS_341A/emulateSerPort.py b/labscript_devices/BS_341A/emulateSerPort.py index fc323c65..5d0c7ea3 100644 --- a/labscript_devices/BS_341A/emulateSerPort.py +++ b/labscript_devices/BS_341A/emulateSerPort.py @@ -1,14 +1,12 @@ """ -Simulate the serial port for the BS 34-1A, as I don't have access to the real device. +Emulate the serial port for the BS 34-1A. You will create a virtual serial port using this script. This script will act as if it’s the BS 34-1A device. When you run the script, it will open a serial port (for example, /dev/pts/1) and allow other programs (such as your BLACS worker) to communicate with it. The virtual serial port should stay open while the simulation is running, so other code that expects to interact with the serial device can do so just as if the actual device were connected. -Run following command - cd labscript-suite/labscript-devices/labscript_devices +Run following command in the corresponding folder. python3 -m BS_341A.emulateSerPort - """ import os, pty diff --git a/labscript_devices/BS_341A/first_connection.py b/labscript_devices/BS_341A/first_connection.py index f5ee9629..23a7d6c2 100644 --- a/labscript_devices/BS_341A/first_connection.py +++ b/labscript_devices/BS_341A/first_connection.py @@ -2,9 +2,7 @@ import time import serial.tools.list_ports -# dmesg | grep tty -# ls /dev/tty* -# lsusb + def check_ports(): ports = serial.tools.list_ports.comports() diff --git a/labscript_devices/BS_341A/labscript_devices.py b/labscript_devices/BS_341A/labscript_devices.py index 77cbd347..e06f2091 100644 --- a/labscript_devices/BS_341A/labscript_devices.py +++ b/labscript_devices/BS_341A/labscript_devices.py @@ -10,7 +10,7 @@ class BS_341A(IntermediateDevice): # no pseudoclock IntermediateDevice --> Devic description = 'BS_341A' @set_passed_properties({"connection_table_properties": ["port", "baud_rate", "num_AO"]}) - def __init__(self, name, port='', baud_rate=115200, parent_device=None, connection=None, num_AO=0, **kwargs): + def __init__(self, name, port='', baud_rate=9600, parent_device=None, connection=None, num_AO=0, **kwargs): IntermediateDevice.__init__(self, name, parent_device, **kwargs) # self.start_commands = [] self.BLACS_connection = '%s,%s' % (port, str(baud_rate)) diff --git a/labscript_devices/BS_341A/test_bs341A_queries.py b/labscript_devices/BS_341A/test_bs341A_queries.py deleted file mode 100644 index 07527176..00000000 --- a/labscript_devices/BS_341A/test_bs341A_queries.py +++ /dev/null @@ -1,90 +0,0 @@ -import os -import pty -import time -import threading -import unittest -import serial - -# Simulated serial device logic (used for testing) -def test_serial_emulator(master_fd): - while True: - command = read_command(master_fd).decode().strip() - if command: - print(f"[EMULATOR] Received command: {command}") - if command == "IDN": - response = "HV341 14 4 b\r" - elif command.startswith("HV341 U"): - response = "+12,345 V\r" - elif command.startswith("HV341 I"): - response = "-00,123 mA\r" - elif command.startswith("HV341 Q"): - response = "+12,345 V -00,123 mA\r" - elif command.startswith("HV341 TEMP"): - response = "TEMP 45.3°C\r" - else: - response = "err\r" - os.write(master_fd, response.encode()) - time.sleep(0.1) - -def read_command(master_fd): - return b"".join(iter(lambda: os.read(master_fd, 1), b"\r")) - -class TestBS110DeviceQueries(unittest.TestCase): - @classmethod - def setUpClass(cls): - master_fd, slave_fd = pty.openpty() - cls.slave_name = os.ttyname(slave_fd) - cls.master_fd = master_fd - - cls.emulator_thread = threading.Thread(target=test_serial_emulator, args=(master_fd,), daemon=True) - cls.emulator_thread.start() - - cls.serial_conn = serial.Serial(cls.slave_name, baudrate=9600, timeout=1) - - class DummyWorker: - device_serial = "HV341" - port = cls.slave_name - connection = cls.serial_conn - device_voltage_range = 60.0 - def _scale_to_normalized(self, val, rng): return val / rng - - cls.worker = DummyWorker() - - def test_identify_query(self): - self.worker.connection.write(b"IDN\r") - response = self.worker.connection.readline().decode().strip() - self.assertEqual(response, "HV341 14 4 b") - - def test_voltage_query(self): - self.worker.connection.write(b"HV341 U01\r") - response = self.worker.connection.readline().decode().strip() - self.assertTrue(response.endswith("V")) - voltage = float(response[:-1].replace(",", ".").strip()) - self.assertAlmostEqual(voltage, 12.345) - - def test_current_query(self): - self.worker.connection.write(b"HV341 I01\r") - response = self.worker.connection.readline().decode().strip() - self.assertTrue(response.endswith("mA")) - current = float(response[:-2].replace(",", ".").strip()) - self.assertAlmostEqual(current, -0.123) - - def test_vol_curr_query(self): - self.worker.connection.write(b"HV341 Q01\r") - response = self.worker.connection.readline().decode().strip() - self.assertTrue(response.endswith("mA") and "V" in response) - parts = response.split("V") - voltage = float(parts[0].replace(",", ".").strip()) - current = float(parts[1].replace("mA", "").replace(",", ".").strip()) - self.assertAlmostEqual(voltage, 12.345) - self.assertAlmostEqual(current, -0.123) - - def test_temperature_query(self): - self.worker.connection.write(b"HV341 TEMP\r") - response = self.worker.connection.readline().decode().strip() - self.assertTrue(response.endswith("°C")) - temperature = float(response.split()[1].replace("°C", "")) - self.assertAlmostEqual(temperature, 45.3) - -if __name__ == "__main__": - unittest.main() diff --git a/labscript_devices/BS_341A/voltage_source.py b/labscript_devices/BS_341A/voltage_source.py index 0991bf23..4e6a59b4 100644 --- a/labscript_devices/BS_341A/voltage_source.py +++ b/labscript_devices/BS_341A/voltage_source.py @@ -43,8 +43,8 @@ def identify_query(self): f"Device identification failed.\n" f"Raw identity: {raw_response!r}\n" f"Parsed identity: {identity!r}\n" - f"Expected format: ['BSXXX', 'RRR', 'CC', 'b']\n" - f"Device: BS-1-10 at port {self.port!r}\n" + f"Expected format: ['HVXXX', 'RRR', 'CC', 'b']\n" + f"Device: at port {self.port!r}\n" ) def set_voltage(self, channel_num, value): @@ -117,7 +117,7 @@ def voltage_query(self, channel_num): Raises: LabscriptError: If the response format is invalid or parsing fails. """ - + channel_num = f"{int(channel_num) + 1}" channel = f"{int(channel_num):02d}" # 1 -> '01' send_str = f"{self.device_serial} U{channel}\r" # 'DDDDD UXX' self.connection.write(send_str.encode()) @@ -127,7 +127,7 @@ def voltage_query(self, channel_num): if response.endswith("V"): try: numeric_part = response[:-1].strip() # remove 'V' and whitespace - numeric_part = numeric_part.replace(',', '.') # convert to Python-style float + numeric_part = numeric_part.replace(',', '.') voltage = float(numeric_part) return voltage except ValueError: @@ -149,9 +149,9 @@ def current_query(self, channel_num): Raises: LabscriptError: If the response format is invalid or parsing fails. """ - + channel_num = f"{int(channel_num) + 1}" channel = f"{int(channel_num):02d}" # 1 -> '01' - send_str = f"{self.device_serial} I{channel}\r" # 'DDDDD IXX' #TODO: is it I or l or 1? + send_str = f"{self.device_serial} I{channel}\r" self.connection.write(send_str.encode()) response = self.connection.readline().decode().strip() # '+/-yy,yyy mA' @@ -182,6 +182,7 @@ def vol_curr_query(self, channel_num): Raises: LabscriptError: If the response format is invalid or parsing fails. """ + channel_num = f"{int(channel_num) + 1}" channel = f"{int(channel_num):02d}" # 1 -> '01' send_str = f"{self.device_serial} Q{channel}\r" # 'DDDDD QXX' self.connection.write(send_str.encode()) From f8c325b1afcd990cefa615f2c955b47961c71721 Mon Sep 17 00:00:00 2001 From: OljaKhor <149250983+OljaKhor@users.noreply.github.com> Date: Fri, 30 May 2025 12:19:10 +0200 Subject: [PATCH 04/13] add defect (different channels voltage range) workaround --- labscript_devices/BS_341A/BLACS_tabs.py | 23 ++++-- labscript_devices/BS_341A/BLACS_workers.py | 6 +- labscript_devices/BS_341A/BS_341A.md | 40 +++++++++++ labscript_devices/BS_341A/emulateSerPort.py | 47 ------------- .../BS_341A/labscript_devices.py | 2 +- labscript_devices/BS_341A/testing/__init__.py | 0 .../BS_341A/testing/emulateSerPort.py | 70 +++++++++++++++++++ .../BS_341A/{ => testing}/first_connection.py | 0 labscript_devices/BS_341A/voltage_source.py | 11 ++- 9 files changed, 135 insertions(+), 64 deletions(-) delete mode 100644 labscript_devices/BS_341A/emulateSerPort.py create mode 100644 labscript_devices/BS_341A/testing/__init__.py create mode 100644 labscript_devices/BS_341A/testing/emulateSerPort.py rename labscript_devices/BS_341A/{ => testing}/first_connection.py (100%) diff --git a/labscript_devices/BS_341A/BLACS_tabs.py b/labscript_devices/BS_341A/BLACS_tabs.py index d1cd97cc..d80b19d4 100644 --- a/labscript_devices/BS_341A/BLACS_tabs.py +++ b/labscript_devices/BS_341A/BLACS_tabs.py @@ -21,13 +21,22 @@ def initialise_GUI(self): # Create AO Output objects ao_prop = {} for i in range(self.num_AO): - ao_prop['channel %d' % i] = { - 'base_unit': self.base_units, - 'min': self.base_min, - 'max': self.base_max, - 'step': self.base_step, - 'decimals': self.base_decimals, - } + if i == 0: + ao_prop['channel %d' % i+1] = { + 'base_unit': self.base_units, + 'min': self.base_min, + 'max': self.base_max, + 'step': self.base_step, + 'decimals': self.base_decimals, + } + else: + ao_prop['channel %d' % i+1] = { + 'base_unit': self.base_units, + 'min': self.base_min - 10, #workaround defect + 'max': self.base_max + 10, + 'step': self.base_step, + 'decimals': self.base_decimals, + } # Create the output objects self.create_analog_outputs(ao_prop) diff --git a/labscript_devices/BS_341A/BLACS_workers.py b/labscript_devices/BS_341A/BLACS_workers.py index 209eb733..b8b047f7 100644 --- a/labscript_devices/BS_341A/BLACS_workers.py +++ b/labscript_devices/BS_341A/BLACS_workers.py @@ -93,7 +93,7 @@ def transition_to_buffered(self, device_name, h5_file, initial_values, fresh): with h5py.File(h5_file, 'r') as hdf5_file: group = hdf5_file['devices'][device_name] - AO_data = group['AO'][:] + AO_data = group['AO_buffered'][:] # self.device_prop = properties.get(hdf5_file, device_name, 'device_properties') # print("======== Device Properties : ", self.device_prop, "=========") @@ -133,7 +133,7 @@ def _program_manual(self, front_panel_values): print("\nProgramming the device with the following values:") logger.info("Programming the device from manual with the following values:") - for channel_num in range(int(self.num_AO)): + for channel_num in range(1, int(self.num_AO) + 1): channel_name = f'channel {channel_num}' voltage = front_panel_values.get(channel_name, 0.0) if self.verbose is True: @@ -182,7 +182,7 @@ def _append_front_panel_values_to_manual(self, front_panel_values, current_time) Parameters ---------- front_panel_values : dict - Dictionary mapping channel names (e.g., 'channel 0') to voltage values (float). + Dictionary mapping channel names (e.g., 'channel 1') to voltage values (float). current_time : str The timestamp (formatted as a string) when the values were recorded diff --git a/labscript_devices/BS_341A/BS_341A.md b/labscript_devices/BS_341A/BS_341A.md index cb9ac890..d1fb3713 100644 --- a/labscript_devices/BS_341A/BS_341A.md +++ b/labscript_devices/BS_341A/BS_341A.md @@ -16,3 +16,43 @@ DDDDD TEMP | Read Temperature DDDDD LOCK | Check lock status of all channels DDDDD DIS [message] | Send string to LCD-display + +--- + +## BS 34-1A Emulator +This [emulator](testing/emulateSerPort.py) +simulates the behavior of the BS 34-1A multichannel voltage source. +It allows for testing with BLACS. When started, the emulator creates +a **virtual serial port** that behaves like a real BS 34-1A device. +Programs can connect to this port and communicate with it as if +it were the real device. + +To launch the emulator: + +```bash +python3 -m testing/BS_341A.emulateSerPort +``` +You’ll see output like: For BS 34-1A use: /dev/pts/5 + +Use that port (e.g., /dev/pts/5) when connecting in `connection_table.py`. + + +## ⚠️ Different Voltage Range on different channels (Hardware Defect) + +The real BS 34-1A we own has **non-uniform voltage ranges**: + +- **Channel 1** supports ±24 V +- **Channels 2–8** support ~ ±34 V + +Labscript normalizes voltage values using a fixed `voltage_range` +parameter for the entire device. So, we add a _**dirty**_ workaround. + +Set `voltage_range = 34` (maximum), and ensure your code or GUI logic: +- avoids setting values outside of ±24 V for **Channel 1** +- optionally warns or clips values to prevent hardware violation + +This workaround ensures all normalized values remain safe for the physical hardware, at the cost of limited precision on Channel 1. + +Future improvements might include: +- per-channel range support +- automatic validation and clipping \ No newline at end of file diff --git a/labscript_devices/BS_341A/emulateSerPort.py b/labscript_devices/BS_341A/emulateSerPort.py deleted file mode 100644 index 5d0c7ea3..00000000 --- a/labscript_devices/BS_341A/emulateSerPort.py +++ /dev/null @@ -1,47 +0,0 @@ -""" -Emulate the serial port for the BS 34-1A. - -You will create a virtual serial port using this script. This script will act as if it’s the BS 34-1A device. When you run the script, it will open a serial port (for example, /dev/pts/1) and allow other programs (such as your BLACS worker) to communicate with it. - -The virtual serial port should stay open while the simulation is running, so other code that expects to interact with the serial device can do so just as if the actual device were connected. - -Run following command in the corresponding folder. - python3 -m BS_341A.emulateSerPort -""" - -import os, pty -import time - -def test_serial(): - """ - Initialise the serial port. - prints the serial port to use. - """ - master, slave = pty.openpty() - port_name = os.ttyname(slave) - print(f"For BS 34-1A use: {port_name}") - - while True: - device_identity = "HV341 14 4 b\r" - command = read_command(master).decode().strip() - if command: - print("command {}".format(command)) - if command == "IDN": - response = device_identity.encode() - os.write(master, response) - elif command.startswith("HV341 CH"): - device, channel, voltage = command.split()[:3] - response = f"{channel} {voltage}\r" - os.write(master, response.encode()) - else: - response = f"err\r" - os.write(master, response.encode()) - - time.sleep(0.1) - -def read_command(master): - """ Reads the command until the '\r' character is encountered. """ - return b"".join(iter(lambda: os.read(master, 1), b"\r")) - -if __name__ == "__main__": - test_serial() \ No newline at end of file diff --git a/labscript_devices/BS_341A/labscript_devices.py b/labscript_devices/BS_341A/labscript_devices.py index e06f2091..5fd07505 100644 --- a/labscript_devices/BS_341A/labscript_devices.py +++ b/labscript_devices/BS_341A/labscript_devices.py @@ -39,7 +39,7 @@ def generate_code(self, hdf5_file): logger.info(f"AO table for BS-34-1A is: {AO_table}") AO_manual_table = self._make_analog_out_table_from_manual(analogs) - group.create_dataset("AO", data=AO_table, compression=config.compression) + group.create_dataset("AO_buffered", data=AO_table, compression=config.compression) group.create_dataset("AO_manual", shape=AO_manual_table.shape, maxshape=(None,), dtype=AO_manual_table.dtype, compression=config.compression, chunks=True) diff --git a/labscript_devices/BS_341A/testing/__init__.py b/labscript_devices/BS_341A/testing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/labscript_devices/BS_341A/testing/emulateSerPort.py b/labscript_devices/BS_341A/testing/emulateSerPort.py new file mode 100644 index 00000000..b7a2c334 --- /dev/null +++ b/labscript_devices/BS_341A/testing/emulateSerPort.py @@ -0,0 +1,70 @@ +""" +Emulate the serial port for the BS 34-1A. + +You will create a virtual serial port using this script. This script will act as if it’s the BS 34-1A device. When you run the script, it will open a serial port (for example, /dev/pts/1) and allow other programs (such as your BLACS worker) to communicate with it. + +The virtual serial port should stay open while the simulation is running, so other code that expects to interact with the serial device can do so just as if the actual device were connected. + +Run following command in the corresponding folder. + python3 -m testing/BS_341A.emulateSerPort +""" + +import os, pty, threading, time +import sys + +class BS_341AEmulator: + def __init__(self, verbose=False): + self.verbose = verbose + self.master, self.slave = pty.openpty() + self.running = False + self.port_name = os.ttyname(self.slave) + self.thread = threading.Thread(target=self._run) + + def start(self): + self.running = True + self.thread.start() + if self.verbose: + print("Starting BS 34-1A Emulator on virtual port: " + self.port_name) + + def stop(self): + self.running = False + self.thread.join() + if self.verbose: + print("Stopping BS 34-1A Emulator.") + + def _run(self): + while self.running: + try: + command = self._read_command().decode().strip() + if self.verbose: + print(f"Received: {command}") + if command == "IDN": + self._respond("HV341 34 8 b\r") + elif command.startswith("HV341 CH"): + _, channel, voltage = command.split()[:3] + self._respond(f"{channel} {voltage}\r") + else: + self._respond("err\r") + except Exception as e: + self._respond("err\r") + time.sleep(0.05) + + def _read_command(self): + """ Reads the command until the '\r' character is encountered. """ + return b"".join(iter(lambda: os.read(self.master, 1), b"\r")) + + def _respond(self, message): + os.write(self.master, message.encode()) + if self.verbose: + print(f"Responded: {message.strip()}") + + +if __name__ == "__main__": + emulator = BS_341AEmulator(verbose=True) + emulator.start() + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + emulator.stop() + sys.exit(0) \ No newline at end of file diff --git a/labscript_devices/BS_341A/first_connection.py b/labscript_devices/BS_341A/testing/first_connection.py similarity index 100% rename from labscript_devices/BS_341A/first_connection.py rename to labscript_devices/BS_341A/testing/first_connection.py diff --git a/labscript_devices/BS_341A/voltage_source.py b/labscript_devices/BS_341A/voltage_source.py index 4e6a59b4..c103eb20 100644 --- a/labscript_devices/BS_341A/voltage_source.py +++ b/labscript_devices/BS_341A/voltage_source.py @@ -56,15 +56,17 @@ def set_voltage(self, channel_num, value): LabscriptError: If the response from BS-1-10 is incorrect. """ try: - channel_num = f"{int(channel_num) + 1}" channel = f"CH{int(channel_num):02d}" - scaled_voltage = self._scale_to_normalized(float(value), float(self.device_voltage_range)) + if channel_num == 1: + scaled_voltage = self._scale_to_normalized(float(value), float(self.device_voltage_range)) + else: #workaround defect + scaled_voltage = self._scale_to_normalized(float(value), float(self.device_voltage_range + 10)) send_str = f"{self.device_serial} {channel} {scaled_voltage:.5f}\r" self.connection.write(send_str.encode()) response = self.connection.readline().decode().strip() #'CHXX Y.YYYYY' - logger.debug(f"Sent to BS-34/BS-1-8: {send_str!r} | Received: {response!r}") + logger.debug(f"Sent to BS-34: {send_str!r} | Received: {response!r}") expected_response = f"{channel} {scaled_voltage:.5f}" if response != expected_response: @@ -117,7 +119,6 @@ def voltage_query(self, channel_num): Raises: LabscriptError: If the response format is invalid or parsing fails. """ - channel_num = f"{int(channel_num) + 1}" channel = f"{int(channel_num):02d}" # 1 -> '01' send_str = f"{self.device_serial} U{channel}\r" # 'DDDDD UXX' self.connection.write(send_str.encode()) @@ -149,7 +150,6 @@ def current_query(self, channel_num): Raises: LabscriptError: If the response format is invalid or parsing fails. """ - channel_num = f"{int(channel_num) + 1}" channel = f"{int(channel_num):02d}" # 1 -> '01' send_str = f"{self.device_serial} I{channel}\r" self.connection.write(send_str.encode()) @@ -182,7 +182,6 @@ def vol_curr_query(self, channel_num): Raises: LabscriptError: If the response format is invalid or parsing fails. """ - channel_num = f"{int(channel_num) + 1}" channel = f"{int(channel_num):02d}" # 1 -> '01' send_str = f"{self.device_serial} Q{channel}\r" # 'DDDDD QXX' self.connection.write(send_str.encode()) From 2457ecf7c2acf389e89fa9082caa8a807ff6ccdc Mon Sep 17 00:00:00 2001 From: David Meyer Date: Fri, 11 Apr 2025 13:44:59 -0400 Subject: [PATCH 05/13] Update workflow to latest and greatest --- .github/workflows/release-vars.sh | 47 +++ .github/workflows/release.yml | 504 +++++++++++++++++++++--------- 2 files changed, 395 insertions(+), 156 deletions(-) create mode 100644 .github/workflows/release-vars.sh diff --git a/.github/workflows/release-vars.sh b/.github/workflows/release-vars.sh new file mode 100644 index 00000000..becdea74 --- /dev/null +++ b/.github/workflows/release-vars.sh @@ -0,0 +1,47 @@ +# This repository. PyPI and Anaconda test and release package uploads are only done if +# the repository the workflow is running in matches this (i.e. is not a fork). Optional, +# if not set, package uploads are skipped. +export RELEASE_REPO="labscript-suite/labscript-devices" + +# Username with which to upload conda packages. If not given, anaconda uploads are +# skipped. +export ANACONDA_USER="labscript-suite" + +# Whether (true or false) to upload releases to PyPI, non-releases to Test PyPI, +# releases to Anaconda, non-releases to Anaconda test label. Only used if the repository +# the workflow is running in matches RELEASE_REPO, otherwise uploads are skipped. +# Anaconda uploads require ANACONDA_USER be specified and ANACONDA_API_TOKEN secret be +# set. Optional, all default to true. +export PYPI_UPLOAD="" +export TESTPYPI_UPLOAD="" +export ANACONDA_UPLOAD="" +export TEST_ANACONDA_UPLOAD="" + +# Which Python version to use for pure wheel builds, sdists, and as the host Python for +# cibuildwheel. Optional, defaults to the second-most recent minor Python version. +export DEFAULT_PYTHON="" + +# Comma-separated list of Python versions to build conda packages for. Only used if +# HAS_ENV_MARKERS=true or PURE=false, otherwise a noarch conda package is built instead. +# Optional, defaults to all non-end-of-life stable Python minor versions. +export CONDA_PYTHONS="" + +# Environment variable set in the envionment that `cibuildwheel` runs in instructing it +# which Pythons to build for, as a space-separated list of specifiers in the format +# specified by `cibuildwheel`. Only used if PURE=false. Optional, defaults to all +# non-end-of-life stable CPython versions. +export CIBW_BUILD="" + +# Name of Python package. Optional, defaults to name from the package metadata +export PKGNAME="" + +# Version of Python package. Optional, defaults to version from the package metadata +export PKGVER="" + +# Whether the Python package is pure (true) or impure (false). Optional, defaults to +# false if the setuptools package has extension modules or libraries, otherwise true. +export PURE="" + +# Whether (true or false) the Python package has dependencies that vary by platform or +# Python version. Optional, Defaults to presence of env markers in package metadata. +export HAS_ENV_MARKERS="" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a418c38..720d509c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,251 +5,443 @@ on: branches: - master - maintenance/* - create: tags: - 'v[0-9]+.[0-9]+.[0-9]+*' env: - PACKAGE_NAME: labscript-devices - ANACONDA_USER: labscript-suite + OS_LIST_UBUNTU: '["ubuntu-latest"]' + OS_LIST_ALL: '["ubuntu-latest", "windows-latest", "macos-latest", "macos-13"]' - # Configuration for a package with compiled extensions: - # PURE: false - # NOARCH: false - - # Configuration for a package with no extensions, but with dependencies that differ by - # platform or Python version: - # PURE: true - # NOARCH: false - - # Configuration for a package with no extensions and the same dependencies on all - # platforms and Python versions. For this configuration you should comment out all but - # the first entry in the job matrix of the build job since multiple platforms are not - # needed. - PURE: true - NOARCH: true jobs: - build: - name: Build - runs-on: ${{ matrix.os }} - strategy: - matrix: - include: - - { os: ubuntu-latest, python: '3.11', arch: x64, conda: true} - # - { os: ubuntu-latest, python: '3.10', arch: x64, conda: true } - # - { os: ubuntu-latest, python: '3.9', arch: x64, conda: true } - # - { os: ubuntu-latest, python: '3.8', arch: x64, conda: true } - # - { os: ubuntu-latest, python: '3.7', arch: x64, conda: true } - - # - { os: macos-11, python: '3.11', arch: x64, conda: true } - # - { os: macos-11, python: '3.10', arch: x64, conda: true } - # - { os: macos-11, python: '3.9', arch: x64, conda: true } - # - { os: macos-11, python: '3.8', arch: x64, conda: true } - # - { os: macos-11, python: '3.7', arch: x64, conda: true } - - # - { os: windows-latest, python: '3.11', arch: x64, conda: true } - # - { os: windows-latest, python: '3.10', arch: x64, conda: true } - # - { os: windows-latest, python: '3.9', arch: x64, conda: true } - # - { os: windows-latest, python: '3.8', arch: x64, conda: true } - # - { os: windows-latest, python: '3.7', arch: x64, conda: true } - - # - { os: windows-latest, python: '3.11', arch: x86, conda: false } # conda not yet available - # - { os: windows-latest, python: '3.10', arch: x86, conda: true } - # - { os: windows-latest, python: '3.9', arch: x86, conda: true } - # - { os: windows-latest, python: '3.8', arch: x86, conda: true } - # - { os: windows-latest, python: '3.7', arch: x86, conda: true } - - if: github.repository == 'labscript-suite/labscript-devices' && (github.event_name != 'create' || github.event.ref_type != 'branch') + configure: + name: Configure workflow run + runs-on: ubuntu-latest + outputs: + DEFAULT_PYTHON: ${{ steps.config.outputs.DEFAULT_PYTHON }} + CIBW_BUILD: ${{ steps.config.outputs.CIBW_BUILD }} + PKGNAME: ${{ steps.config.outputs.PKGNAME }} + PKGVER: ${{ steps.config.outputs.PKGVER }} + PURE: ${{ steps.config.outputs.PURE }} + ANACONDA_USER: ${{ steps.config.outputs.ANACONDA_USER }} + CONDA_BUILD_ARGS: ${{ steps.config.outputs.CONDA_BUILD_ARGS }} + BUILD_OS_LIST: ${{ steps.config.outputs.BUILD_OS_LIST }} + RELEASE: ${{ steps.config.outputs.RELEASE }} + TESTPYPI_UPLOAD_THIS_RUN: ${{ steps.config.outputs.TESTPYPI_UPLOAD_THIS_RUN }} + PYPI_UPLOAD_THIS_RUN: ${{ steps.config.outputs.PYPI_UPLOAD_THIS_RUN }} + TEST_ANACONDA_UPLOAD_THIS_RUN: ${{ steps.config.outputs.TEST_ANACONDA_UPLOAD_THIS_RUN }} + ANACONDA_UPLOAD_THIS_RUN: ${{ steps.config.outputs.ANACONDA_UPLOAD_THIS_RUN }} + steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Ignore Tags - if: github.event.ref_type != 'tag' + - name: Ignore Tags for non-tag pushes + if: "!startsWith(github.ref, 'refs/tags/')" run: git tag -d $(git tag --points-at HEAD) - name: Install Python uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python }} - architecture: ${{ matrix.arch }} + python-version: '3.x' - - name: Source Distribution - if: strategy.job-index == 0 + - name: Configure workflow + id: config run: | - python -m pip install --upgrade pip setuptools wheel build - python -m build -s . + pip install ci-helper - - name: Wheel Distribution - # Impure Linux wheels are built in the manylinux job. - if: (env.PURE == 'true' && strategy.job-index == 0) || (env.PURE == 'false' && runner.os != 'Linux') - run: | - python -m pip install --upgrade pip setuptools wheel build - python -m build -w . + # Load repo-specific variables and overrides: + VARS_FILE=".github/workflows/release-vars.sh" + if [ -f "${VARS_FILE}" ]; then + source "${VARS_FILE}" + fi - - name: Upload Artifact - if: strategy.job-index == 0 || (env.PURE == 'false' && runner.os != 'Linux') - uses: actions/upload-artifact@v4 - with: - name: dist - path: ./dist + # Python version used to build sdists, pure wheels, and as host Python for + # `cibuildwheel`: + if [ -z "${DEFAULT_PYTHON}" ]; then + # Default to second-most recent supported Python version: + DEFAULT_PYTHON=$(ci-helper defaultpython) + fi - - name: Set Variables for Conda Build - if: matrix.conda - shell: bash - run: | - if [ $NOARCH == true ]; then - CONDA_BUILD_ARGS="--noarch" + # Versions of Python to build conda packages for: + if [ -z "${CONDA_PYTHONS}" ]; then + # Default to all supported Python versions: + CONDA_PYTHONS=$(ci-helper pythons) + fi + + # Env var for `cibuildwheel` specifying target Python versions: + if [ -z "${CIBW_BUILD}" ]; then + # default to all supported CPython versions: + CIBW_BUILD=$(ci-helper pythons --cibw) + fi + + # Package name and version + if [ -z "${PKGNAME}" ]; then + # Default to package name from project metadata: + PKGNAME=$(ci-helper distinfo name .) + fi + if [ -z "${PKGVER}" ]; then + # Default to package version from project metadata: + PKGVER=$(ci-helper distinfo version .) + fi + + # Whether the package is pure python + if [ -z "${PURE}" ]; then + # Default to whether the setuptools package declares no modules/libraries: + PURE=$(ci-helper distinfo is_pure .) + fi + + # Whether the package requirements depend on platform or Python version: + if [ -z "${HAS_ENV_MARKERS}" ]; then + # Default to the presence of env markers in package metadata: + HAS_ENV_MARKERS=$(ci-helper distinfo has_env_markers .) + fi + + # List of OSs we need to run the build job on and arguments to + # `setuptools-conda build`: + if [[ "${PURE}" == false || "${HAS_ENV_MARKERS}" == true ]]; then + BUILD_OS_LIST="${OS_LIST_ALL}" + CONDA_BUILD_ARGS="--pythons=${CONDA_PYTHONS}" else - CONDA_BUILD_ARGS="" + BUILD_OS_LIST="${OS_LIST_UBUNTU}" + CONDA_BUILD_ARGS="--noarch" fi - echo "CONDA_BUILD_ARGS=$CONDA_BUILD_ARGS" >> $GITHUB_ENV - - name: Install Miniconda - if: matrix.conda - uses: conda-incubator/setup-miniconda@v3 - with: - auto-update-conda: true - python-version: ${{ matrix.python }} - architecture: ${{ matrix.arch }} - miniconda-version: "latest" + # Release if a tag was pushed: + if [ "${{ contains(github.ref, '/tags') }}" == true ]; then + RELEASE=true + else + RELEASE=false + fi - - name: Workaround conda-build incompatibility with xcode 12+ - if: runner.os == 'macOS' - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: 11.7 + # What types of package uploads are enabled: + if [ -z "${PYPI_UPLOAD}" ]; then + PYPI_UPLOAD=true + else + PYPI_UPLOAD=false + fi + if [ -z "${TESTPYPI_UPLOAD}" ]; then + TESTPYPI_UPLOAD=true + else + TESTPYPI_UPLOAD=false + fi + if [ -z "${ANACONDA_UPLOAD}" ]; then + ANACONDA_UPLOAD=true + else + ANACONDA_UPLOAD=false + fi + if [ -z "${TEST_ANACONDA_UPLOAD}" ]; then + TEST_ANACONDA_UPLOAD=true + else + TEST_ANACONDA_UPLOAD=false + fi - - name: Conda package (Unix) - if: (matrix.conda && runner.os != 'Windows') - shell: bash -l {0} - run: | - conda install -c labscript-suite setuptools-conda - setuptools-conda build $CONDA_BUILD_ARGS . + if [ "${{ github.repository }}" != "${RELEASE_REPO}" ]; then + echo "Workflow repo doesn't match ${RELEASE_REPO}, disabling package uploads" + PYPI_UPLOAD=false + TESTPYPI_UPLOAD=false + ANACONDA_UPLOAD=false + TEST_ANACONDA_UPLOAD=false + fi - - name: Conda Package (Windows) - if: (matrix.conda && runner.os == 'Windows') - shell: cmd /C CALL {0} - run: | - conda install -c labscript-suite setuptools-conda && ^ - setuptools-conda build %CONDA_BUILD_ARGS% --croot ${{ runner.temp }}\cb . + # If Anaconda uploads enabled, check necessary username and token are + # available: + if [[ "${ANACONDA_UPLOAD}" == true || "${TEST_ANACONDA_UPLOAD}" == true ]]; then + if [ -z "${{ secrets.ANACONDA_API_TOKEN }}" ]; then + echo "Anaconda uploads enabled but ANACONDA_API_TOKEN secret not set" + exit 1 + fi + if [ -z "${ANACONDA_USER}" ]; then + echo "Anaconda uploads enabled but ANACONDA_USER not set" + exit 1 + fi + fi - - name: Upload Artifact - if: matrix.conda - uses: actions/upload-artifact@v4 - with: - name: conda_packages - path: ./conda_packages + # If enabled, upload releases to PyPI and Anaconda: + if [[ "${RELEASE}" == true && "${PYPI_UPLOAD}" == true ]]; then + PYPI_UPLOAD_THIS_RUN=true + else + PYPI_UPLOAD_THIS_RUN=false + fi + if [[ "${RELEASE}" == true && "${ANACONDA_UPLOAD}" == true ]]; then + ANACONDA_UPLOAD_THIS_RUN=true + else + ANACONDA_UPLOAD_THIS_RUN=false + fi + # If enabled, upload non-releases to Test PyPI and Anaconda test label: + if [[ "${RELEASE}" == false && "${TESTPYPI_UPLOAD}" == true ]]; then + TESTPYPI_UPLOAD_THIS_RUN=true + else + TESTPYPI_UPLOAD_THIS_RUN=false + fi + if [[ "${RELEASE}" == false && "${TEST_ANACONDA_UPLOAD}" == true ]]; then + TEST_ANACONDA_UPLOAD_THIS_RUN=true + else + TEST_ANACONDA_UPLOAD_THIS_RUN=false + fi + + echo "DEFAULT_PYTHON=${DEFAULT_PYTHON}" >> "${GITHUB_OUTPUT}" + echo "CIBW_BUILD=${CIBW_BUILD}" >> "${GITHUB_OUTPUT}" + echo "PKGNAME=${PKGNAME}" >> "${GITHUB_OUTPUT}" + echo "PKGVER=${PKGVER}" >> "${GITHUB_OUTPUT}" + echo "PURE=${PURE}" >> "${GITHUB_OUTPUT}" + echo "ANACONDA_USER=${ANACONDA_USER}" >> "${GITHUB_OUTPUT}" + echo "CONDA_BUILD_ARGS=${CONDA_BUILD_ARGS}" >> "${GITHUB_OUTPUT}" + echo "BUILD_OS_LIST=${BUILD_OS_LIST}" >> "${GITHUB_OUTPUT}" + echo "RELEASE=${RELEASE}" >> "${GITHUB_OUTPUT}" + echo "TESTPYPI_UPLOAD_THIS_RUN=${TESTPYPI_UPLOAD_THIS_RUN}" >> "${GITHUB_OUTPUT}" + echo "PYPI_UPLOAD_THIS_RUN=${PYPI_UPLOAD_THIS_RUN}" >> "${GITHUB_OUTPUT}" + echo "TEST_ANACONDA_UPLOAD_THIS_RUN=${TEST_ANACONDA_UPLOAD_THIS_RUN}" >> "${GITHUB_OUTPUT}" + echo "ANACONDA_UPLOAD_THIS_RUN=${ANACONDA_UPLOAD_THIS_RUN}" >> "${GITHUB_OUTPUT}" + + echo + echo "==========================" + echo "Workflow run configuration:" + echo "--------------------------" + cat "${GITHUB_OUTPUT}" + echo "==========================" + echo + + + build: + name: Build + runs-on: ${{ matrix.os }} + needs: configure + strategy: + matrix: + os: ${{ fromJSON(needs.configure.outputs.BUILD_OS_LIST) }} + + env: + DEFAULT_PYTHON: ${{ needs.configure.outputs.DEFAULT_PYTHON }} + CIBW_BUILD: ${{ needs.configure.outputs.CIBW_BUILD }} + PURE: ${{ needs.configure.outputs.PURE }} + CONDA_BUILD_ARGS: ${{ needs.configure.outputs.CONDA_BUILD_ARGS }} - manylinux: - name: Build Manylinux - runs-on: ubuntu-latest - if: github.repository == 'labscript-suite/labscript-devices' && (github.event_name != 'create' || github.event.ref_type != 'branch') steps: - name: Checkout - if: env.PURE == 'false' uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Ignore Tags - if: github.event.ref_type != 'tag' && env.PURE == 'false' + - name: Ignore Tags for non-tag pushes + if: "!startsWith(github.ref, 'refs/tags/')" run: git tag -d $(git tag --points-at HEAD) - - name: Build Manylinux Wheels - if: env.PURE == 'false' - uses: RalfG/python-wheels-manylinux-build@v0.4.2 + - name: Install Python + uses: actions/setup-python@v5 with: - python-versions: 'cp37-cp37m cp38-cp38 cp39-cp39 cp310-cp310 cp311-cp311' - pre-build-command: 'git config --global --add safe.directory "*"' + python-version: ${{ env.DEFAULT_PYTHON }} - - name: Upload Artifact + - name: Install Python tools + run: python -m pip install --upgrade pip setuptools wheel build cibuildwheel + + - name: Source distribution + if: strategy.job-index == 0 + run: python -m build -s . + + - name: Wheel distribution (pure) + if: env.PURE == 'true' && strategy.job-index == 0 + run: python -m build -w . + + - name: Wheel distribution (impure) if: env.PURE == 'false' + run: cibuildwheel --output-dir dist + + - name: Upload artifact + if: env.PURE == 'false' || strategy.job-index == 0 uses: actions/upload-artifact@v4 with: - name: dist - path: dist/*manylinux*.whl + name: dist-${{ matrix.os }} + path: ./dist + if-no-files-found: error - release: - name: Release - runs-on: ubuntu-latest - needs: [build, manylinux] - steps: + - name: Install Miniforge + uses: conda-incubator/setup-miniconda@v3 + with: + miniforge-version: "latest" + auto-update-conda: true + conda-remove-defaults: true + auto-activate-base: true + activate-environment: "" - - name: Download Artifact - uses: actions/download-artifact@v4 + - name: Conda package + shell: bash -l {0} + run: | + if [ "${{ runner.os }}" == Windows ]; then + # Short path to minimise odds of hitting Windows max path length + CONDA_BUILD_ARGS+=" --croot ${{ runner.temp }}\cb" + fi + conda install -c labscript-suite setuptools-conda "conda-build<25" + setuptools-conda build $CONDA_BUILD_ARGS . + + - name: Upload artifact + uses: actions/upload-artifact@v4 with: - name: dist - path: ./dist + name: conda_packages-${{ matrix.os }} + path: ./conda_packages + if-no-files-found: error + + github-release: + name: Publish release (GitHub) + runs-on: ubuntu-latest + needs: [configure, build] + if: ${{ needs.configure.outputs.RELEASE == 'true' }} + permissions: + contents: write + env: + PKGNAME: ${{ needs.configure.outputs.PKGNAME }} + PKGVER: ${{ needs.configure.outputs.PKGVER }} + + steps: - name: Download Artifact uses: actions/download-artifact@v4 with: - name: conda_packages - path: ./conda_packages - - - name: Get Version Number - if: github.event.ref_type == 'tag' - run: | - VERSION="${GITHUB_REF/refs\/tags\/v/}" - echo "VERSION=$VERSION" >> $GITHUB_ENV + pattern: dist* + path: ./dist + merge-multiple: true - - name: Create GitHub Release and Upload Release Asset - if: github.event.ref_type == 'tag' - uses: softprops/action-gh-release@v1 + - name: Create GitHub release and upload release asset + uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.event.ref }} - name: ${{ env.PACKAGE_NAME }} ${{ env.VERSION }} + name: ${{ env.PKGNAME }} ${{ env.PKGVER }} draft: true prerelease: ${{ contains(github.event.ref, 'rc') }} - files: ./dist/${{ env.PACKAGE_NAME }}-${{ env.VERSION }}.tar.gz + files: ./dist/*.tar.gz + + + testpypi-upload: + name: Publish on Test PyPI + runs-on: ubuntu-latest + needs: [configure, build] + if: ${{ needs.configure.outputs.TESTPYPI_UPLOAD_THIS_RUN == 'true' }} + env: + PKGNAME: ${{ needs.configure.outputs.PKGNAME }} + PKGVER: ${{ needs.configure.outputs.PKGVER }} + environment: + name: testpypi + url: https://test.pypi.org/project/${{ env.PKGNAME }}/${{ env.PKGVER }} + permissions: + id-token: write + + steps: + - name: Download Artifact + uses: actions/download-artifact@v4 + with: + pattern: dist* + path: ./dist + merge-multiple: true - name: Publish on TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - user: __token__ - password: ${{ secrets.testpypi }} repository-url: https://test.pypi.org/legacy/ + + pypi-upload: + name: Publish on PyPI + runs-on: ubuntu-latest + needs: [configure, build] + if: ${{ needs.configure.outputs.PYPI_UPLOAD_THIS_RUN == 'true' }} + env: + PKGNAME: ${{ needs.configure.outputs.PKGNAME }} + PKGVER: ${{ needs.configure.outputs.PKGVER }} + environment: + name: pypi + url: https://pypi.org/project/${{ env.PKGNAME }}/${{ env.PKGVER }} + permissions: + id-token: write + + steps: + - name: Download Artifact + uses: actions/download-artifact@v4 + with: + pattern: dist* + path: ./dist + merge-multiple: true + - name: Publish on PyPI - if: github.event.ref_type == 'tag' uses: pypa/gh-action-pypi-publish@release/v1 + + + test-anaconda-upload: + name: Publish on Anaconda (test label) + runs-on: ubuntu-latest + needs: [configure, build] + if: ${{ needs.configure.outputs.TEST_ANACONDA_UPLOAD_THIS_RUN == 'true' }} + + steps: + - name: Download Artifact + uses: actions/download-artifact@v4 with: - user: __token__ - password: ${{ secrets.pypi }} + pattern: conda_packages-* + path: ./conda_packages + merge-multiple: true - - name: Install Miniconda + - name: Install Miniforge uses: conda-incubator/setup-miniconda@v3 with: + miniforge-version: "latest" auto-update-conda: true + conda-remove-defaults: true + auto-activate-base: true + activate-environment: "" - name: Install Anaconda cloud client shell: bash -l {0} run: conda install anaconda-client - name: Publish to Anaconda test label - if: github.event.ref_type != 'tag' shell: bash -l {0} run: | anaconda \ --token ${{ secrets.ANACONDA_API_TOKEN }} \ upload \ - --user $ANACONDA_USER \ + --skip-existing \ + --user ${{ needs.configure.outputs.ANACONDA_USER }} \ --label test \ conda_packages/*/* - - name: Publish to Anaconda main label + + anaconda-upload: + name: Publish on Anaconda + runs-on: ubuntu-latest + needs: [configure, build] + if: ${{ needs.configure.outputs.ANACONDA_UPLOAD_THIS_RUN == 'true' }} + + steps: + - name: Download Artifact + uses: actions/download-artifact@v4 + with: + pattern: conda_packages-* + path: ./conda_packages + merge-multiple: true + + - name: Install Miniforge + uses: conda-incubator/setup-miniconda@v3 + with: + miniforge-version: "latest" + auto-update-conda: true + conda-remove-defaults: true + auto-activate-base: true + activate-environment: "" + + - name: Install Anaconda cloud client + shell: bash -l {0} + run: conda install anaconda-client + + - name: Publish to Anaconda main shell: bash -l {0} - if: github.event.ref_type == 'tag' run: | anaconda \ --token ${{ secrets.ANACONDA_API_TOKEN }} \ upload \ - --user $ANACONDA_USER \ + --skip-existing \ + --user ${{ needs.configure.outputs.ANACONDA_USER }} \ conda_packages/*/* From 549ff6963164c224412ba77d5fcb4f0bca3f6e66 Mon Sep 17 00:00:00 2001 From: David Meyer Date: Fri, 11 Apr 2025 13:45:15 -0400 Subject: [PATCH 06/13] Update readme link, project metadata, and dependencies --- README.md | 2 +- pyproject.toml | 11 ++--------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ff27e41c..85703788 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ### Plugin architecture for controlling experiment hardware -[![Actions Status](https://github.com/labscript-suite/labscript-devices/workflows/Build%20and%20Release/badge.svg?branch=maintenance%2F3.0.x)](https://github.com/labscript-suite/labscript-devices/actions) +[![Actions Status](https://github.com/labscript-suite/labscript-devices/workflows/Build%20and%20Release/badge.svg)](https://github.com/labscript-suite/labscript-devices/actions) [![License](https://img.shields.io/pypi/l/labscript-devices.svg)](https://github.com/labscript-suite/labscript-devices/raw/master/LICENSE.txt) [![Python Version](https://img.shields.io/pypi/pyversions/labscript-devices.svg)](https://python.org) [![PyPI](https://img.shields.io/pypi/v/labscript-devices.svg)](https://pypi.org/project/labscript-devices) diff --git a/pyproject.toml b/pyproject.toml index b827ec37..7f06498c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=64", "wheel", "setuptools_scm>=8"] +requires = ["setuptools>=64", "setuptools_scm>=8"] build-backend = "setuptools.build_meta" [tool.setuptools_scm] @@ -25,18 +25,11 @@ license = {file = 'LICENSE.txt'} classifiers = [ "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", ] -requires-python = ">=3.6" +requires-python = ">=3.8" dependencies = [ "blacs>=3.0.0", "runmanager>=3.0.0", - "importlib_metadata", "labscript>=3.0.0", "labscript_utils>=3.0.0", "numpy>=1.15.1", From 273a105ddf3f108b03ffb1995d8501b44124aa91 Mon Sep 17 00:00:00 2001 From: David Meyer Date: Mon, 21 Apr 2025 12:26:55 -0400 Subject: [PATCH 07/13] Add old blob documentation for the novatech dds9m, now that the blog is gone. --- docs/source/devices/novatechDDS9m.rst | 77 ++++++++++++++++++ docs/source/img/NT_DDS9m_schemeit-project.png | Bin 0 -> 16959 bytes 2 files changed, 77 insertions(+) create mode 100644 docs/source/img/NT_DDS9m_schemeit-project.png diff --git a/docs/source/devices/novatechDDS9m.rst b/docs/source/devices/novatechDDS9m.rst index b0124a47..85e8e400 100644 --- a/docs/source/devices/novatechDDS9m.rst +++ b/docs/source/devices/novatechDDS9m.rst @@ -4,6 +4,83 @@ Novatech DDS 9m Labscript device for control of the Novatech DDS9m synthesizer. With minor modifications, it can also control the Novatech 409B DDS. +.. note:: + The following text is copied from the old labscriptsuite.org blog. + Its information dates from 2014. + +The DDS9m has four outputs, the first two of which can be stepped through a pre-programmed table, +with the remaining two only controllable by software commands (and hence static during buffered runs). +We use a revision 1.3 board, +which supports external timing of the table mode, as detailed in `Appendix A of the Manual `_. + +The clocking of the DDS9m through the table entries is non-trivial, however we have converged on an implementation which reliably updates the output on rising clock edges. +Here we will detail the hardware involved, along with the software commands sent from the BLACS tab, and the resulting behaviour of the device. + +Hardware +~~~~~~~~ + +We have installed each DDS9m board in a box with a power supply, rf switches and rf amplifiers, creating what we refer to as a Supernova. +Each channel of the DDS9m is fed into a switch, with one output port going to the amplifier, +and the other going directly to the front panel for debugging (though this isn’t necessary, we don’t use this feature often). +The direction of the output (amp. vs test) is determined by a toggle switch for each channel. +The on/off state of each switch is then determined by a second toggle switch for each channel, which can switch between on, off and TTL modes. +In TTL mode the state of the switch is determined by the high/low state of a TTL line connected to a BNC port on the front panel. +We use these TTL lines to switch our rf during the experiment, since it saves on lines in the DDS9m’s table, and allows some control of the static channels. + +To step through the table, we use a TTL clocking line, along with a “table enable” TTL line, to drive a tri-state driver chip, +which in turn drives pins 10 and 14 of the DDS9m. +The roles of the pins (for the rev. 1.3 boards and later) when in table mode, with hardware output enabled, are as follows: +falling edges on pin 10 cause the next table entry to be loaded into the buffer, and rising edges on pin 14 cause the values in the buffer to be output. +Since pin 14 is usually an output when I e hardware output has not been enabled, +it should not be directly connected to pin 10, as this interferes with operation during manual mode (and possibly programming of the table?). +For this same reason, you should not hold pin 14 high or low when not in hardware table mode, hence the use of a tri-state buffer. + +We use an M74HC125B1R quad tri-state driver in the following configuration: + +.. image:: /img/NT_DDS9m_schemeit-project.png + +The clock line used to step through the table is sent to two channels of the buffer, which are connected to pins 10 and 14 of the DDS9m. +Our table enable line passes through another channel of the buffer and has its output inverted by a transistor before feeding the disable lines of the other channels of the buffer. +The result is that when the enable line is low, the buffer is disabled, +meaning that the DDS9m pins see a high impedance, and importantly, are isolated from each other since they are on their own channels. +When the enable pin is high, the buffer is enabled, and the signal from the clock line is sent to both pins. + +Since the one clock line feeds both pins, when it goes high the output is updated, and when it goes low the next value is loaded into the buffer in preparation for the next clock tick. + +.. note:: + Alternate circuits that do not involve tri-state buffering are described in the `mailing list `_. + +Software implementation +~~~~~~~~~~~~~~~~~~~~~~~ + +Manual/static mode +------------------ + +When the Novatech BLACS tab is in static mode, the device operates in “automatic update” mode, having had the I a command called. +When front panel values are changed, the appropriate Fn, Vn, or Pn command is sent, and the output updates without the need for any extra hardware trigger. + +Table/buffered mode +------------------- + +When the Novatech BLACS tab transitions to buffered mode, it executes commands in a very specific order. +Firstly, the “static” channels (2 & 3) are programmed using the same method as manual mode, then the values for the buffered channels (0 & 1) are programmed in. +Since it takes a considerable amount of time to program a full table over the slow RS232 connection, we have implemented “smart programming”, +where the table to be programmed is compared with the last table programmed into the device. +Only the lines which have changed are reprogrammed, overwriting those values in the DDS9m’s table, but keeping all other previous values as they are. +If you suspect that your table has become corrupt you can always force a “fresh program” where BLACS‘ “smart cache” is cleared and the whole table is programmed. + +Once the table has been written, we sent the mt command to the board, which places it in table mode. +Since we are still in I a auto update mode at this point, the first entry of the table is not only loaded into the buffer, but output too. +At this point, all channels on the board are outputting the instruction set at their initial output time for the experiment to be run. +We now send the I e command to switch to hardware updating, and wait for the experiment to begin. + +As the experiment starts, the table enable line must be set to go high at the board’s initial output time, and the clocking line will go high too. +This initial rising edge will do nothing, since the device is already outputting the values in its buffer. +The first falling edge will then load the second line of the table into the buffer, ready for the second rising edge to trigger the output of the second value, and so on. + +On transition to manual, at the end of the experiment, m 0 is sent to put the board back into manual mode, and I a is sent to turn automatic updating of the outputs again. +The last values of the experiment are then programmed in via the normal manual update mode to keep the front panel consistent with the output. + Detailed Documentation ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/img/NT_DDS9m_schemeit-project.png b/docs/source/img/NT_DDS9m_schemeit-project.png new file mode 100644 index 0000000000000000000000000000000000000000..2e4ad09aa9bcdb0a1f11e2a7bca5387522026eea GIT binary patch literal 16959 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%0D@MELe+oVZoyFrGPiiD6`(ljJl%_}+*I=>#s! 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 Bk$V6D literal 0 HcmV?d00001 From 8a328a441d23590e900e8c4cdc4e862fa8b06b2b Mon Sep 17 00:00:00 2001 From: OljaKhor Date: Fri, 30 May 2025 14:53:11 +0200 Subject: [PATCH 08/13] fix minor bugs --- labscript_devices/BS_341A/BLACS_tabs.py | 12 ++++++------ labscript_devices/BS_341A/BLACS_workers.py | 4 ++-- labscript_devices/BS_341A/BS_341A.md | 16 ++++------------ .../BS_341A/testing/emulateSerPort.py | 2 +- labscript_devices/BS_341A/voltage_source.py | 15 ++++++--------- 5 files changed, 19 insertions(+), 30 deletions(-) diff --git a/labscript_devices/BS_341A/BLACS_tabs.py b/labscript_devices/BS_341A/BLACS_tabs.py index d80b19d4..e5cc9ed7 100644 --- a/labscript_devices/BS_341A/BLACS_tabs.py +++ b/labscript_devices/BS_341A/BLACS_tabs.py @@ -20,9 +20,9 @@ def initialise_GUI(self): # Create AO Output objects ao_prop = {} - for i in range(self.num_AO): - if i == 0: - ao_prop['channel %d' % i+1] = { + for i in range(1, int(self.num_AO) + 1): + if i == 1: + ao_prop['channel %d' % i] = { 'base_unit': self.base_units, 'min': self.base_min, 'max': self.base_max, @@ -30,10 +30,10 @@ def initialise_GUI(self): 'decimals': self.base_decimals, } else: - ao_prop['channel %d' % i+1] = { + ao_prop['channel %d' % i] = { 'base_unit': self.base_units, - 'min': self.base_min - 10, #workaround defect - 'max': self.base_max + 10, + 'min': -34.560, #workaround defect + 'max': 34.560, 'step': self.base_step, 'decimals': self.base_decimals, } diff --git a/labscript_devices/BS_341A/BLACS_workers.py b/labscript_devices/BS_341A/BLACS_workers.py index b8b047f7..c4a1d501 100644 --- a/labscript_devices/BS_341A/BLACS_workers.py +++ b/labscript_devices/BS_341A/BLACS_workers.py @@ -230,7 +230,7 @@ def _ao_to_channel_name(ao_name: str) -> str: @staticmethod def _channel_name_to_ao(channel_name: str) -> str: - """ Convert 'channel 0' to 'ao0' """ + """ Convert 'channel 1' to 'ao1' """ try: channel_index = int(channel_name.replace('channel ', '')) return f'ao{channel_index}' @@ -248,7 +248,7 @@ def _playback_thread(self, events): time.sleep(t) for ch_name, voltage in voltages.items(): - ch_num = self._get_channel_num(ch_name) + ch_num = self._get_channel_num(ch_name) # 'ao1' --> '01' self.voltage_source.set_voltage(ch_num, voltage) self.final_values[ch_num] = voltage if self.verbose: diff --git a/labscript_devices/BS_341A/BS_341A.md b/labscript_devices/BS_341A/BS_341A.md index d1fb3713..a7de79ec 100644 --- a/labscript_devices/BS_341A/BS_341A.md +++ b/labscript_devices/BS_341A/BS_341A.md @@ -42,17 +42,9 @@ Use that port (e.g., /dev/pts/5) when connecting in `connection_table.py`. The real BS 34-1A we own has **non-uniform voltage ranges**: - **Channel 1** supports ±24 V -- **Channels 2–8** support ~ ±34 V +- **Channels 2–8** support ~ ±34.560 V Labscript normalizes voltage values using a fixed `voltage_range` -parameter for the entire device. So, we add a _**dirty**_ workaround. - -Set `voltage_range = 34` (maximum), and ensure your code or GUI logic: -- avoids setting values outside of ±24 V for **Channel 1** -- optionally warns or clips values to prevent hardware violation - -This workaround ensures all normalized values remain safe for the physical hardware, at the cost of limited precision on Channel 1. - -Future improvements might include: -- per-channel range support -- automatic validation and clipping \ No newline at end of file +parameter for the entire device. So, we add a _dirty_ workaround +in `voltage_source.set_voltage()` function. This workaround ensures +all normalized values remain safe for the physical hardware. diff --git a/labscript_devices/BS_341A/testing/emulateSerPort.py b/labscript_devices/BS_341A/testing/emulateSerPort.py index b7a2c334..5bb2b2ea 100644 --- a/labscript_devices/BS_341A/testing/emulateSerPort.py +++ b/labscript_devices/BS_341A/testing/emulateSerPort.py @@ -6,7 +6,7 @@ The virtual serial port should stay open while the simulation is running, so other code that expects to interact with the serial device can do so just as if the actual device were connected. Run following command in the corresponding folder. - python3 -m testing/BS_341A.emulateSerPort + python3 -m BS_341A.testing.emulateSerPort """ import os, pty, threading, time diff --git a/labscript_devices/BS_341A/voltage_source.py b/labscript_devices/BS_341A/voltage_source.py index c103eb20..bd65870d 100644 --- a/labscript_devices/BS_341A/voltage_source.py +++ b/labscript_devices/BS_341A/voltage_source.py @@ -50,23 +50,20 @@ def identify_query(self): def set_voltage(self, channel_num, value): """ Send set voltage command to device. Args: - channel_num (str): Channel number. + channel_num (str): Channel number '01'. value (float): Voltage value to set. Raises: - LabscriptError: If the response from BS-1-10 is incorrect. + LabscriptError: If the response from BS-341A is incorrect. """ try: channel = f"CH{int(channel_num):02d}" - if channel_num == 1: - scaled_voltage = self._scale_to_normalized(float(value), float(self.device_voltage_range)) - else: #workaround defect - scaled_voltage = self._scale_to_normalized(float(value), float(self.device_voltage_range + 10)) + voltage_range = float(self.device_voltage_range) if channel == 'CH01' else 34.560 # dirty workaround + scaled_voltage = self._scale_to_normalized(float(value), float(voltage_range)) send_str = f"{self.device_serial} {channel} {scaled_voltage:.5f}\r" self.connection.write(send_str.encode()) response = self.connection.readline().decode().strip() #'CHXX Y.YYYYY' - - logger.debug(f"Sent to BS-34: {send_str!r} | Received: {response!r}") + logger.debug(f"Sent to BS-34: {send_str!r} with {value} | Received: {response!r}") expected_response = f"{channel} {scaled_voltage:.5f}" if response != expected_response: @@ -113,7 +110,7 @@ def voltage_query(self, channel_num): """ Query voltage on the channel. Args: - channel_num (int): Channel number. + channel_num (str): Channel number. Returns: float: voltage in Volts. Raises: From bda78f8f6605631d5700a8970025cde90caad971 Mon Sep 17 00:00:00 2001 From: OljaKhor Date: Thu, 5 Jun 2025 15:42:37 +0200 Subject: [PATCH 09/13] refactor --- .../BS_341A/testing/first_connection.py | 41 ---- .../{BS_341A => BS_Series}/BLACS_tabs.py | 50 ++--- .../{BS_341A => BS_Series}/BLACS_workers.py | 188 +++++++----------- .../{BS_341A => BS_Series}/BS_341A.md | 2 +- .../{BS_341A => BS_Series}/__init__.py | 0 .../labscript_devices.py | 19 +- .../{BS_341A => BS_Series}/logger_config.py | 4 +- .../register_classes.py | 2 +- .../testing/__init__.py | 0 .../testing/emulateSerPort.py | 2 +- labscript_devices/BS_Series/utils.py | 76 +++++++ .../{BS_341A => BS_Series}/voltage_source.py | 12 +- 12 files changed, 184 insertions(+), 212 deletions(-) delete mode 100644 labscript_devices/BS_341A/testing/first_connection.py rename labscript_devices/{BS_341A => BS_Series}/BLACS_tabs.py (64%) rename labscript_devices/{BS_341A => BS_Series}/BLACS_workers.py (54%) rename labscript_devices/{BS_341A => BS_Series}/BS_341A.md (97%) rename labscript_devices/{BS_341A => BS_Series}/__init__.py (100%) rename labscript_devices/{BS_341A => BS_Series}/labscript_devices.py (88%) rename labscript_devices/{BS_341A => BS_Series}/logger_config.py (87%) rename labscript_devices/{BS_341A => BS_Series}/register_classes.py (62%) rename labscript_devices/{BS_341A => BS_Series}/testing/__init__.py (100%) rename labscript_devices/{BS_341A => BS_Series}/testing/emulateSerPort.py (98%) create mode 100644 labscript_devices/BS_Series/utils.py rename labscript_devices/{BS_341A => BS_Series}/voltage_source.py (95%) diff --git a/labscript_devices/BS_341A/testing/first_connection.py b/labscript_devices/BS_341A/testing/first_connection.py deleted file mode 100644 index 23a7d6c2..00000000 --- a/labscript_devices/BS_341A/testing/first_connection.py +++ /dev/null @@ -1,41 +0,0 @@ -import serial -import time -import serial.tools.list_ports - - - -def check_ports(): - ports = serial.tools.list_ports.comports() - for port in ports: - print(f"Port: {port.device}, Description: {port.description}") - - -port = '/dev/ttyUSB0' -baud_rate = 9600 -timeout = 2 - -try: - ser = serial.Serial(port, baud_rate, timeout=timeout) - print(f"Connected to {port} at {baud_rate} baud") - - while True: - cmd = input("Enter command (or type 'exit' to quit): ") - cms = cmd + '\r' - if cmd.lower() == 'exit': - print("Exiting...") - break - - # Send the command with carriage return, modify if your device uses something else - ser.write((cmd + "\r").encode()) - time.sleep(0.5) # Wait a bit for the device to respond - - # Read all available lines (or just one line if you prefer) - response = ser.readline().decode().strip() - print("Device response:", response) - - ser.close() - -except serial.SerialException as e: - print(f"Serial error: {e}") -except Exception as e: - print(f"Unexpected error: {e}") \ No newline at end of file diff --git a/labscript_devices/BS_341A/BLACS_tabs.py b/labscript_devices/BS_Series/BLACS_tabs.py similarity index 64% rename from labscript_devices/BS_341A/BLACS_tabs.py rename to labscript_devices/BS_Series/BLACS_tabs.py index e5cc9ed7..f79f7fa5 100644 --- a/labscript_devices/BS_341A/BLACS_tabs.py +++ b/labscript_devices/BS_Series/BLACS_tabs.py @@ -3,26 +3,24 @@ from blacs.device_base_class import DeviceTab from .logger_config import logger from blacs.tab_base_classes import MODE_MANUAL +from .utils import _create_button -class BS_341ATab(DeviceTab): +class BS_Tab(DeviceTab): def initialise_GUI(self): - connection_table = self.settings['connection_table'] - properties = connection_table.find_by_name(self.device_name).properties - # Capabilities self.base_units = 'V' - self.base_min = -24 - self.base_max = 24 + self.base_min = -24 # Depends on channel + self.base_max = 24 # Depends on channel self.base_step = 1 self.base_decimals = 3 - self.num_AO = 8 # or properties['num_AO'] + self.num_AO = 8 # Create AO Output objects ao_prop = {} for i in range(1, int(self.num_AO) + 1): if i == 1: - ao_prop['channel %d' % i] = { + ao_prop['CH0%d' % i] = { 'base_unit': self.base_units, 'min': self.base_min, 'max': self.base_max, @@ -30,41 +28,23 @@ def initialise_GUI(self): 'decimals': self.base_decimals, } else: - ao_prop['channel %d' % i] = { + ao_prop['CH0%d' % i] = { 'base_unit': self.base_units, - 'min': -34.560, #workaround defect + 'min': -34.560, # workaround defect 'max': 34.560, 'step': self.base_step, 'decimals': self.base_decimals, } - # Create the output objects + # Create and save AO objects self.create_analog_outputs(ao_prop) - # Create widgets for output objects + # Create widgets for AO objects widgets, ao_widgets,_ = self.auto_create_widgets() self.auto_place_widgets(("Analog Outputs", ao_widgets)) - # Add button to reprogramm device from manual mode - self.send_button = QPushButton("Send to device") - self.send_button.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - self.send_button.adjustSize() - self.send_button.setStyleSheet(""" - QPushButton { - border: 1px solid #B8B8B8; - border-radius: 3px; - background-color: #F0F0F0; - padding: 4px 10px; - font-weight: light; - } - QPushButton:hover { - background-color: #E0E0E0; - } - QPushButton:pressed { - background-color: #D0D0D0; - } - """) - self.send_button.clicked.connect(lambda: self.send_to_BS()) + # Create buttons to send-to-device + self.send_button = _create_button("Send to device", self.send_to_BS) # Add centered layout to center the button center_layout = QHBoxLayout() @@ -98,7 +78,7 @@ def initialise_workers(self): # Start a worker process self.create_worker( 'main_worker', - 'labscript_devices.BS_341A.BLACS_workers.BS_341AWorker', + 'labscript_devices.BS_Series.BLACS_workers.BS_Worker', worker_kwargs, ) self.primary_worker = "main_worker" @@ -108,10 +88,10 @@ def send_to_BS(self): """Queue a manual send-to-device operation from the GUI. This function is triggered from the BLACS tab (by pressing a button) - and runs in the main thread. It queues the `send2BS()` function to be + and runs in the main thread. It queues the `send_to_BS()` function to be executed by the worker. - Used to reprogram the BS-1-10 device based on current front panel values. + Used to reprogram the device based on current front panel values. """ try: yield(self.queue_work(self.primary_worker, 'send_to_BS', [])) diff --git a/labscript_devices/BS_341A/BLACS_workers.py b/labscript_devices/BS_Series/BLACS_workers.py similarity index 54% rename from labscript_devices/BS_341A/BLACS_workers.py rename to labscript_devices/BS_Series/BLACS_workers.py index c4a1d501..e3a86822 100644 --- a/labscript_devices/BS_341A/BLACS_workers.py +++ b/labscript_devices/BS_Series/BLACS_workers.py @@ -1,5 +1,3 @@ -import threading - from blacs.tab_base_classes import Worker from labscript import LabscriptError from .logger_config import logger @@ -9,37 +7,29 @@ from labscript_utils import properties from zprocess import rich_print from datetime import datetime +import threading +from .utils import _get_channel_num, _ao_to_CH -class BS_341AWorker(Worker): +class BS_Worker(Worker): def init(self): """Initialises communication with the device. When BLACS (re)starts""" - self.thread = None - # self.thread_stop_event = threading.Event() - - self.final_values = {} # [[channel_nums(ints)],[voltages(floats)]] + self.final_values = {} # [[channel_nums(ints)],[voltages(floats)]] to update GUI after shot self.verbose = True + # for running the buffered experiment in a separate thread: + self.thread = None + self._stop_event = threading.Event() + self._finished_event = threading.Event() + try: # Try to establish a serial connection from .voltage_source import VoltageSource self.voltage_source = VoltageSource(self.port, self.baud_rate) - # Get device information - self.device_serial = self.voltage_source.device_serial # For example, 'HV023' - self.device_voltage_range = self.voltage_source.device_voltage_range # For example, '50' - self.device_channels = self.voltage_source.device_channels # For example, '10' - self.device_output_type = self.voltage_source.device_output_type # For example, 'b' (bipolar, unipolar, quadrupole, steerer supply) - - logger.info( - f"Connected to BS-34-1A on {self.port} with baud rate {self.baud_rate}\n" - f"Device Serial: {self.device_serial}, Voltage Range: {self.device_voltage_range}, " - f"Channels: {self.device_channels}, Output Type: {self.device_output_type}" - ) - except LabscriptError as e: - raise RuntimeError(f"BS-1-10 identification failed: {e}") + raise RuntimeError(f"BS-34-1A identification failed: {e}") except Exception as e: - raise RuntimeError(f"An error occurred during BS_341AWorker initialization: {e}") + raise RuntimeError(f"An error occurred during BS_Worker initialization: {e}") def shutdown(self): @@ -51,31 +41,31 @@ def program_manual(self, front_panel_values): setting outputs to the values set in the BLACS_tab widgets. Runs at the end of the shot.""" - rich_print(f"---------- Manual MODE start: ----------", color=PINK) + rich_print(f"---------- Manual MODE start: ----------", color=BLUE) self.front_panel_values = front_panel_values - if self.verbose is True: - print("Front panel values (before shot):") - for ch_name, voltage in front_panel_values.items(): - print(f" {ch_name}: {voltage:.2f} V") - - # Restore final values from previous shot, if available - if self.final_values and not getattr(self, 'restored_from_final_values', False): - for ch_num, value in self.final_values.items(): - front_panel_values[f'channel {int(ch_num)}'] = value - self.restored_from_final_values = True + if not getattr(self, 'restored_from_final_values', False): + if self.verbose is True: + print("Front panel values (before shot):") + for ch_name, voltage in front_panel_values.items(): + print(f" {ch_name}: {voltage:.2f} V") - if self.verbose is True: - print("\nFront panel values (after shot):") - for ch_num, voltage in self.final_values.items(): - print(f" {ch_num}: {voltage:.2f} V") + # Restore final values from previous shot, if available + if self.final_values: + for ch_num, value in self.final_values.items(): + front_panel_values[f'CH0{int(ch_num)}'] = value - self.final_values = {} # Empty after restoring + if self.verbose is True: + print("\nFront panel values (after shot):") + for ch_num, voltage in self.final_values.items(): + print(f" {ch_num}: {voltage:.2f} V") - return front_panel_values + self.final_values = {} # Empty after restoring + self.restored_from_final_values = True - def check_remote_values(self): # reads the current settings of the device, updating the BLACS_tab widgets + return front_panel_values + def check_remote_values(self): return def transition_to_buffered(self, device_name, h5_file, initial_values, fresh): @@ -84,6 +74,7 @@ def transition_to_buffered(self, device_name, h5_file, initial_values, fresh): labscript_device.generate_code and sending the appropriate commands to the hardware. Runs at the start of each shot.""" + rich_print(f"---------- Begin transition to Buffered: ----------", color=BLUE) self.restored_from_final_values = False # Drop flag self.initial_values = initial_values # Store the initial values in case we have to abort and restore them @@ -94,10 +85,8 @@ def transition_to_buffered(self, device_name, h5_file, initial_values, fresh): with h5py.File(h5_file, 'r') as hdf5_file: group = hdf5_file['devices'][device_name] AO_data = group['AO_buffered'][:] - # self.device_prop = properties.get(hdf5_file, device_name, 'device_properties') - # print("======== Device Properties : ", self.device_prop, "=========") - # prepare events + # Prepare events events = [] for row in AO_data: t = row['time'] @@ -105,22 +94,44 @@ def transition_to_buffered(self, device_name, h5_file, initial_values, fresh): events.append((t, voltages)) # Create and launch thread - # self.thread_stop_event.clear() - self.thread = threading.Thread(target=self._playback_thread, args=(events,)) + self._stop_event.clear() + self._finished_event.clear() + self.thread = threading.Thread(target=self._run_experiment_sequence, args=(events,)) self.thread.start() - rich_print(f"---------- End transition to Buffered: ----------", color=BLUE) return - + + def _run_experiment_sequence(self, events): + try: + if self._stop_event.is_set(): + return + + for t, voltages in events: + time.sleep(t) + for conn_name, voltage in voltages.items(): + channel_num = _get_channel_num(conn_name) # 'ao 1' --> 1 + self.voltage_source.set_voltage(channel_num, voltage) + self.final_values[channel_num] = voltage + if self.verbose: + print(f"[{t:.3f}s] --> Set {conn_name} (#{channel_num}) = {voltage}") + if self._stop_event.is_set(): + return + finally: + self._finished_event.set() + print(f"[Thread] finished all events !") def transition_to_manual(self): """transitions the device from buffered to manual mode to read/save measurements from hardware to the shot h5 file as results. - Runs at the end of the shot.""" + Ensure background thread has finished before exiting the shot.""" #Stop the thread rich_print(f"---------- Begin transition to Manual: ----------", color=BLUE) + self.thread.join() - rich_print(f"---------- End transition to Manual: ----------", color=BLUE) + if not self._finished_event.is_set(): + print("WARNING: experiment sequence did not finish properly.") + else: + print("Experiment sequence completed successfully.") return True def abort_transition_to_buffered(self): @@ -128,39 +139,27 @@ def abort_transition_to_buffered(self): def _program_manual(self, front_panel_values): """Sends voltage values to the device for all channels using VoltageSource. + Parameters: + - front_panel_values (dict): Dictionary of voltages keyed by channel name (e.g., 'CH01', 'CH02', ...). """ if self.verbose is True: print("\nProgramming the device with the following values:") logger.info("Programming the device from manual with the following values:") for channel_num in range(1, int(self.num_AO) + 1): - channel_name = f'channel {channel_num}' - voltage = front_panel_values.get(channel_name, 0.0) - if self.verbose is True: + channel_name = f'CH0{channel_num}' # 'CH01' + try: + voltage = front_panel_values[channel_name] + except Exception as e: + raise ValueError(f"Error accessing front panel values for channel '{channel_name}': {e}") + if self.verbose: print(f"→ {channel_name}: {voltage:.2f} V") - # logger.info(f"Setting {channel_name} to {voltage:.2f} V (manual mode)") - self.voltage_source.set_voltage(channel_num, voltage) + logger.info(f"Setting {channel_name} to {voltage:.2f} V (manual mode)") - def _get_channel_num(self, channel): - """Gets channel number with leading zeros 'XX' from strings like 'AOX' or 'channel X'. - Args: - channel (str): The name of the channel, e.g. 'AO0', 'AO12', or 'channel 3'. - - Returns: - str: Two-digit channel number as string, e.g. '01', '12'.""" - ch_lower = channel.lower() - if ch_lower.startswith("ao"): - channel_num = channel[2:] # 'ao3' -> '3' - elif ch_lower.startswith("channel"): - _, channel_num = channel.split() # 'channel 1' -> '1' - else: - raise LabscriptError(f"Unexpected channel name format: '{channel}'") - - channel_int = int(channel_num) - return f"{channel_int:02d}" + self.voltage_source.set_voltage(channel_num, voltage) def send_to_BS(self, kwargs): - """Sends manual values from the front panel to the BS-1-10 device. + """Sends manual values from the front panel to the BS-series device. This function is executed in the worker process. It uses the current front panel values to reprogram the device in manual mode by clicking the button 'send to device'. Args: @@ -182,7 +181,7 @@ def _append_front_panel_values_to_manual(self, front_panel_values, current_time) Parameters ---------- front_panel_values : dict - Dictionary mapping channel names (e.g., 'channel 1') to voltage values (float). + Dictionary mapping channel names (e.g., 'CH01') to voltage values (float). current_time : str The timestamp (formatted as a string) when the values were recorded @@ -201,62 +200,21 @@ def _append_front_panel_values_to_manual(self, front_panel_values, current_time) with h5py.File(self.h5file, 'r+') as hdf5_file: group = hdf5_file['devices'][self.device_name] - # print("Keys in group:", list(group.keys())) - dset = group['AO_manual'] old_shape = dset.shape[0] dtype = dset.dtype - connections = [name for name in dset.dtype.names if name != 'time'] #'ao1' + connections = [name for name in dset.dtype.names if name != 'time'] #'ao 1' # Create new data row new_row = np.zeros((1,), dtype=dtype) new_row['time'] = current_time for conn in connections: - channel_name = self._ao_to_channel_name(conn) + channel_name = _ao_to_CH(conn) # 'CH01' new_row[conn] = front_panel_values.get(channel_name, 0.0) # Add new row to table dset.resize(old_shape + 1, axis=0) dset[old_shape] = new_row[0] - @staticmethod - def _ao_to_channel_name(ao_name: str) -> str: - """ Convert 'ao0' to 'channel 0' """ - try: - channel_index = int(ao_name.replace('ao', '')) - return f'channel {channel_index}' - except ValueError: - raise ValueError(f"Impossible to convert from '{ao_name}'") - - @staticmethod - def _channel_name_to_ao(channel_name: str) -> str: - """ Convert 'channel 1' to 'ao1' """ - try: - channel_index = int(channel_name.replace('channel ', '')) - return f'ao{channel_index}' - except ValueError: - raise ValueError(f"Impossible to convert from '{channel_name}'") - - def _playback_thread(self, events): - for t, voltages in events: - if self.verbose: - # print(f"stop event flag: {self.thread_stop_event.is_set()}") - print(f"time: {t} \t voltage: {voltages} \n") - #if self.thread_stop_event.is_set(): - #break - - time.sleep(t) - - for ch_name, voltage in voltages.items(): - ch_num = self._get_channel_num(ch_name) # 'ao1' --> '01' - self.voltage_source.set_voltage(ch_num, voltage) - self.final_values[ch_num] = voltage - if self.verbose: - print(f"[{t:.3f}s] --> Set {ch_num} (#{ch_num}) = {voltage}") - - print(f"[Thread] finished all events !") - - # --------------------contants -PINK = 'ff52fa' BLUE = '#66D9EF' \ No newline at end of file diff --git a/labscript_devices/BS_341A/BS_341A.md b/labscript_devices/BS_Series/BS_341A.md similarity index 97% rename from labscript_devices/BS_341A/BS_341A.md rename to labscript_devices/BS_Series/BS_341A.md index a7de79ec..9da2e106 100644 --- a/labscript_devices/BS_341A/BS_341A.md +++ b/labscript_devices/BS_Series/BS_341A.md @@ -30,7 +30,7 @@ it were the real device. To launch the emulator: ```bash -python3 -m testing/BS_341A.emulateSerPort +python3 -m testing/BS_Series.emulateSerPort ``` You’ll see output like: For BS 34-1A use: /dev/pts/5 diff --git a/labscript_devices/BS_341A/__init__.py b/labscript_devices/BS_Series/__init__.py similarity index 100% rename from labscript_devices/BS_341A/__init__.py rename to labscript_devices/BS_Series/__init__.py diff --git a/labscript_devices/BS_341A/labscript_devices.py b/labscript_devices/BS_Series/labscript_devices.py similarity index 88% rename from labscript_devices/BS_341A/labscript_devices.py rename to labscript_devices/BS_Series/labscript_devices.py index 5fd07505..674f385e 100644 --- a/labscript_devices/BS_341A/labscript_devices.py +++ b/labscript_devices/BS_Series/labscript_devices.py @@ -6,23 +6,22 @@ from labscript_devices.NI_DAQmx.utils import split_conn_DO, split_conn_AO from .logger_config import logger -class BS_341A(IntermediateDevice): # no pseudoclock IntermediateDevice --> Device - description = 'BS_341A' +class BS_341A(IntermediateDevice): + description = 'BS_Series' @set_passed_properties({"connection_table_properties": ["port", "baud_rate", "num_AO"]}) - def __init__(self, name, port='', baud_rate=9600, parent_device=None, connection=None, num_AO=0, **kwargs): + def __init__(self, name, port='', baud_rate=9600, parent_device=None, num_AO=0, **kwargs): + self.num_AO = num_AO IntermediateDevice.__init__(self, name, parent_device, **kwargs) - # self.start_commands = [] self.BLACS_connection = '%s,%s' % (port, str(baud_rate)) def add_device(self, device): - Device.add_device(self, device) + IntermediateDevice.add_device(self, device) def generate_code(self, hdf5_file): """Convert the list of commands into numpy arrays and save them to the shot file.""" logger.info("generate_code for BS 34-1A is called") IntermediateDevice.generate_code(self, hdf5_file) - group = self.init_device_group(hdf5_file) clockline = self.parent_device pseudoclock = clockline.parent_device @@ -35,10 +34,11 @@ def generate_code(self, hdf5_file): analogs[child_device.connection] = child_device AO_table = self._make_analog_out_table(analogs, times) - logger.info(f"Times in generate_code AO table: {times}") - logger.info(f"AO table for BS-34-1A is: {AO_table}") AO_manual_table = self._make_analog_out_table_from_manual(analogs) + logger.info(f"Times in generate_code AO table: {times}") + logger.info(f"AO table for HV-Series is: {AO_table}") + group = self.init_device_group(hdf5_file) group.create_dataset("AO_buffered", data=AO_table, compression=config.compression) group.create_dataset("AO_manual", shape=AO_manual_table.shape, maxshape=(None,), dtype=AO_manual_table.dtype, compression=config.compression, chunks=True) @@ -56,8 +56,7 @@ def _make_analog_out_table(self, analogs, times): return None n_timepoints = len(times) - connections = sorted(analogs, key=split_conn_AO) # sorted channel names - dtypes = [('time', np.float64)] + [(c, np.float32) for c in connections] # first column = time + dtypes = [('time', np.float64)] + [(c, np.float32) for c in analogs] # first column = time analog_out_table = np.empty(n_timepoints, dtype=dtypes) diff --git a/labscript_devices/BS_341A/logger_config.py b/labscript_devices/BS_Series/logger_config.py similarity index 87% rename from labscript_devices/BS_341A/logger_config.py rename to labscript_devices/BS_Series/logger_config.py index d5446466..143cabd7 100644 --- a/labscript_devices/BS_341A/logger_config.py +++ b/labscript_devices/BS_Series/logger_config.py @@ -3,10 +3,10 @@ # Configure the logger BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -LOG_FILE = os.path.join(BASE_DIR, 'logs.log') +LOG_FILE = os.path.join(BASE_DIR, 'device.log') # Create logger -logger = logging.getLogger("logs") +logger = logging.getLogger("BS_34") logger.setLevel(logging.DEBUG) # Create file handler and set level to debug diff --git a/labscript_devices/BS_341A/register_classes.py b/labscript_devices/BS_Series/register_classes.py similarity index 62% rename from labscript_devices/BS_341A/register_classes.py rename to labscript_devices/BS_Series/register_classes.py index dda2de57..af36d8f6 100644 --- a/labscript_devices/BS_341A/register_classes.py +++ b/labscript_devices/BS_Series/register_classes.py @@ -2,6 +2,6 @@ register_classes( "BS_341A", - BLACS_tab='labscript_devices.BS_341A.BLACS_tabs.BS_341ATab', + BLACS_tab='labscript_devices.BS_Series.BLACS_tabs.BS_Tab', runviewer_parser=None, ) \ No newline at end of file diff --git a/labscript_devices/BS_341A/testing/__init__.py b/labscript_devices/BS_Series/testing/__init__.py similarity index 100% rename from labscript_devices/BS_341A/testing/__init__.py rename to labscript_devices/BS_Series/testing/__init__.py diff --git a/labscript_devices/BS_341A/testing/emulateSerPort.py b/labscript_devices/BS_Series/testing/emulateSerPort.py similarity index 98% rename from labscript_devices/BS_341A/testing/emulateSerPort.py rename to labscript_devices/BS_Series/testing/emulateSerPort.py index 5bb2b2ea..4bbeb740 100644 --- a/labscript_devices/BS_341A/testing/emulateSerPort.py +++ b/labscript_devices/BS_Series/testing/emulateSerPort.py @@ -6,7 +6,7 @@ The virtual serial port should stay open while the simulation is running, so other code that expects to interact with the serial device can do so just as if the actual device were connected. Run following command in the corresponding folder. - python3 -m BS_341A.testing.emulateSerPort + python3 -m BS_Series.testing.emulateSerPort """ import os, pty, threading, time diff --git a/labscript_devices/BS_Series/utils.py b/labscript_devices/BS_Series/utils.py new file mode 100644 index 00000000..b53e286e --- /dev/null +++ b/labscript_devices/BS_Series/utils.py @@ -0,0 +1,76 @@ +from labscript_utils import dedent +from .logger_config import logger +from qtutils.qt.QtWidgets import QPushButton, QSizePolicy, QHBoxLayout, QSpacerItem, QSizePolicy as QSP + +def _ao_to_CH(ao_name: str) -> str: + """Converts a string like 'ao 0' or 'ao2' to a channel string like 'CH01' or 'CH03'. + Args: + ao_name (str): Analog output name (e.g., 'ao 0', 'ao1') + + Returns: + str: Channel name (e.g., 'CH01') + + Raises: + ValueError: If input format is invalid. + """ + ao_name = ao_name.strip().lower().replace(' ', '') # Normalize input: 'ao 0' -> 'ao0' + + if not ao_name.startswith('ao') or len(ao_name) < 3: + raise ValueError(f"Invalid AO name format: '{ao_name}'") + + try: + ao_index = int(ao_name[2:]) + except ValueError: + raise ValueError(f"Unable to extract index from AO name: '{ao_name}'") + + return f'CH{ao_index:02d}' # Always 2 digits, e.g. CH01, CH02 + + +def _get_channel_num(channel: str) -> int: + """Extracts the channel number from strings like 'AO3', 'ao 3', or 'CH03'. + Args: + channel (str): The name of the channel. + + Returns: + int: Channel number, e.g., 1 to 8. + + Raises: + ValueError: If the channel string format is invalid or the number is out of range.""" + ch_lower = channel.lower() + if ch_lower.startswith("ao "): + channel_num = int(ch_lower[3:]) # 'ao 3' -> 3 + elif ch_lower.startswith("ao"): + channel_num = int(ch_lower[2:]) # 'ao3' -> 3 + elif ch_lower.startswith("channel"): + _, channel_num_str = channel.split() # 'channel 1' -> 1 + channel_num = int(channel_num_str) + elif ch_lower.startswith("ch0"): + channel_num = int(channel[3:]) # 'ch03' -> 3 + else: + raise ValueError(f"Unexpected channel name format: '{channel}'") + + return channel_num + +def _create_button(text, on_click_callback): + """Creates a styled QPushButton with consistent appearance and connects it to the given callback.""" + button = QPushButton(text) + button.setSizePolicy(QSP.Fixed, QSP.Fixed) + button.adjustSize() + button.setStyleSheet(""" + QPushButton { + border: 1px solid #B8B8B8; + border-radius: 3px; + background-color: #F0F0F0; + padding: 4px 10px; + font-weight: light; + } + QPushButton:hover { + background-color: #E0E0E0; + } + QPushButton:pressed { + background-color: #D0D0D0; + } + """) + button.clicked.connect(lambda: on_click_callback()) + logger.debug(f"Button {text} is created") + return button \ No newline at end of file diff --git a/labscript_devices/BS_341A/voltage_source.py b/labscript_devices/BS_Series/voltage_source.py similarity index 95% rename from labscript_devices/BS_341A/voltage_source.py rename to labscript_devices/BS_Series/voltage_source.py index bd65870d..858ffe58 100644 --- a/labscript_devices/BS_341A/voltage_source.py +++ b/labscript_devices/BS_Series/voltage_source.py @@ -4,7 +4,7 @@ from .logger_config import logger class VoltageSource: - """ Voltage Source for ST BS 34-1/BS 1-8 class to establish and maintain the communication with the connection. + """ Voltage Source class to establish and maintain the communication with the connection. """ def __init__(self, port, @@ -17,7 +17,7 @@ def __init__(self, self.baud_rate = baud_rate # connecting to connectionice - self.connection = serial.Serial(self.port, self.baud_rate, timeout=1) + self.connection = serial.Serial(self.port, self.baud_rate, timeout=0.05) device_info = self.identify_query() self.device_serial = device_info[0] # For example, 'HV023' self.device_voltage_range = device_info[1] # For example, '50' @@ -44,16 +44,16 @@ def identify_query(self): f"Raw identity: {raw_response!r}\n" f"Parsed identity: {identity!r}\n" f"Expected format: ['HVXXX', 'RRR', 'CC', 'b']\n" - f"Device: at port {self.port!r}\n" + f"Device: BS at port {self.port!r}\n" ) def set_voltage(self, channel_num, value): """ Send set voltage command to device. Args: - channel_num (str): Channel number '01'. + channel_num (int): Channel number. value (float): Voltage value to set. Raises: - LabscriptError: If the response from BS-341A is incorrect. + LabscriptError: If the response from device is incorrect. """ try: channel = f"CH{int(channel_num):02d}" @@ -72,7 +72,7 @@ def set_voltage(self, channel_num, value): f"Sent command: {send_str.strip()!r}\n" f"Expected response: {expected_response!r}\n" f"Actual response: {response!r}\n" - f"Device: BS-1-10 at port {self.port!r}" + f"Device at port {self.port!r}" ) except Exception as e: raise LabscriptError(f"Error in set_voltage: {e}") From a789bcb83fabf6dc9e610a336b5d3c646626fa25 Mon Sep 17 00:00:00 2001 From: OljaKhor Date: Fri, 6 Jun 2025 14:25:20 +0200 Subject: [PATCH 10/13] add standard & special device config in models --- labscript_devices/BS_Series/BLACS_tabs.py | 47 +++++++------ labscript_devices/BS_Series/BLACS_workers.py | 2 +- .../BS_Series/labscript_devices.py | 67 +++++++++++++++++-- labscript_devices/BS_Series/models/BS_341A.py | 18 +++++ .../BS_Series/models/BS_341A_spec.py | 18 +++++ .../BS_Series/models/__init__.py | 19 ++++++ .../BS_Series/models/capabilities.json | 26 +++++++ .../BS_Series/register_classes.py | 27 +++++++- labscript_devices/BS_Series/voltage_source.py | 11 ++- 9 files changed, 206 insertions(+), 29 deletions(-) create mode 100644 labscript_devices/BS_Series/models/BS_341A.py create mode 100644 labscript_devices/BS_Series/models/BS_341A_spec.py create mode 100644 labscript_devices/BS_Series/models/__init__.py create mode 100644 labscript_devices/BS_Series/models/capabilities.json diff --git a/labscript_devices/BS_Series/BLACS_tabs.py b/labscript_devices/BS_Series/BLACS_tabs.py index f79f7fa5..ab5f47ed 100644 --- a/labscript_devices/BS_Series/BLACS_tabs.py +++ b/labscript_devices/BS_Series/BLACS_tabs.py @@ -7,31 +7,41 @@ class BS_Tab(DeviceTab): def initialise_GUI(self): + # Get properties from connection table + connection_table = self.settings['connection_table'] + properties = connection_table.find_by_name(self.device_name).properties - # Capabilities + logger.info(f"properties: {properties}") + + self.supports_custom_voltages_per_channel = properties['supports_custom_voltages_per_channel'] + self.num_AO = properties['num_AO'] + if self.supports_custom_voltages_per_channel: + self.AO_ranges = properties['AO_ranges'] + else: + self.default_voltage_range = properties['default_voltage_range'] + + # GUI Capabilities self.base_units = 'V' - self.base_min = -24 # Depends on channel - self.base_max = 24 # Depends on channel self.base_step = 1 self.base_decimals = 3 - self.num_AO = 8 # Create AO Output objects ao_prop = {} for i in range(1, int(self.num_AO) + 1): - if i == 1: + if self.supports_custom_voltages_per_channel: + voltage_range = self.AO_ranges[i-1]['voltage_range'] ao_prop['CH0%d' % i] = { 'base_unit': self.base_units, - 'min': self.base_min, - 'max': self.base_max, + 'min': voltage_range[0], + 'max': voltage_range[1], 'step': self.base_step, 'decimals': self.base_decimals, } else: ao_prop['CH0%d' % i] = { 'base_unit': self.base_units, - 'min': -34.560, # workaround defect - 'max': 34.560, + 'min': self.default_voltage_range[0], + 'max': self.default_voltage_range[1], 'step': self.base_step, 'decimals': self.base_decimals, } @@ -61,18 +71,15 @@ def initialise_GUI(self): def initialise_workers(self): # Get properties from connection table. - device = self.settings['connection_table'].find_by_name(self.device_name) - if device is None: - raise ValueError(f"Device '{self.device_name}' not found in the connection table.") - - # look up the port and baud in the connection table - port = device.properties["port"] - baud_rate = device.properties["baud_rate"] - num_AO = device.properties['num_AO'] + properties = self.settings['connection_table'].find_by_name(self.device_name).properties + worker_kwargs = {"name": self.device_name + '_main', - "port": port, - "baud_rate": baud_rate, - "num_AO": num_AO + "port": properties['port'], + "baud_rate": properties['baud_rate'], + "num_AO": properties['num_AO'], + "supports_custom_voltages_per_channel": properties['supports_custom_voltages_per_channel'], + "AO_ranges": properties['AO_ranges'], + "default_voltage_range": properties['default_voltage_range'], } # Start a worker process diff --git a/labscript_devices/BS_Series/BLACS_workers.py b/labscript_devices/BS_Series/BLACS_workers.py index e3a86822..55aa9f10 100644 --- a/labscript_devices/BS_Series/BLACS_workers.py +++ b/labscript_devices/BS_Series/BLACS_workers.py @@ -24,7 +24,7 @@ def init(self): try: # Try to establish a serial connection from .voltage_source import VoltageSource - self.voltage_source = VoltageSource(self.port, self.baud_rate) + self.voltage_source = VoltageSource(self.port, self.baud_rate, self.supports_custom_voltages_per_channel, self.default_voltage_range, self.AO_ranges) except LabscriptError as e: raise RuntimeError(f"BS-34-1A identification failed: {e}") diff --git a/labscript_devices/BS_Series/labscript_devices.py b/labscript_devices/BS_Series/labscript_devices.py index 674f385e..d065cbb4 100644 --- a/labscript_devices/BS_Series/labscript_devices.py +++ b/labscript_devices/BS_Series/labscript_devices.py @@ -6,12 +6,70 @@ from labscript_devices.NI_DAQmx.utils import split_conn_DO, split_conn_AO from .logger_config import logger -class BS_341A(IntermediateDevice): +class BS_(IntermediateDevice): description = 'BS_Series' - @set_passed_properties({"connection_table_properties": ["port", "baud_rate", "num_AO"]}) - def __init__(self, name, port='', baud_rate=9600, parent_device=None, num_AO=0, **kwargs): + @set_passed_properties( + property_names={ + "connection_table_properties": [ + "static_AO", + "baud_rate", + "port", + "num_AO", + "AO_ranges", + "default_voltage_range", + "supports_custom_voltages_per_channel", + ], + } + ) + def __init__( + self, + name, + port='', + baud_rate=9600, + parent_device=None, + num_AO=0, + static_AO = None, + AO_ranges = [], + default_voltage_range = [], + supports_custom_voltages_per_channel = False, + **kwargs + ): + """Initialize a generic BS-series analog output device. + + This constructor supports both devices that share a global analog output + voltage range, and those that allow custom voltage ranges per channel. + + Args: + name (str): Name to assign to the created labscript device. + port (str): Serial port used to connect to the device (e.g. COM3, /dev/ttyUSB0) + baud_rate (int): + parent_device (clockline): Parent clockline device that will + clock the outputs of this device + num_AO (int): Number of analog output channels. + AO_ranges (list of dict, optional): A list specifying the voltage range for each AO channel, + used only if `supports_custom_voltages_per_channel` is True. + Each item should be a dict of the form: + { + "channel": , # Channel index + "voltage_range": [, ] # Min and max voltage + } + static_AO (int, optional): Number of static analog output channels. + default_voltage_range (iterable): A `[Vmin, Vmax]` pair that sets the analog + output voltage range for all analog outputs. + supports_custom_voltages_per_channel (bool): Whether this device supports specifying + individual voltage ranges for each AO channel. + """ self.num_AO = num_AO + if supports_custom_voltages_per_channel: + if len(AO_ranges) < num_AO: + raise ValueError( + "AO_ranges must contain at least num_AO entries when custom voltage ranges are enabled.") + else: + self.AO_ranges = AO_ranges + else: + self.default_voltage_range = default_voltage_range + IntermediateDevice.__init__(self, name, parent_device, **kwargs) self.BLACS_connection = '%s,%s' % (port, str(baud_rate)) @@ -76,8 +134,7 @@ def _make_analog_out_table_from_manual(self, analogs): str_dtype = h5py.string_dtype(encoding='utf-8', length=19) - connections = sorted(analogs, key=split_conn_AO) # sorted channel names - dtypes = [('time', str_dtype)] + [(c, np.float32) for c in connections] + dtypes = [('time', str_dtype)] + [(c, np.float32) for c in analogs] analog_out_table = np.empty(0, dtype=dtypes) return analog_out_table diff --git a/labscript_devices/BS_Series/models/BS_341A.py b/labscript_devices/BS_Series/models/BS_341A.py new file mode 100644 index 00000000..f5befc01 --- /dev/null +++ b/labscript_devices/BS_Series/models/BS_341A.py @@ -0,0 +1,18 @@ +from labscript_devices.BS_Series.labscript_devices import BS_ +import json +import os + +THIS_FOLDER = os.path.dirname(os.path.abspath(__file__)) +CAPABILITIES_FILE = os.path.join(THIS_FOLDER, 'capabilities.json') +with open(CAPABILITIES_FILE, 'r') as f: + CAPABILITIES = json.load(f).get('BS_341A', {}) + + +class BS_341A(BS_): + description = 'BS_341A' + + def __init__(self, *args, **kwargs): + """Class for BS 34-1A basic configuration""" + combined_kwargs = CAPABILITIES.copy() + combined_kwargs.update(kwargs) + BS_.__init__(self, *args, **combined_kwargs) \ No newline at end of file diff --git a/labscript_devices/BS_Series/models/BS_341A_spec.py b/labscript_devices/BS_Series/models/BS_341A_spec.py new file mode 100644 index 00000000..ea3e9229 --- /dev/null +++ b/labscript_devices/BS_Series/models/BS_341A_spec.py @@ -0,0 +1,18 @@ +from labscript_devices.BS_Series.labscript_devices import BS_ +import json +import os + +THIS_FOLDER = os.path.dirname(os.path.abspath(__file__)) +CAPABILITIES_FILE = os.path.join(THIS_FOLDER, 'capabilities.json') +with open(CAPABILITIES_FILE, 'r') as f: + CAPABILITIES = json.load(f).get('BS_341A_spec', {}) + + +class BS_341A_spec(BS_): + description = 'BS_341A_spec' + + def __init__(self, *args, **kwargs): + """Class for BS 34-1A special configuration""" + combined_kwargs = CAPABILITIES.copy() + combined_kwargs.update(kwargs) + BS_.__init__(self, *args, **combined_kwargs) \ No newline at end of file diff --git a/labscript_devices/BS_Series/models/__init__.py b/labscript_devices/BS_Series/models/__init__.py new file mode 100644 index 00000000..61a72f8a --- /dev/null +++ b/labscript_devices/BS_Series/models/__init__.py @@ -0,0 +1,19 @@ +import os +import json +from labscript_devices import import_class_by_fullname + +THIS_FOLDER = os.path.dirname(os.path.abspath(__file__)) +CAPABILITIES_FILE = os.path.join(THIS_FOLDER, 'capabilities.json') + +capabilities = {} +if os.path.exists(CAPABILITIES_FILE): + with open(CAPABILITIES_FILE) as f: + capabilities = json.load(f) + +__all__ = [] +# Import all subclasses into the global namespace: +for model_name in capabilities: + class_name = model_name + path = f'labscript_devices.BS_Series.models.{model_name}.{class_name}' + globals()[class_name] = import_class_by_fullname(path) + __all__.append(class_name) \ No newline at end of file diff --git a/labscript_devices/BS_Series/models/capabilities.json b/labscript_devices/BS_Series/models/capabilities.json new file mode 100644 index 00000000..12807d63 --- /dev/null +++ b/labscript_devices/BS_Series/models/capabilities.json @@ -0,0 +1,26 @@ +{ + "BS_341A_spec": { + "static_AO": 0, + "num_AO": 8, + "baud_rate": 9600, + "supports_custom_voltages_per_channel": true, + "AO_ranges": [ + {"channel": 1, "voltage_range": [-24.0, 24.0]}, + {"channel": 2, "voltage_range": [-34.565, 34.565]}, + {"channel": 3, "voltage_range": [-34.565, 34.565]}, + {"channel": 4, "voltage_range": [-34.565, 34.565]}, + {"channel": 5, "voltage_range": [-34.565, 34.565]}, + {"channel": 6, "voltage_range": [-34.565, 34.565]}, + {"channel": 7, "voltage_range": [-34.565, 34.565]}, + {"channel": 8, "voltage_range": [-34.565, 34.565]} + ], + "default_voltage_range": [-24, 24] + }, + "BS_341A": { + "static_AO": 0, + "num_AO": 8, + "baud_rate": 9600, + "supports_custom_voltages_per_channel": false, + "default_voltage_range": [-34, 34] + } +} \ No newline at end of file diff --git a/labscript_devices/BS_Series/register_classes.py b/labscript_devices/BS_Series/register_classes.py index af36d8f6..52fc15be 100644 --- a/labscript_devices/BS_Series/register_classes.py +++ b/labscript_devices/BS_Series/register_classes.py @@ -1,7 +1,30 @@ from labscript_devices import register_classes +import json +import os +from labscript_devices.BS_Series.logger_config import logger + +THIS_FOLDER = os.path.dirname(os.path.abspath(__file__)) +CAPABILITIES_FILE = os.path.join(THIS_FOLDER, 'models', 'capabilities.json') + +capabilities = {} +if os.path.exists(CAPABILITIES_FILE): + with open(CAPABILITIES_FILE) as f: + capabilities = json.load(f) register_classes( - "BS_341A", + "BS_", BLACS_tab='labscript_devices.BS_Series.BLACS_tabs.BS_Tab', runviewer_parser=None, -) \ No newline at end of file +) + +for model_name in capabilities: + logger.debug(f"Registering model: {model_name}") + + try: + register_classes( + model_name, + BLACS_tab='labscript_devices.BS_Series.BLACS_tabs.BS_Tab', + runviewer_parser=None, + ) + except Exception as e: + logger.error(f"Error registering {model_name}: {e}") \ No newline at end of file diff --git a/labscript_devices/BS_Series/voltage_source.py b/labscript_devices/BS_Series/voltage_source.py index 858ffe58..b9befbe9 100644 --- a/labscript_devices/BS_Series/voltage_source.py +++ b/labscript_devices/BS_Series/voltage_source.py @@ -9,12 +9,18 @@ class VoltageSource: def __init__(self, port, baud_rate, + supports_custom_voltages_per_channel, + default_voltage_range, + AO_ranges, verbose=False ): logger.debug(f"") self.verbose = verbose self.port = port self.baud_rate = baud_rate + self.supports_custom_voltages_per_channel = supports_custom_voltages_per_channel + self.default_voltage_range = default_voltage_range + self.AO_ranges = AO_ranges # connecting to connectionice self.connection = serial.Serial(self.port, self.baud_rate, timeout=0.05) @@ -57,7 +63,10 @@ def set_voltage(self, channel_num, value): """ try: channel = f"CH{int(channel_num):02d}" - voltage_range = float(self.device_voltage_range) if channel == 'CH01' else 34.560 # dirty workaround + if self.supports_custom_voltages_per_channel: + voltage_range = float(self.AO_ranges[channel_num - 1]['voltage_range'][1]) + else: + voltage_range = float(self.default_voltage_range[1]) scaled_voltage = self._scale_to_normalized(float(value), float(voltage_range)) send_str = f"{self.device_serial} {channel} {scaled_voltage:.5f}\r" From 2ffea077df8ef554b42300f831242ee2d2e3fc28 Mon Sep 17 00:00:00 2001 From: OljaKhor Date: Thu, 12 Jun 2025 12:32:50 +0200 Subject: [PATCH 11/13] enhance readme --- labscript_devices/BS_Series/BS_341A.md | 102 ++++++++++++++++++++----- 1 file changed, 81 insertions(+), 21 deletions(-) diff --git a/labscript_devices/BS_Series/BS_341A.md b/labscript_devices/BS_Series/BS_341A.md index 9da2e106..b238f9e2 100644 --- a/labscript_devices/BS_Series/BS_341A.md +++ b/labscript_devices/BS_Series/BS_341A.md @@ -1,21 +1,29 @@ -# Precision multichannel voltage source for Spectroscopy. +# BS Series Precision Voltage Source (BS 34-1A) +It adds initial support for the **Stahl Electronics BS 34-1A** multichannel voltage source +to the labscript suite. -[Manual](https://www.manualslib.com/manual/1288197/Stahl-Electronics-Bs-Series.html?page=28#manual) - -Interface: USB -- standard setting: 9600 baud +Commands are sent via a standard serial interface with standard baud rate = 9600. --- -## Remote commands -IDN | Identify +## Timing Strategy +Since the BS 34-1A has **no internal clock** and **no buffering**, +time-sensitive operations (e.g., updating voltages across a sequence) are +implemented using `time.sleep(t)` in a **background thread**, opened in `transition_to_buffered`. +After the whole sequence is done, the thread is closed in `transition_to_manual`. -DDDDD CHXX Y.YYYYY | Set voltage +While this is a naive and dirty approach, it currently works to avoid blocking +the main thread. +However, we acknowledge that this is not ideal, and we welcome proposals for +a cleaner, event-driven timing model. -DDDDD TEMP | Read Temperature +--- -DDDDD LOCK | Check lock status of all channels +## GUI small extension on button +A "Send to Device" button that: -DDDDD DIS [message] | Send string to LCD-display + * Collects all entered voltages + * Queues them into the worker process + * Sends commands serially to the device --- @@ -30,21 +38,73 @@ it were the real device. To launch the emulator: ```bash -python3 -m testing/BS_Series.emulateSerPort +python3 -m BS_Series.testing.emulateSerPort ``` You’ll see output like: For BS 34-1A use: /dev/pts/5 -Use that port (e.g., /dev/pts/5) when connecting in `connection_table.py`. +Use that port when connecting in `connection_table.py`. + +--- + +## Current implementation +The current implementation consists the standard implementation and tailored implementation +to the specific BS 34-1A unit we own, which has the following configuration: -## ⚠️ Different Voltage Range on different channels (Hardware Defect) +| Channel | Voltage Range | +| ------- |---------------| +| CH1 | ±24 V | +| CH2–CH8 | ±34.565 V | -The real BS 34-1A we own has **non-uniform voltage ranges**: +We are modeling this modular support similar to how `NI_DAQmx` handles multiple device types. -- **Channel 1** supports ±24 V -- **Channels 2–8** support ~ ±34.560 V +--- +## Connection table + +```python +from labscript import start, stop, add_time_marker, AnalogOut, DigitalOut +from labscript_devices.DummyPseudoclock.labscript_devices import DummyPseudoclock +from labscript_devices.BS_Series.models.BS_341A_spec import BS_341A_spec +from labscript_devices.BS_Series.models.BS_341A import BS_341A + +DummyPseudoclock(name='pseudoclock') +BS_341A_spec(name='voltage_source_for_ST', parent_device=pseudoclock.clockline, port='/dev/ttyUSB0', baud_rate=9600, num_AO=8) +BS_341A(name='voltage_source', parent_device=pseudoclock.clockline, port='/dev/pts/2', baud_rate=9600) + +AnalogOut(name='ao_BS_1', parent_device=voltage_source_for_ST ,connection='ao 1') +AnalogOut(name='ao_BS_2', parent_device=voltage_source_for_ST ,connection='ao 2') +AnalogOut(name='ao_BS_3', parent_device=voltage_source_for_ST ,connection='ao 3') +AnalogOut(name='ao_BS_4', parent_device=voltage_source_for_ST ,connection='ao 4') +AnalogOut(name='ao_BS_5', parent_device=voltage_source_for_ST ,connection='ao 5') +AnalogOut(name='ao_BS_6', parent_device=voltage_source_for_ST ,connection='ao 6') +AnalogOut(name='ao_BS_7', parent_device=voltage_source_for_ST ,connection='ao 7') + +AnalogOut(name='ao_BS_11', parent_device=voltage_source ,connection='ao 1', default_value=20) +AnalogOut(name='ao_BS_22', parent_device=voltage_source ,connection='ao 2') +AnalogOut(name='ao_BS_33', parent_device=voltage_source ,connection='ao 3') +AnalogOut(name='ao_BS_44', parent_device=voltage_source ,connection='ao 4') +``` +--- -Labscript normalizes voltage values using a fixed `voltage_range` -parameter for the entire device. So, we add a _dirty_ workaround -in `voltage_source.set_voltage()` function. This workaround ensures -all normalized values remain safe for the physical hardware. +## Extensions + +This is the first device in the BS series we've integrated. +Others in the same family are expected to have similar interfaces but may differ in: + +* Voltage ranges per channel +* Number of channels +* Supported commands +* etc. + +The plan is to extend current BS_ implementation by using subclassing and +extending the configurations in [capabilities.json](labscript_devices/BS_Series/models/capabilities.json). +In details: + +To add a new device, the user should: +- Define the new device model in the capabilities.json file. +- Create a corresponding .py file (by copying an existing model as a template). +- Adjust the class and the model names in .py file to match the new entry in the JSON file. + +Note: The class name must exactly match the model name specified in capabilities.json. + +--- From 693804a683d8d520391fe2e80f2e16513b12c2bb Mon Sep 17 00:00:00 2001 From: OljaKhor Date: Fri, 13 Jun 2025 14:58:36 +0200 Subject: [PATCH 12/13] enhance timing in buffered mode --- labscript_devices/BS_Series/BLACS_workers.py | 26 +++++++++---------- labscript_devices/BS_Series/BS_341A.md | 8 +++--- labscript_devices/BS_Series/voltage_source.py | 2 +- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/labscript_devices/BS_Series/BLACS_workers.py b/labscript_devices/BS_Series/BLACS_workers.py index 55aa9f10..a511e084 100644 --- a/labscript_devices/BS_Series/BLACS_workers.py +++ b/labscript_devices/BS_Series/BLACS_workers.py @@ -33,7 +33,6 @@ def init(self): def shutdown(self): - # Should be done when Blacs is closed self.connection.close() def program_manual(self, front_panel_values): @@ -103,13 +102,15 @@ def transition_to_buffered(self, device_name, h5_file, initial_values, fresh): def _run_experiment_sequence(self, events): try: - if self._stop_event.is_set(): - return - + start_time = time.time() for t, voltages in events: - time.sleep(t) + now = time.time() + wait_time = t - (now - start_time) + if wait_time > 0: + time.sleep(wait_time) + print(f"[Time: {datetime.now()}] \n") for conn_name, voltage in voltages.items(): - channel_num = _get_channel_num(conn_name) # 'ao 1' --> 1 + channel_num = _get_channel_num(conn_name) self.voltage_source.set_voltage(channel_num, voltage) self.final_values[channel_num] = voltage if self.verbose: @@ -178,17 +179,14 @@ def _append_front_panel_values_to_manual(self, front_panel_values, current_time) It assumes that `self.h5file` and `self.device_name` have been set (in `transition_to_buffered`). If not, a RuntimeError is raised. - Parameters - ---------- - front_panel_values : dict + Args: + front_panel_values (dict): Dictionary mapping channel names (e.g., 'CH01') to voltage values (float). - current_time : str + current_time (str): The timestamp (formatted as a string) when the values were recorded - Raises - ------ - RuntimeError - If `self.h5file` is not set (i.e., manual values are being saved before + Raises: + RuntimeError: If `self.h5file` is not set (i.e., manual values are being saved before the system is in buffered mode). """ # Check if h5file is set (transition_to_buffered must be called first) diff --git a/labscript_devices/BS_Series/BS_341A.md b/labscript_devices/BS_Series/BS_341A.md index b238f9e2..31d2e1b6 100644 --- a/labscript_devices/BS_Series/BS_341A.md +++ b/labscript_devices/BS_Series/BS_341A.md @@ -6,12 +6,12 @@ Commands are sent via a standard serial interface with standard baud rate = 9600 --- ## Timing Strategy -Since the BS 34-1A has **no internal clock** and **no buffering**, +Since the BS 34-1A has no internal clock and no buffering, time-sensitive operations (e.g., updating voltages across a sequence) are -implemented using `time.sleep(t)` in a **background thread**, opened in `transition_to_buffered`. +implemented using `time.sleep(t)` in a background thread, opened in `transition_to_buffered`. After the whole sequence is done, the thread is closed in `transition_to_manual`. -While this is a naive and dirty approach, it currently works to avoid blocking +While this is a naive approach, it currently works to avoid blocking the main thread. However, we acknowledge that this is not ideal, and we welcome proposals for a cleaner, event-driven timing model. @@ -97,7 +97,7 @@ Others in the same family are expected to have similar interfaces but may differ * etc. The plan is to extend current BS_ implementation by using subclassing and -extending the configurations in [capabilities.json](labscript_devices/BS_Series/models/capabilities.json). +extending the configurations in [capabilities.json](models/capabilities.json). In details: To add a new device, the user should: diff --git a/labscript_devices/BS_Series/voltage_source.py b/labscript_devices/BS_Series/voltage_source.py index b9befbe9..f42264e5 100644 --- a/labscript_devices/BS_Series/voltage_source.py +++ b/labscript_devices/BS_Series/voltage_source.py @@ -23,7 +23,7 @@ def __init__(self, self.AO_ranges = AO_ranges # connecting to connectionice - self.connection = serial.Serial(self.port, self.baud_rate, timeout=0.05) + self.connection = serial.Serial(self.port, self.baud_rate, timeout=0.04) device_info = self.identify_query() self.device_serial = device_info[0] # For example, 'HV023' self.device_voltage_range = device_info[1] # For example, '50' From 0b94b23f7fbfe854bc875c9a2aaa47c107b83e6c Mon Sep 17 00:00:00 2001 From: OljaKhor Date: Thu, 26 Jun 2025 11:21:09 +0200 Subject: [PATCH 13/13] fix serial read delay --- labscript_devices/BS_Series/voltage_source.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/labscript_devices/BS_Series/voltage_source.py b/labscript_devices/BS_Series/voltage_source.py index f42264e5..3d9596df 100644 --- a/labscript_devices/BS_Series/voltage_source.py +++ b/labscript_devices/BS_Series/voltage_source.py @@ -23,7 +23,7 @@ def __init__(self, self.AO_ranges = AO_ranges # connecting to connectionice - self.connection = serial.Serial(self.port, self.baud_rate, timeout=0.04) + self.connection = serial.Serial(self.port, self.baud_rate, timeout=1) device_info = self.identify_query() self.device_serial = device_info[0] # For example, 'HV023' self.device_voltage_range = device_info[1] # For example, '50' @@ -38,7 +38,7 @@ def identify_query(self): LabscriptError: If identity format is incorrect. """ self.connection.write("IDN\r".encode()) - raw_response = self.connection.readline().decode() + raw_response = self.connection.read_until(b'\r').decode() identity = raw_response.split() if len(identity) == 4: @@ -71,7 +71,7 @@ def set_voltage(self, channel_num, value): send_str = f"{self.device_serial} {channel} {scaled_voltage:.5f}\r" self.connection.write(send_str.encode()) - response = self.connection.readline().decode().strip() #'CHXX Y.YYYYY' + response = self.connection.read_until(b'\r').decode().strip() #'CHXX Y.YYYYY' logger.debug(f"Sent to BS-34: {send_str!r} with {value} | Received: {response!r}") expected_response = f"{channel} {scaled_voltage:.5f}" @@ -97,7 +97,7 @@ def read_temperature(self): send_str = f"{self.device_serial} TEMP\r" self.connection.write(send_str.encode()) - response = self.connection.readline().decode().strip() #'TEMP XXX.X°C' + response = self.connection.read_until(b'\r').decode().strip() #'TEMP XXX.X°C' if response.endswith("°C"): try: @@ -129,7 +129,7 @@ def voltage_query(self, channel_num): send_str = f"{self.device_serial} U{channel}\r" # 'DDDDD UXX' self.connection.write(send_str.encode()) - response = self.connection.readline().decode().strip() # '+/-yy,yyy V' + response = self.connection.read_until(b'\r').decode().strip() # '+/-yy,yyy V' if response.endswith("V"): try: @@ -160,7 +160,7 @@ def current_query(self, channel_num): send_str = f"{self.device_serial} I{channel}\r" self.connection.write(send_str.encode()) - response = self.connection.readline().decode().strip() # '+/-yy,yyy mA' + response = self.connection.read_until(b'\r').decode().strip() # '+/-yy,yyy mA' if response.endswith("mA"): try: @@ -192,7 +192,7 @@ def vol_curr_query(self, channel_num): send_str = f"{self.device_serial} Q{channel}\r" # 'DDDDD QXX' self.connection.write(send_str.encode()) - response = self.connection.readline().decode().strip() # '+/-yy,yyy V +/-z,zzz mA' + response = self.connection.read_until(b'\r').decode().strip() # '+/-yy,yyy V +/-z,zzz mA' if response.endswith("mA"): try: