diff --git a/.gitignore b/.gitignore index 9ecaa2e7..d8f9840a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,6 @@ build/ *.egg-info/ # Sphinx documentation -docs/hmtl/ -docs/latexpdf/ \ No newline at end of file +docs/html/ +docs/latexpdf/ +*.stats diff --git a/README.md b/README.md index 70a564f1..fb43f58e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ [![PyPi](https://img.shields.io/pypi/v/autolab)](https://pypi.org/project/autolab/) +![PyPI - Python Version](https://img.shields.io/pypi/pyversions/autolab) +[![PyPI Downloads](https://img.shields.io/pypi/dm/autolab.svg?label=PyPI%20downloads)](https://pypi.org/project/autolab/) + [![Documentation Status](https://readthedocs.org/projects/autolab/badge/?version=latest)](https://autolab.readthedocs.io/en/latest/?badge=latest) # Autolab @@ -17,10 +20,10 @@ Visit https://autolab.readthedocs.io/ for the full documentation of this package ## Overview -![Autolab scheme](docs/scheme.png) + ## GUI example -![Autolab Scanning GUI](docs/gui/scanning.png) + -![Autolab Control Panel GUI](docs/gui/control_panel.png) + diff --git a/autolab/__init__.py b/autolab/__init__.py index 88835ea5..c911d004 100644 --- a/autolab/__init__.py +++ b/autolab/__init__.py @@ -17,8 +17,8 @@ import socket # OPTIMIZE: temporary fix to an infinite loading on some computer # Load current version in version file -from .core import paths as _paths -with open(_paths.VERSION) as version_file: +from .core.paths import PATHS, DRIVER_SOURCES +with open(PATHS['version']) as version_file: __version__ = version_file.read().strip() del version_file @@ -36,11 +36,21 @@ _config.add_extra_driver_path() _config.add_extra_driver_repo_url() +# Add drivers folder to sys (allows a driver to import another driver) +import sys +# Order of append between local and official matter for priority +for folder in reversed(list(DRIVER_SOURCES.values())): + sys.path.append(folder) +del sys +del folder + # infos -from .core.infos import list_devices, list_drivers, infos, config_help +from .core.infos import infos, config_help +from .core.infos import _list_devices as list_devices +from .core.infos import _list_drivers as list_drivers # Devices -from .core.devices import get_device, close +from .core.devices import get_device, close, list_loaded_devices from .core import devices as _devices # Drivers @@ -54,7 +64,11 @@ from .core.server import Server as server # GUI -from .core.gui import gui, plotter, monitor, slider, add_device, about, variables_menu +from .core.gui import (gui, plotter, monitor, slider, add_device, about, + variables_menu, preferences, driver_installer) + +from .core.variables import get_variable, list_variables +from .core.variables import set_variable as add_variable # Repository from .core.repository import install_drivers diff --git a/autolab/autolab.pdf b/autolab/autolab.pdf index d966177b..61fe6706 100644 Binary files a/autolab/autolab.pdf and b/autolab/autolab.pdf differ diff --git a/autolab/core/_create_shortcut.py b/autolab/core/_create_shortcut.py index 80566587..189c3884 100644 --- a/autolab/core/_create_shortcut.py +++ b/autolab/core/_create_shortcut.py @@ -9,7 +9,6 @@ import os import sys -from .gui.icons import icons from .utilities import input_wrap @@ -17,7 +16,9 @@ def create_shortcut(ask: bool = False): """ Create Autolab GUI shortcut on desktop. """ # Works on Windows with winpython and with base or env conda try: - autolab_icon = icons['autolab'] + autolab_icon = os.path.join( + os.path.dirname(__file__), 'gui', + 'icons', 'autolab-icon.ico').replace("\\", "/") userprofile = os.path.expanduser('~') desktop = os.path.join(userprofile, 'Desktop') link = os.path.join(desktop, 'Autolab GUI.lnk') @@ -25,7 +26,7 @@ def create_shortcut(ask: bool = False): python_env = sys.prefix if not os.path.exists(desktop): - return + return None is_conda = os.path.exists(os.path.join(sys.prefix, 'conda-meta')) @@ -39,7 +40,7 @@ def create_shortcut(ask: bool = False): python_script = os.path.normpath(os.path.join(python, r'Scripts/activate.bat')) if not os.path.exists(python_script): - return + return None from comtypes.client import CreateObject from comtypes.persist import IPersistFile @@ -51,7 +52,7 @@ def create_shortcut(ask: bool = False): ans = 'yes' if ans.strip().lower() == 'no': - return + return None s = CreateObject(ShellLink) s.SetPath('cmd.exe') @@ -63,5 +64,8 @@ def create_shortcut(ask: bool = False): p = s.QueryInterface(IPersistFile) p.Save(link, True) + if not os.path.exists(os.path.join(python_env, r'Scripts/autolab.exe')): + print('Warning autolab has not been installed with pip, the shortcut will not work') + except Exception as e: print(f'Cannot create Autolab shortcut: {e}') diff --git a/autolab/core/config.py b/autolab/core/config.py index bc453cee..2278a264 100644 --- a/autolab/core/config.py +++ b/autolab/core/config.py @@ -9,13 +9,14 @@ import tempfile import configparser from typing import List -from . import paths -from . import utilities +from .paths import PATHS, DRIVER_SOURCES, DRIVER_REPOSITORY +from .utilities import boolean -# ============================================================================== + +# ============================================================================= # GENERAL -# ============================================================================== +# ============================================================================= def initialize_local_directory() -> bool: """ This function creates the default autolab local directory. @@ -23,9 +24,9 @@ def initialize_local_directory() -> bool: FIRST = False _print = True # LOCAL DIRECTORY - if not os.path.exists(paths.USER_FOLDER): - os.mkdir(paths.USER_FOLDER) - print(f'The local directory of AUTOLAB has been created: {paths.USER_FOLDER}.\n'\ + if not os.path.exists(PATHS['user_folder']): + os.mkdir(PATHS['user_folder']) + print(f"The local directory of AUTOLAB has been created: {PATHS['user_folder']}.\n"\ 'It contains the configuration files devices_config.ini, autolab_config.ini ' \ 'and plotter.ini.\n' \ "It also contains the 'driver' directory with 'official' and 'local' sub-directories." @@ -34,53 +35,67 @@ def initialize_local_directory() -> bool: FIRST = True # DEVICES CONFIGURATION FILE - if not os.path.exists(paths.DEVICES_CONFIG): + if not os.path.exists(PATHS['devices_config']): devices_config = configparser.ConfigParser() devices_config['system'] = {'driver': 'system', 'connection': 'DEFAULT'} devices_config['dummy'] = {'driver': 'dummy', 'connection': 'CONN'} devices_config['plotter'] = {'driver': 'plotter', 'connection': 'DEFAULT'} - save_config('devices', devices_config) - if _print: print(f'The devices configuration file devices_config.ini has been created: {paths.DEVICES_CONFIG}') + save_config('devices_config', devices_config) + if _print: print(f"The devices configuration file devices_config.ini has been created: {PATHS['devices_config']}") # DRIVER FOLDERS - if not os.path.exists(paths.DRIVERS): - os.mkdir(paths.DRIVERS) - if _print: print(f"The drivers directory has been created: {paths.DRIVERS}") - if not os.path.exists(paths.DRIVER_SOURCES['official']): - os.mkdir(paths.DRIVER_SOURCES['official']) - if _print: print(f'The official driver directory has been created: {paths.DRIVER_SOURCES["official"]}') - if not os.path.exists(paths.DRIVER_SOURCES['local']): - os.mkdir(paths.DRIVER_SOURCES['local']) - if _print: print(f'The local driver directory has been created: {paths.DRIVER_SOURCES["local"]}') + if not os.path.exists(PATHS['drivers']): + os.mkdir(PATHS['drivers']) + if _print: print(f"The drivers directory has been created: {PATHS['drivers']}") + if not os.path.exists(DRIVER_SOURCES['official']): + os.mkdir(DRIVER_SOURCES['official']) + if _print: print(f'The official driver directory has been created: {DRIVER_SOURCES["official"]}') + if not os.path.exists(DRIVER_SOURCES['local']): + os.mkdir(DRIVER_SOURCES['local']) + if _print: print(f'The local driver directory has been created: {DRIVER_SOURCES["local"]}') # AUTOLAB CONFIGURATION FILE - if not os.path.exists(paths.AUTOLAB_CONFIG): - save_config('autolab', configparser.ConfigParser()) - if _print: print(f'The configuration file autolab_config.ini has been created: {paths.AUTOLAB_CONFIG}') + if not os.path.exists(PATHS['autolab_config']): + save_config('autolab_config', configparser.ConfigParser()) + if _print: print(f"The configuration file autolab_config.ini has been created: {PATHS['autolab_config']}") # PLOTTER CONFIGURATION FILE - if not os.path.exists(paths.PLOTTER_CONFIG): - save_config('plotter', configparser.ConfigParser()) - if _print: print(f'The configuration file plotter_config.ini has been created: {paths.PLOTTER_CONFIG}') + if not os.path.exists(PATHS['plotter_config']): + save_config('plotter_config', configparser.ConfigParser()) + if _print: print(f"The configuration file plotter_config.ini has been created: {PATHS['plotter_config']}") return FIRST -def save_config(config_name, config): +def save_config(config_name: str, config: configparser.ConfigParser): """ This function saves the given config parser in the autolab configuration file """ - with open(getattr(paths, f'{config_name.upper()}_CONFIG'), 'w') as file: + with open(PATHS[config_name], 'w') as file: config.write(file) -def load_config(config_name) -> configparser.ConfigParser: +def load_config(config_name: str) -> configparser.ConfigParser: """ This function loads the autolab configuration file in a config parser """ config = configparser.ConfigParser(allow_no_value=True, delimiters='=') # don't want ':' as delim, needed for path as key config.optionxform = str try: # encoding order matter - config.read(getattr(paths, f'{config_name.upper()}_CONFIG'), + config.read(PATHS[config_name], encoding='utf-8') except: - config.read(getattr(paths, f'{config_name.upper()}_CONFIG')) + config.read(PATHS[config_name]) + + return config + + +def modify_config(config_name: str, config_dict: dict) -> configparser.ConfigParser: + """ Returns a modified config file structures using the input dict """ + config = load_config(config_name) + + for section_key, section_dic in config_dict.items(): + conf = {} + for key, dic in section_dic.items(): + conf[key] = str(dic) + + config[section_key] = conf return config @@ -108,70 +123,102 @@ def load_config(config_name) -> configparser.ConfigParser: # return get_config_section(config,section_name) -# ============================================================================== +# ============================================================================= # AUTOLAB CONFIG -# ============================================================================== +# ============================================================================= + +autolab_dict = { + 'server': {'port': 4001}, + 'GUI': {'qt_api': "default", + 'theme': "default", + 'font_size': 10, + 'image_background': 'w', + 'image_foreground': 'k', + }, + 'control_center': {'precision': 7, + 'print': True, + 'logger': False, + 'console': False, + }, + 'monitor': {'precision': 4, + 'save_figure': True}, + 'scanner': {'precision': 15, + 'save_config': True, + 'save_figure': True, + 'save_temp': True, + 'ask_close': True, + }, + 'directories': {'temp_folder': 'default'}, + 'extra_driver_path': {}, + 'extra_driver_url_repo': {}, +} + + +def change_autolab_config(config: configparser.ConfigParser): + """ Save the autolab config file structures with comments """ + config.set('GUI', '# qt_api -> Choose between default, pyqt5, pyside2, pyqt6 and pyside6') + config.set('GUI', '# theme -> Choose between default and dark') + config.set('scanner', '# Think twice before using save_temp = False') + config.set('extra_driver_path', r'# Example: onedrive = C:\Users\username\OneDrive\my_drivers') + config.set('extra_driver_url_repo', r'# Example: C:\Users\username\OneDrive\my_drivers = https://github.com/my_repo/my_drivers') + + if not boolean(config['scanner']["save_temp"]): + print('Warning: save_temp in "autolab_config.ini" is disabled, ' \ + 'be aware that data will not be saved during the scan. ' \ + 'If a crash occurs during a scan, you will loose its data. ' \ + 'Disabling this option is only useful if you want to do fast ' \ + 'scan when plotting large dataframe (images for examples)') + + save_config('autolab_config', config) + def check_autolab_config(): - """ This function checks config file structures """ - autolab_config = load_config('autolab') - - autolab_dict = { - 'server': {'port': 4001}, - 'GUI': {'QT_API': "default", - 'font_size': "default", - 'image_background': 'w', - 'image_foreground': 'k'}, - 'control_center': {'precision': 7, - 'print': True, - 'logger': False, - 'console': False}, - 'monitor': {'precision': 4, - 'save_figure': True}, - 'scanner': {'precision': 15, - 'save_config': True, - 'save_figure': True, - 'save_temp': True}, - 'directories': {'temp_folder': 'default'}, - 'extra_driver_path': {}, - 'extra_driver_url_repo': {}, - # 'plotter': {'precision': 10}, - } + """ Changes the autolab config file structures """ + autolab_config = load_config('autolab_config') for section_key, section_dic in autolab_dict.items(): if section_key in autolab_config.sections(): conf = dict(autolab_config[section_key]) - for key, dic in section_dic.items(): + for key, value in section_dic.items(): if key not in conf: - conf[key] = str(dic) + conf[key] = str(value) else: conf = section_dic autolab_config[section_key] = conf - autolab_config.set('GUI', '# QT_API -> Choose between default, pyqt5, pyside2, pyqt6 and pyside6') + # added in 2.0 for retrocompatibilty with 1.1.12 + if 'QT_API' in autolab_config['GUI']: + value = autolab_config.get('GUI', 'QT_API') + autolab_config.remove_option('GUI', 'QT_API') + autolab_config.set('GUI', 'qt_api', value) - autolab_config.set('scanner', '# Think twice before using save_temp = False') - - if not utilities.boolean(autolab_config['scanner']["save_temp"]): - print('Warning: save_temp in "autolab_config.ini" is disabled, ' \ - 'be aware that data will not be saved during the scan. ' \ - 'If a crash occurs during a scan, you will loose its data. ' \ - 'Disabling this option is only useful if you want to do fast ' \ - 'scan when plotting large dataframe (images for examples)') - - autolab_config.set('extra_driver_path', r'# Example: onedrive = C:\Users\username\OneDrive\my_drivers') - - autolab_config.set('extra_driver_url_repo', r'# Example: C:\Users\username\OneDrive\my_drivers = https://github.com/my_repo/my_drivers') - - save_config('autolab', autolab_config) - - -def get_config(section_name) -> configparser.SectionProxy: + # Check and correct boolean, float and int + for section_key, section_dic in autolab_dict.items(): + for key, value in section_dic.items(): + try: + if isinstance(value, bool): + boolean(autolab_config[section_key][key]) + elif isinstance(value, float): + float(autolab_config[section_key][key]) + elif isinstance(value, int): + int(float(autolab_config[section_key][key])) + except: + autolab_config[section_key][key] = str(autolab_dict[section_key][key]) + print(f'Wrong {section_key} {key} in config, change to default value') + + # Check for specific values + if autolab_config['GUI']['theme'] not in ('default', 'dark'): + autolab_config['GUI']['theme'] = str(autolab_dict['GUI']['theme']) + print('Wrong GUI theme in config, change to default value') + + change_autolab_config(autolab_config) + + +def get_config(section_name: str) -> configparser.SectionProxy: ''' Returns section from autolab_config.ini ''' - config = load_config('autolab') + config = load_config('autolab_config') assert section_name in config.sections(), f'Missing {section_name} section in autolab_config.ini' - return config[section_name] @@ -181,9 +228,10 @@ def get_server_config() -> configparser.SectionProxy: def get_GUI_config() -> configparser.SectionProxy: - ''' Returns section QT_API from autolab_config.ini ''' + ''' Returns section qt_api from autolab_config.ini ''' return get_config('GUI') + def get_control_center_config() -> configparser.SectionProxy: ''' Returns section control_center from autolab_config.ini ''' return get_config('control_center') @@ -237,9 +285,9 @@ def add_extra_driver_path(): for driver_path_name in extra_driver_path.keys(): assert driver_path_name not in ['official', 'local'], ( "Can't change 'official' nor 'local' driver folder paths. " \ - f"Change name '{driver_path_name}' in section [extra_driver_path] of {paths.AUTOLAB_CONFIG}") + f"Change name '{driver_path_name}' in section [extra_driver_path] of {PATHS['autolab_config']}") - paths.DRIVER_SOURCES.update(extra_driver_path) + DRIVER_SOURCES.update(extra_driver_path) def add_extra_driver_repo_url(): @@ -248,46 +296,50 @@ def add_extra_driver_repo_url(): extra_driver_path = get_extra_driver_repo_url_config() for driver_path_name in extra_driver_path.keys(): - assert driver_path_name not in [paths.DRIVER_SOURCES['official']], ( + assert driver_path_name not in [DRIVER_SOURCES['official']], ( "Can't install driver in 'official' folder. " \ f"Change path '{driver_path_name}' in section [extra_driver_path]" \ - f" of {paths.AUTOLAB_CONFIG}") + f" of {PATHS['autolab_config']}") - paths.DRIVER_REPOSITORY.update( + DRIVER_REPOSITORY.update( {driver_path_name: extra_driver_path[driver_path_name]}) -# ============================================================================== +# ============================================================================= # PLOTTER CONFIG -# ============================================================================== +# ============================================================================= + +plotter_dict = { + 'plugin': {'plotter': 'plotter'}, + 'device': {'address': 'dummy.array_1D'}, +} + +def change_plotter_config(config: configparser.ConfigParser): + """ Save the plotter config file structures with comments """ + config.set('plugin', '# Usage: = ') + config.set('plugin', '# Example: plotter = plotter') + config.set('device', '# Usage: address = ') + config.set('device', '# Example: address = dummy.array_1D') + + save_config('plotter_config', config) + def check_plotter_config(): """ This function checks config file structures """ - plotter_config = load_config('plotter') - - plotter_dict = { - 'plugin': {'plotter': 'plotter'}, - 'device': {'address': 'dummy.array_1D'}, - } + plotter_config = load_config('plotter_config') for section_key, section_dic in plotter_dict.items(): if section_key in plotter_config.sections(): conf = dict(plotter_config[section_key]) - for key, dic in section_dic.items(): + for key, value in section_dic.items(): if key not in conf: - conf[key] = str(dic) + conf[key] = str(value) else: conf = section_dic plotter_config[section_key] = conf - plotter_config.set('plugin', '# Usage: = ') - plotter_config.set('plugin', '# Example: plotter = plotter') - - plotter_config.set('device', '# Usage: address = ') - plotter_config.set('device', '# Example: address = dummy.array_1D') - - save_config('plotter', plotter_config) + change_plotter_config(plotter_config) # ============================================================================= @@ -296,7 +348,7 @@ def check_plotter_config(): def get_all_devices_configs() -> configparser.ConfigParser: ''' Returns current devices configuration ''' - config = load_config('devices') + config = load_config('devices_config') assert len(set(config.sections())) == len(config.sections()), "Each device must have a unique name." return config diff --git a/autolab/core/devices.py b/autolab/core/devices.py index 41a64343..ccdfdb48 100644 --- a/autolab/core/devices.py +++ b/autolab/core/devices.py @@ -7,13 +7,15 @@ from typing import List, Union -from . import drivers -from . import config +from .drivers import get_driver_path, get_driver +from .config import list_all_devices_configs, get_device_config from .elements import Module, Element # Storage of the devices DEVICES = {} +# After DEVICES to avoid circular import +from .variables import update_allowed_dict # ============================================================================= # DEVICE CLASS @@ -25,7 +27,7 @@ def __init__(self, device_name: str, instance, device_config: dict): """ device_config is returned by :meth:`get_final_device_config` """ self.device_config = device_config # hidden from completion - self.driver_path = drivers.get_driver_path(device_config["driver"]) + self.driver_path = get_driver_path(device_config["driver"]) super().__init__(None, {'name': device_name, 'object': instance, 'help': f'Device {device_name} at {self.driver_path}'}) @@ -41,12 +43,15 @@ def close(self): if struc[1] == 'variable': element._read_signal = None element._write_signal = None + if struc[1] == 'action': + element._write_signal = None except: pass try: self.instance.close() except: pass del DEVICES[self.name] + update_allowed_dict() def __dir__(self): """ For auto-completion """ @@ -58,29 +63,32 @@ def __dir__(self): # DEVICE GET FUNCTION # ============================================================================= -def get_element_by_address(address: str) -> Union[Element, None]: - """ Returns the Element located at the provided address if exists """ - address = address.split('.') - try: - device_name = address[0] - if device_name in DEVICES: - element = DEVICES[device_name] +def get_element_by_address(address: str) -> Element: + """ Returns the Element located at the provided address """ + address_list = address.split('.') + device_name = address_list[0] + if device_name in DEVICES: + element = DEVICES[device_name] + else: + # This should not be used on autolab closing to avoid access violation due to config opening + element = get_device(device_name) + + for i, address_part in enumerate(address_list[1: ]): + address_part = address_part.replace(' ', '') + if hasattr(element, address_part): + element = getattr(element, address_part) else: - # This should not be used on autolab closing to avoid access violation due to config opening - element = get_device(device_name) - for addressPart in address[1: ]: - element = getattr(element, addressPart.replace(" ", "")) - return element - except: - return None + raise AttributeError( + f"'{address_part}' not found in '{'.'.join(address_list[: i+1])}'.") + return element def get_final_device_config(device_name: str, **kwargs) -> dict: ''' Returns a valid device config from configuration file overwritten by kwargs ''' - assert device_name in config.list_all_devices_configs(), f"Device name {device_name} not found in devices_config.ini" + assert device_name in list_all_devices_configs(), f"Device name '{device_name}' not found in devices_config.ini" # Load config object - device_config = dict(config.get_device_config(device_name)) + device_config = dict(get_device_config(device_name)) # Overwrite config with provided configuration in kwargs for key, value in kwargs.items(): @@ -105,11 +113,12 @@ def get_device(device_name: str, **kwargs) -> Device: assert device_config == DEVICES[device_name].device_config, 'You cannot change the configuration of an existing Device. Close it first & retry, or remove the provided configuration.' else: - instance = drivers.get_driver( + instance = get_driver( device_config['driver'], device_config['connection'], **{k: v for k, v in device_config.items() if k not in [ 'driver', 'connection']}) DEVICES[device_name] = Device(device_name, instance, device_config) + update_allowed_dict() return DEVICES[device_name] @@ -119,13 +128,13 @@ def get_device(device_name: str, **kwargs) -> Device: # ============================================================================= def list_loaded_devices() -> List[str]: - ''' Returns the list of the loaded devices ''' + ''' Returns the list of loaded device names ''' return list(DEVICES) def list_devices() -> List[str]: - ''' Returns the list of all configured devices ''' - return config.list_all_devices_configs() + ''' Returns the list of all configured device names ''' + return list_all_devices_configs() def get_devices_status() -> dict: @@ -149,19 +158,20 @@ def close(device: Union[str, Device] = "all"): try: DEVICES[device_name].close() except Exception: - print(f"Warning: device \"{device_name}\" has not been closed properly") + print(f"Warning: device '{device_name}' has not been closed properly") elif isinstance(device, Device): if device.name in DEVICES: device.close() else: - print(f"No device {device.name} in {list_loaded_devices()}") + print(f"No device '{device.name}' in {list_loaded_devices()}") elif isinstance(device, str): if device in DEVICES: DEVICES[device].close() else: - print(f"No device {device} in {list_loaded_devices()}") + print(f"No device '{device}' in {list_loaded_devices()}") else: - print(f"Warning, {device} is not a reconized device") + var_type = str(type(device)).split("'")[1] + print(f"Warning, {device} with type '{var_type}' is not a reconized device") diff --git a/autolab/core/drivers.py b/autolab/core/drivers.py index 351495d4..7bf4b777 100644 --- a/autolab/core/drivers.py +++ b/autolab/core/drivers.py @@ -8,10 +8,10 @@ import sys import inspect import importlib -from typing import Type, List +from typing import Type, List, Tuple from types import ModuleType -from . import paths, server +from .paths import PATHS, DRIVERS_PATHS, DRIVER_SOURCES # ============================================================================= @@ -21,11 +21,19 @@ def get_driver(driver_name: str, connection: str, **kwargs) -> Type: ''' Returns a driver instance using configuration provided in kwargs ''' if driver_name == 'autolab_server': - driver_instance = server.Driver_REMOTE(**kwargs) + from .server import Driver_REMOTE # avoid circular import + driver_instance = Driver_REMOTE(**kwargs) else: assert driver_name in list_drivers(), f"Driver {driver_name} not found in autolab's drivers" driver_lib = load_driver_lib(driver_name) - driver_instance = get_connection_class(driver_lib, connection)(**kwargs) + # Need to add the driver path to allow driver imports from its folder (and only his own, not other drivers) + driver_path = os.path.dirname(driver_lib.__file__) + if driver_path not in sys.path: + sys.path.append(driver_path) + try: + driver_instance = get_connection_class(driver_lib, connection)(**kwargs) + finally: + sys.path.remove(driver_path) return driver_instance @@ -69,7 +77,7 @@ def load_driver_utilities_lib(driver_utilities_name: str) -> ModuleType: if os.path.exists(driver_utilities_name): driver_path = get_driver_path(driver_utilities_name.replace('_utilities', '')) else: - driver_path = os.path.join(paths.AUTOLAB_FOLDER, 'core', 'default_driver.py') + driver_path = os.path.join(PATHS['autolab_folder'], 'core', 'default_driver.py') # Load library driver_lib = load_utilities_lib(driver_path) @@ -166,7 +174,7 @@ def get_connection_class(driver_lib: ModuleType, connection: str) -> Type: driver_instance = create_default_driver_conn(driver_lib, connection) if driver_instance is not None: - print(f'Warning, {connection} not find in {driver_lib.__name__} but will try to connect using default connection') + print(f'Warning, connection {connection} not find in driver {driver_lib.__name__} but will try to connect using default connection') return driver_instance assert connection in get_connection_names(driver_lib), f"Invalid connection type {connection} for driver {driver_lib.__name__}. Try using one of this connections: {get_connection_names(driver_lib)}" @@ -175,7 +183,7 @@ def get_connection_class(driver_lib: ModuleType, connection: str) -> Type: def create_default_driver_conn(driver_lib: ModuleType, connection: str) -> Type: """ Create a default connection class when not provided in Driver. Will be used to try to connect to an instrument. """ - Driver = getattr(driver_lib, f'Driver') + Driver = getattr(driver_lib, 'Driver') if connection == 'DEFAULT': class Driver_DEFAULT(Driver): @@ -186,7 +194,8 @@ def __init__(self): if connection == 'VISA': class Driver_VISA(Driver): - def __init__(self, address='GPIB0::2::INSTR', **kwargs): + def __init__(self, address: str = 'GPIB0::2::INSTR', + **kwargs): import pyvisa as visa self.TIMEOUT = 15000 # ms @@ -201,106 +210,129 @@ def close(self): try: self.controller.close() except: pass - def query(self, command): + def query(self, command: str) -> str: result = self.controller.query(command) result = result.strip('\n') return result - def write(self, command): + def write(self, command: str): self.controller.write(command) - def read(self): + def write_raw(self, command: bytes): + self.controller.write_raw(command) + + def read(self) -> str: return self.controller.read() + def read_raw(self, memory: int = 100000000) -> bytes: + return self.controller.read_raw(memory) + return Driver_VISA if connection == 'GPIB': class Driver_GPIB(Driver): - def __init__(self, address=23, board_index=0, **kwargs): + def __init__(self, address: int = 23, board_index: int = 0, + **kwargs): import Gpib - self.inst = Gpib.Gpib(int(board_index), int(address)) + self.controller = Gpib.Gpib(int(board_index), int(address)) Driver.__init__(self) - def query(self, query): - self.write(query) + def query(self, command: str) -> str: + self.write(command) return self.read() - def write(self, query): - self.inst.write(query) + def write(self, command: str): + self.controller.write(command) - def read(self, length=1000000000): - return self.inst.read().decode().strip('\n') + def read(self, length=1000000000) -> str: + return self.controller.read(length).decode().strip('\n') def close(self): """WARNING: GPIB closing is automatic at sys.exit() doing it twice results in a gpib error""" - #Gpib.gpib.close(self.inst.id) + #Gpib.gpib.close(self.controller.id) pass - return Driver_USB + return Driver_GPIB - if connection == 'USB': - class Driver_USB(Driver): - def __init__(self, **kwargs): - import usb - import usb.core - import usb.util + # if connection == 'USB': + # class Driver_USB(Driver): + # def __init__(self, **kwargs): + # import usb + # import usb.core + # import usb.util - dev = usb.core.find(idVendor=0x104d, idProduct=0x100a) - dev.reset() - dev.set_configuration() - interface = 0 - if dev.is_kernel_driver_active(interface) is True: - dev.detach_kernel_driver(interface) # tell the kernel to detach - usb.util.claim_interface(dev, interface) # claim the device + # dev = usb.core.find(idVendor=0x104d, idProduct=0x100a) + # dev.reset() + # dev.set_configuration() + # interface = 0 + # if dev.is_kernel_driver_active(interface) is True: + # dev.detach_kernel_driver(interface) # tell the kernel to detach + # usb.util.claim_interface(dev, interface) # claim the device - cfg = dev.get_active_configuration() - intf = cfg[(0,0)] - self.ep_out = usb.util.find_descriptor(intf, custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT) - self.ep_in = usb.util.find_descriptor(intf, custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN) + # cfg = dev.get_active_configuration() + # intf = cfg[(0,0)] + # self.ep_out = usb.util.find_descriptor(intf, custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT) + # self.ep_in = usb.util.find_descriptor(intf, custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN) - assert self.ep_out is not None - assert self.ep_in is not None + # assert self.ep_out is not None + # assert self.ep_in is not None - Driver.__init__(self) + # Driver.__init__(self) - def write(self, query): - self.string = query + '\r\n' - self.ep_out.write(self.string) + # def write(self, query): + # self.string = query + '\r\n' + # self.ep_out.write(self.string) - def read(self): - rep = self.ep_in.read(64) - const = ''.join(chr(i) for i in rep) - const = const[:const.find('\r\n')] - return const + # def read(self): + # rep = self.ep_in.read(64) + # const = ''.join(chr(i) for i in rep) + # const = const[:const.find('\r\n')] + # return const - return Driver_USB + # return Driver_USB if connection == 'SOCKET': class Driver_SOCKET(Driver): - def __init__(self, address='192.168.0.8', **kwargs): + BUFFER_SIZE: int = 40000 + + def __init__(self, address: str = '192.168.0.8', port: int = 5005, + **kwargs): import socket self.ADDRESS = address - self.PORT = 5005 - self.BUFFER_SIZE = 40000 + self.PORT = port self.controller = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + self.s.settimeout(5) + self.controller.connect((self.ADDRESS, int(self.PORT))) - Driver.__init__(self) + Driver.__init__(self, **kwargs) - def write(self, command): + def write(self, command: str): self.controller.send(command.encode()) self.controller.recv(self.BUFFER_SIZE) - def query(self, command): + def write_raw(self, command: bytes): + self.controller.send(command) + + def query(self, command: str) -> str: self.controller.send(command.encode()) data = self.controller.recv(self.BUFFER_SIZE) return data.decode() + def read(self, memory: int = BUFFER_SIZE) -> str: + rep = self.controller.recv(memory).decode() + return rep + + def read_raw(self, memory: int = BUFFER_SIZE) -> bytes: + rep = self.controller.recv(memory) + return rep + def close(self): try: self.controller.close() except: pass @@ -320,13 +352,15 @@ def __init__(self, *args, **kwargs): self.controller = Controller() self.controller.timeout = 5000 - def write(self, value): + def write(self, value: str): + pass + def write_raw(self, value: bytes): pass - def read(self): + def read(self) -> str: return '1' - def read_raw(self): + def read_raw(self) -> bytes: return b'1' - def query(self, value): + def query(self, value: str) -> str: self.write(value) return self.read() @@ -355,23 +389,24 @@ def explore_driver(instance: Type, _print: bool = True) -> str: return s -def get_instance_methods(instance: Type) -> Type: +def get_instance_methods(instance: Type) -> List[Tuple[str, Type]]: ''' Returns the list of all the methods (and their args) in that class ''' methods = [] # LEVEL 1 for name, _ in inspect.getmembers(instance, inspect.ismethod): - if name != '__init__': + if not name.startswith('_'): attr = getattr(instance, name) args = list(inspect.signature(attr).parameters) methods.append([name, args]) # LEVEL 2 for key, val in vars(instance).items(): + if key.startswith('_'): continue try: # explicit to avoid visa and inspect.getmembers issue for name, _ in inspect.getmembers(val, inspect.ismethod): - if name != '__init__': - attr = getattr(getattr(instance, key), name) + if not name.startswith('_'): + attr = getattr(val, name) args = list(inspect.signature(attr).parameters) methods.append([f'{key}.{name}', args]) except: pass @@ -404,7 +439,7 @@ def load_drivers_paths() -> dict: - value: path of the driver python script ''' drivers_paths = {} - for source_name, source_path in paths.DRIVER_SOURCES.items(): + for source_name, source_path in DRIVER_SOURCES.items(): if not os.path.isdir(source_path): print(f"Warning, can't found driver folder: {source_path}") continue @@ -425,5 +460,5 @@ def load_drivers_paths() -> dict: def update_drivers_paths(): ''' Update list of available driver ''' - global DRIVERS_PATHS - DRIVERS_PATHS = load_drivers_paths() + DRIVERS_PATHS.clear() + DRIVERS_PATHS.update(load_drivers_paths()) diff --git a/autolab/core/elements.py b/autolab/core/elements.py index b9973f25..eca4f69e 100644 --- a/autolab/core/elements.py +++ b/autolab/core/elements.py @@ -13,7 +13,7 @@ import numpy as np import pandas as pd -from . import paths +from .paths import PATHS from .utilities import emphasize, clean_string, SUPPORTED_EXTENSION @@ -44,6 +44,8 @@ def __init__(self, parent: Type, config: dict): assert 'type' in config, f"Variable {self.address()}: Missing variable type" assert config['type'] in [int, float, bool, str, bytes, tuple, np.ndarray, pd.DataFrame], f"Variable {self.address()} configuration: Variable type not supported in autolab" self.type = config['type'] + if self.type in [tuple]: + self.value = ([], -1) # Read and write function assert 'read' in config or 'write' in config, f"Variable {self.address()} configuration: no 'read' nor 'write' functions provided" @@ -51,7 +53,7 @@ def __init__(self, parent: Type, config: dict): # Read function self.read_function = None self.read_init = False - if config['type'] in [tuple]: assert 'read' in config, f"Variable {self.address()} configuration: Must provide a read function" + # if config['type'] in [tuple]: assert 'read' in config, f"Variable {self.address()} configuration: Must provide a read function" if 'read' in config: assert inspect.ismethod(config['read']), f"Variable {self.address()} configuration: Read parameter must be a function" self.read_function = config['read'] @@ -89,7 +91,7 @@ def __init__(self, parent: Type, config: dict): def save(self, path: str, value: Any = None): """ This function measure the variable and saves its value in the provided path """ - assert self.readable, f"The variable {self.name} is not configured to be measurable" + assert self.readable, f"The variable {self.address()} is not configured to be measurable" if os.path.isdir(path): path = os.path.join(path, self.address()+'.txt') @@ -110,7 +112,7 @@ def save(self, path: str, value: Any = None): elif self.type == pd.DataFrame: value.to_csv(path, index=False) else: - raise ValueError("The variable {self.name} of type {self.type} cannot be saved.") + raise ValueError("The variable {self.address()} of type {self.type} cannot be saved.") def help(self): """ This function prints informations for the user about the current variable """ @@ -118,7 +120,7 @@ def help(self): def __str__(self) -> str: """ This function returns informations for the user about the current variable """ - display = '\n' + emphasize(f'Variable {self.name}') + '\n' + display = '\n' + emphasize(f'Variable {self.address()}') + '\n' if self._help is not None: display += f'Help: {self._help}\n' display += '\n' @@ -142,20 +144,24 @@ def __call__(self, value: Any = None) -> Any: """ Measure or set the value of the variable """ # GET FUNCTION if value is None: - assert self.readable, f"The variable {self.name} is not readable" + assert self.readable, f"The variable {self.address()} is not readable" answer = self.read_function() if self._read_signal is not None: self._read_signal.emit_read(answer) + if self.type in [tuple]: # OPTIMIZE: could be generalized to any variable but fear could lead to memory issue + self.value = answer return answer # SET FUNCTION - assert self.writable, f"The variable {self.name} is not writable" + assert self.writable, f"The variable {self.address()} is not writable" - if isinstance(value, np.ndarray): + if isinstance(value, np.ndarray) or self.type in [np.ndarray]: value = np.array(value, ndmin=1) # ndim=1 to avoid having float if 0D else: value = self.type(value) + if self.type in [tuple]: # OPTIMIZE: could be generalized to any variable but fear could lead to memory issue + self.value = value self.write_function(value) - if self._write_signal is not None: self._write_signal.emit_write() + if self._write_signal is not None: self._write_signal.emit_write(value) return None @@ -187,13 +193,19 @@ def __init__(self, parent: Type, config: dict): self.has_parameter = self.type is not None + if self.type in [tuple]: + self.value = ([], -1) + + # Signals for GUI + self._write_signal = None + def help(self): """ This function prints informations for the user about the current variable """ print(self) def __str__(self) -> str: """ This function returns informations for the user about the current variable """ - display = '\n' + emphasize(f'Action {self.name}') + '\n' + display = '\n' + emphasize(f'Action {self.address()}') + '\n' if self._help is not None: display+=f'Help: {self._help}\n' display += '\n' @@ -211,15 +223,18 @@ def __str__(self) -> str: def __call__(self, value: Any = None) -> Any: """ Executes the action """ # DO FUNCTION - assert self.function is not None, f"The action {self.name} is not configured to be actionable" + assert self.function is not None, f"The action {self.address()} is not configured to be actionable" if self.has_parameter: if value is not None: - value = self.type(value) + if isinstance(value, np.ndarray): + value = np.array(value, ndmin=1) # ndim=1 to avoid having float if 0D + else: + value = self.type(value) self.function(value) elif self.unit in ('open-file', 'save-file', 'filename'): if self.unit == 'filename': # LEGACY (may be removed later) print(f"Using 'filename' as unit is depreciated in favor of 'open-file' and 'save-file'" \ - f"\nUpdate driver {self.name} to remove this warning", + f"\nUpdate driver '{self.address().split('.')[0]}' to remove this warning", file=sys.stderr) self.unit = 'open-file' @@ -228,21 +243,22 @@ def __call__(self, value: Any = None) -> Any: if self.unit == 'open-file': filename, _ = QtWidgets.QFileDialog.getOpenFileName( - caption=f"Open file - {self.name}", - directory=paths.USER_LAST_CUSTOM_FOLDER, + caption=f"Open file - {self.address()}", + directory=PATHS['last_folder'], filter=SUPPORTED_EXTENSION) elif self.unit == 'save-file': filename, _ = QtWidgets.QFileDialog.getSaveFileName( - caption=f"Save file - {self.name}", - directory=paths.USER_LAST_CUSTOM_FOLDER, + caption=f"Save file - {self.address()}", + directory=PATHS['last_folder'], filter=SUPPORTED_EXTENSION) if filename != '': path = os.path.dirname(filename) - paths.USER_LAST_CUSTOM_FOLDER = path - self.function(filename) + PATHS['last_folder'] = path + value = filename + self.function(value) else: - print(f"Action '{self.name}' cancel filename selection") + print(f"Action '{self.address()}' cancel filename selection") elif self.unit == "user-input": @@ -250,17 +266,22 @@ def __call__(self, value: Any = None) -> Any: _ = QtWidgets.QApplication(sys.argv) # Needed if started outside of GUI # OPTIMIZE: dialog closes on instantiation inside Spyder response, _ = QtWidgets.QInputDialog.getText( - None, self.name, f"Set {self.name} value", + None, self.address(), f"Set {self.address()} value", QtWidgets.QLineEdit.Normal) if response != '': - self.function(response) + value = response + self.function(value) else: - assert value is not None, f"The action {self.name} requires an argument" + assert value is not None, f"The action {self.address()} requires an argument" else: - assert value is None, f"The action {self.name} doesn't require an argument" + assert value is None, f"The action {self.address()} doesn't require an argument" self.function() + if self.type in [tuple]: # OPTIMIZE: could be generalized to any variable but fear could lead to memory issue + self.value = value + if self._write_signal is not None: self._write_signal.emit_write(value) + class Module(Element): @@ -274,7 +295,7 @@ def __init__(self, parent: Type, config: dict): self._read_init_list = [] # Object - instance - assert 'object' in config, f"Module {self.name}: missing module object" + assert 'object' in config, f"Module {self.address()}: missing module object" self.instance = config['object'] # Help @@ -285,44 +306,44 @@ def __init__(self, parent: Type, config: dict): # Loading instance assert hasattr(self.instance, 'get_driver_model'), "There is no function 'get_driver_model' in the driver class" driver_config = self.instance.get_driver_model() - assert isinstance(driver_config, list), f"Module {self.name} configuration: 'get_driver_model' output must be a list of dictionnaries" + assert isinstance(driver_config, list), f"Module {self.address()} configuration: 'get_driver_model' output must be a list of dictionnaries" for config_line in driver_config: # General check - assert isinstance(config_line, dict), f"Module {self.name} configuration: 'get_driver_model' output must be a list of dictionnaries" + assert isinstance(config_line, dict), f"Module {self.address()} configuration: 'get_driver_model' output must be a list of dictionnaries" # Name check - assert 'name' in config_line, f"Module {self.name} configuration: missing 'name' key in one dictionnary" - assert isinstance(config_line['name'], str), f"Module {self.name} configuration: elements names must be a string" + assert 'name' in config_line, f"Module {self.address()} configuration: missing 'name' key in one dictionnary" + assert isinstance(config_line['name'], str), f"Module {self.address()} configuration: elements names must be a string" name = clean_string(config_line['name']) - assert name != '', f"Module {self.name}: elements names cannot be empty" + assert name != '', f"Module {self.address()}: elements names cannot be empty" # Element type check - assert 'element' in config_line, f"Module {self.name}, Element {name} configuration: missing 'element' key in the dictionnary" - assert isinstance(config_line['element'], str), f"Module {self.name}, Element {name} configuration: element type must be a string" + assert 'element' in config_line, f"Module {self.address()}, Element {name} configuration: missing 'element' key in the dictionnary" + assert isinstance(config_line['element'], str), f"Module {self.address()}, Element {name} configuration: element type must be a string" element_type = config_line['element'] - assert element_type in ['module', 'variable', 'action'], f"Module {self.name}, Element {name} configuration: Element type has to be either 'module','variable' or 'action'" + assert element_type in ['module', 'variable', 'action'], f"Module {self.address()}, Element {name} configuration: Element type has to be either 'module','variable' or 'action'" if element_type == 'module': # Check name uniqueness - assert name not in self.get_names(), f"Module {self.name}, Submodule {name} configuration: '{name}' already exists" + assert name not in self.get_names(), f"Module {self.address()}, Submodule {name} configuration: '{name}' already exists" self._mod[name] = Module(self, config_line) elif element_type == 'variable': # Check name uniqueness - assert name not in self.get_names(), f"Module {self.name}, Variable {name} configuration: '{name}' already exists" + assert name not in self.get_names(), f"Module {self.address()}, Variable {name} configuration: '{name}' already exists" self._var[name] = Variable(self, config_line) if self._var[name].read_init: self._read_init_list.append(self._var[name]) elif element_type == 'action': # Check name uniqueness - assert name not in self.get_names(), f"Module {self.name}, Action {name} configuration: '{name}' already exists" + assert name not in self.get_names(), f"Module {self.address()}, Action {name} configuration: '{name}' already exists" self._act[name] = Action(self, config_line) def get_module(self, name: str) -> Type: # -> Module """ Returns the submodule of the given name """ - assert name in self.list_modules(), f"The submodule '{name}' does not exist in module {self.name}" + assert name in self.list_modules(), f"The submodule '{name}' does not exist in module {self.address()}" return self._mod[name] def list_modules(self) -> List[str]: @@ -331,7 +352,7 @@ def list_modules(self) -> List[str]: def get_variable(self, name: str) -> Variable: """ Returns the variable with the given name """ - assert name in self.list_variables(), f"The variable '{name}' does not exist in module {self.name}" + assert name in self.list_variables(), f"The variable '{name}' does not exist in module {self.address()}" return self._var[name] def list_variables(self) -> List[str]: @@ -340,7 +361,7 @@ def list_variables(self) -> List[str]: def get_action(self, name) -> Action: """ Returns the action with the given name """ - assert name in self.list_actions(), f"The action '{name}' does not exist in device {self.name}" + assert name in self.list_actions(), f"The action '{name}' does not exist in device {self.address()}" return self._act[name] def list_actions(self) -> List[str]: @@ -355,7 +376,7 @@ def __getattr__(self, attr: str) -> Element: if attr in self.list_variables(): return self.get_variable(attr) if attr in self.list_actions(): return self.get_action(attr) if attr in self.list_modules(): return self.get_module(attr) - raise AttributeError(f"'{attr}' not found in module '{self.name}'") + raise AttributeError(f"'{attr}' not found in module '{self.address()}'") def get_structure(self) -> List[Tuple[str, str]]: """ Returns the structure of the module as a list containing each element address associated with its type as @@ -398,7 +419,7 @@ def help(self): def __str__(self) -> str: """ This function returns informations for the user about the availables submodules, variables and action attached to the current module """ - display ='\n' + emphasize(f'Module {self.name}') + '\n' + display ='\n' + emphasize(f'Module {self.address()}') + '\n' if self._help is not None: display += f'Help: {self._help}\n' diff --git a/autolab/core/gui/GUI_about.py b/autolab/core/gui/GUI_about.py new file mode 100644 index 00000000..265b5587 --- /dev/null +++ b/autolab/core/gui/GUI_about.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +""" +Created on Mon Aug 5 14:32:41 2024 + +@author: Jonathan +""" + +import sys +import platform + +import numpy as np +import pandas as pd +import qtpy +from qtpy import QtCore, QtWidgets +import pyqtgraph as pg + +from .GUI_instances import clearAbout +from .icons import icons + +from ..web import project_url, drivers_url, doc_url +from ... import __version__ + + +def get_versions() -> dict: + """Information about Autolab versions """ + + # Based on Spyder about.py (https://github.com/spyder-ide/spyder/blob/3ce32d6307302a93957594569176bc84d9c1612e/spyder/plugins/application/widgets/about.py#L40) + versions = { + 'autolab': __version__, + 'python': platform.python_version(), # "2.7.3" + 'bitness': 64 if sys.maxsize > 2**32 else 32, + 'qt_api': qtpy.API_NAME, # PyQt5 + 'qt_api_ver': (qtpy.PYSIDE_VERSION if 'pyside' in qtpy.API + else qtpy.PYQT_VERSION), + 'system': platform.system(), # Linux, Windows, ... + 'release': platform.release(), # XP, 10.6, 2.2.0, etc. + 'pyqtgraph': pg.__version__, + 'numpy': np.__version__, + 'pandas': pd.__version__, + } + if sys.platform == 'darwin': + versions.update(system='macOS', release=platform.mac_ver()[0]) + + return versions + + +class AboutWindow(QtWidgets.QMainWindow): + + def __init__(self, parent: QtWidgets.QMainWindow = None): + + super().__init__() + self.mainGui = parent + self.setWindowTitle('AUTOLAB - About') + self.setWindowIcon(icons['autolab']) + + self.init_ui() + + self.adjustSize() + + # Don't want to have about windows taking the full screen + self.setWindowFlags(QtCore.Qt.Window + | QtCore.Qt.WindowMinimizeButtonHint + | QtCore.Qt.WindowCloseButtonHint + | QtCore.Qt.WindowTitleHint) + + def init_ui(self): + versions = get_versions() + + # Main layout creation + layoutWindow = QtWidgets.QVBoxLayout() + layoutTab = QtWidgets.QHBoxLayout() + layoutWindow.addLayout(layoutTab) + layoutWindow.setContentsMargins(0,0,0,0) + layoutWindow.setSpacing(0) + + centralWidget = QtWidgets.QWidget() + centralWidget.setLayout(layoutWindow) + self.setCentralWidget(centralWidget) + + frameOverview = QtWidgets.QFrame() + layoutOverview = QtWidgets.QVBoxLayout(frameOverview) + layoutOverview.setAlignment(QtCore.Qt.AlignTop) + + frameLegal = QtWidgets.QFrame() + layoutLegal = QtWidgets.QVBoxLayout(frameLegal) + layoutLegal.setAlignment(QtCore.Qt.AlignTop) + + tab = QtWidgets.QTabWidget(self) + tab.addTab(frameOverview, 'Overview') + tab.addTab(frameLegal, 'Legal') + + label_pic = QtWidgets.QLabel() + label_pic.setPixmap(icons['autolab-pixmap']) + + label_autolab = QtWidgets.QLabel(f"

Autolab {versions['autolab']}

") + label_autolab.setAlignment(QtCore.Qt.AlignCenter) + + frameIcon = QtWidgets.QFrame() + layoutIcon = QtWidgets.QVBoxLayout(frameIcon) + layoutIcon.addWidget(label_pic) + layoutIcon.addWidget(label_autolab) + layoutIcon.addStretch() + + layoutTab.addWidget(frameIcon) + layoutTab.addWidget(tab) + + label_versions = QtWidgets.QLabel( + f""" +

Autolab

+ +

Python package for scientific experiments automation

+ +

+ {versions['system']} {versions['release']} +
+ Python {versions['python']} - {versions['bitness']}-bit +
+ {versions['qt_api']} {versions['qt_api_ver']} | + PyQtGraph {versions['pyqtgraph']} | + Numpy {versions['numpy']} | + Pandas {versions['pandas']} +

+ +

+ Project | + Drivers | + Documentation +

+ """ + ) + label_versions.setOpenExternalLinks(True) + label_versions.setWordWrap(True) + + layoutOverview.addWidget(label_versions) + + label_legal = QtWidgets.QLabel( + f""" +

+ Created by Quentin Chateiller, Python drivers originally from + Quentin Chateiller and Bruno Garbin, for the C2N-CNRS + (Center for Nanosciences and Nanotechnologies, Palaiseau, France) + ToniQ team. +
+ Project continued by Jonathan Peltier, for the C2N-CNRS + Minaphot team and Mathieu Jeannin, for the C2N-CNRS + Odin team. +
+
+ Distributed under the terms of the + GPL-3.0 licence +

""" + ) + label_legal.setOpenExternalLinks(True) + label_legal.setWordWrap(True) + layoutLegal.addWidget(label_legal) + + def closeEvent(self, event): + """ Does some steps before the window is really killed """ + clearAbout() + + if not self.mainGui: + QtWidgets.QApplication.quit() # close the about app diff --git a/autolab/core/gui/GUI_add_device.py b/autolab/core/gui/GUI_add_device.py new file mode 100644 index 00000000..a6de1ce2 --- /dev/null +++ b/autolab/core/gui/GUI_add_device.py @@ -0,0 +1,386 @@ +# -*- coding: utf-8 -*- +""" +Created on Mon Aug 5 14:39:02 2024 + +@author: Jonathan +""" +import sys + +from qtpy import QtCore, QtWidgets + +from .GUI_instances import clearAddDevice +from .icons import icons +from ..drivers import (list_drivers, load_driver_lib, get_connection_names, + get_driver_class, get_connection_class, get_class_args) +from ..config import get_all_devices_configs, save_config + + +class AddDeviceWindow(QtWidgets.QMainWindow): + + def __init__(self, parent: QtWidgets.QMainWindow = None): + + super().__init__(parent) + self.mainGui = parent + self.setWindowTitle('AUTOLAB - Add Device') + self.setWindowIcon(icons['autolab']) + + self.statusBar = self.statusBar() + + self._prev_name = '' + self._prev_conn = '' + + try: + import pyvisa as visa + self.rm = visa.ResourceManager() + except: + self.rm = None + + # Main layout creation + layoutWindow = QtWidgets.QVBoxLayout() + layoutWindow.setAlignment(QtCore.Qt.AlignTop) + + centralWidget = QtWidgets.QWidget() + centralWidget.setLayout(layoutWindow) + self.setCentralWidget(centralWidget) + + # Device nickname + layoutDeviceNickname = QtWidgets.QHBoxLayout() + layoutWindow.addLayout(layoutDeviceNickname) + + label = QtWidgets.QLabel('Device') + + self.deviceNickname = QtWidgets.QLineEdit() + self.deviceNickname.setText('my_device') + + layoutDeviceNickname.addWidget(label) + layoutDeviceNickname.addWidget(self.deviceNickname) + + # Driver name + layoutDriverName = QtWidgets.QHBoxLayout() + layoutWindow.addLayout(layoutDriverName) + + label = QtWidgets.QLabel('Driver') + + self.driversComboBox = QtWidgets.QComboBox() + self.driversComboBox.addItems(list_drivers()) + self.driversComboBox.activated.connect(self.driverChanged) + self.driversComboBox.setSizeAdjustPolicy( + QtWidgets.QComboBox.AdjustToContents) + + layoutDriverName.addWidget(label) + layoutDriverName.addStretch() + layoutDriverName.addWidget(self.driversComboBox) + + # Driver connection + layoutDriverConnection = QtWidgets.QHBoxLayout() + layoutWindow.addLayout(layoutDriverConnection) + + label = QtWidgets.QLabel('Connection') + + self.connectionComboBox = QtWidgets.QComboBox() + self.connectionComboBox.activated.connect(self.connectionChanged) + self.connectionComboBox.setSizeAdjustPolicy( + QtWidgets.QComboBox.AdjustToContents) + + layoutDriverConnection.addWidget(label) + layoutDriverConnection.addStretch() + layoutDriverConnection.addWidget(self.connectionComboBox) + + # Driver arguments + self.layoutDriverArgs = QtWidgets.QHBoxLayout() + layoutWindow.addLayout(self.layoutDriverArgs) + + self.layoutDriverOtherArgs = QtWidgets.QHBoxLayout() + layoutWindow.addLayout(self.layoutDriverOtherArgs) + + # layout for optional args + self.layoutOptionalArg = QtWidgets.QVBoxLayout() + layoutWindow.addLayout(self.layoutOptionalArg) + + # Add argument + layoutButtonArg = QtWidgets.QHBoxLayout() + layoutWindow.addLayout(layoutButtonArg) + + addOptionalArg = QtWidgets.QPushButton('Add argument') + addOptionalArg.setIcon(icons['add']) + addOptionalArg.clicked.connect(lambda state: self.addOptionalArgClicked()) + + layoutButtonArg.addWidget(addOptionalArg) + layoutButtonArg.setAlignment(QtCore.Qt.AlignLeft) + + # Add device + layoutButton = QtWidgets.QHBoxLayout() + layoutWindow.addLayout(layoutButton) + + self.addButton = QtWidgets.QPushButton('Add Device') + self.addButton.clicked.connect(self.addButtonClicked) + + layoutButton.addWidget(self.addButton) + + # update driver name combobox + self.driverChanged() + + self.resize(self.minimumSizeHint()) + + def addOptionalArgClicked(self, key: str = None, val: str = None): + """ Add new layout for optional argument """ + layout = QtWidgets.QHBoxLayout() + self.layoutOptionalArg.addLayout(layout) + + widget = QtWidgets.QLineEdit() + widget.setText(key) + layout.addWidget(widget) + widget = QtWidgets.QLineEdit() + widget.setText(val) + layout.addWidget(widget) + widget = QtWidgets.QPushButton() + widget.setIcon(icons['remove']) + widget.clicked.connect(lambda: self.removeOptionalArgClicked(layout)) + layout.addWidget(widget) + + def removeOptionalArgClicked(self, layout): + """ Remove optional argument layout """ + for j in reversed(range(layout.count())): + layout.itemAt(j).widget().setParent(None) + layout.setParent(None) + + def addButtonClicked(self): + """ Add the device to the config file """ + device_name = self.deviceNickname.text() + driver_name = self.driversComboBox.currentText() + conn = self.connectionComboBox.currentText() + + if device_name == '': + self.setStatus('Need device name', 10000, False) + return None + + device_dict = {} + device_dict['driver'] = driver_name + device_dict['connection'] = conn + + for layout in (self.layoutDriverArgs, self.layoutDriverOtherArgs): + for i in range(0, (layout.count()//2)*2, 2): + key = layout.itemAt(i).widget().text() + val = layout.itemAt(i+1).widget().text() + device_dict[key] = val + + for i in range(self.layoutOptionalArg.count()): + layout = self.layoutOptionalArg.itemAt(i).layout() + key = layout.itemAt(0).widget().text() + val = layout.itemAt(1).widget().text() + device_dict[key] = val + + # Update devices config + device_config = get_all_devices_configs() + new_device = {device_name: device_dict} + device_config.update(new_device) + save_config('devices_config', device_config) + + if hasattr(self.mainGui, 'initialize'): self.mainGui.initialize() + + self.close() + + def modify(self, nickname: str, conf: dict): + """ Modify existing driver (not optimized) """ + + self.setWindowTitle('AUTOLAB - Modify device') + self.addButton.setText('Modify device') + + self.deviceNickname.setText(nickname) + self.deviceNickname.setEnabled(False) + driver_name = conf.pop('driver') + conn = conf.pop('connection') + index = self.driversComboBox.findText(driver_name) + self.driversComboBox.setCurrentIndex(index) + self.driverChanged() + + try: + driver_lib = load_driver_lib(driver_name) + except: pass + else: + list_conn = get_connection_names(driver_lib) + if conn not in list_conn: + if list_conn: + self.setStatus(f"Connection {conn} not found, switch to {list_conn[0]}", 10000, False) + conn = list_conn[0] + else: + self.setStatus(f"No connections available for driver '{driver_name}'", 10000, False) + conn = '' + + index = self.connectionComboBox.findText(conn) + self.connectionComboBox.setCurrentIndex(index) + if index != -1: + self.connectionChanged() + + # Used to remove default value + try: + driver_lib = load_driver_lib(driver_name) + driver_class = get_driver_class(driver_lib) + assert hasattr(driver_class, 'slot_config') + except: + slot_config = '' + else: + slot_config = f'{driver_class.slot_config}' + + # Update args + for layout in (self.layoutDriverArgs, self.layoutDriverOtherArgs): + for i in range(0, (layout.count()//2)*2, 2): + key = layout.itemAt(i).widget().text() + if key in conf: + layout.itemAt(i+1).widget().setText(conf[key]) + conf.pop(key) + + # Update optional args + for i in reversed(range(self.layoutOptionalArg.count())): + layout = self.layoutOptionalArg.itemAt(i).layout() + key = layout.itemAt(0).widget().text() + val_tmp = layout.itemAt(1).widget().text() + # Remove default value + if ((key == 'slot1' and val_tmp == slot_config) + or (key == 'slot1_name' and val_tmp == 'my_')): + for j in reversed(range(layout.count())): + layout.itemAt(j).widget().setParent(None) + layout.setParent(None) + elif key in conf: + layout.itemAt(1).widget().setText(conf[key]) + conf.pop(key) + + # Add remaining optional args from config + for key, val in conf.items(): + self.addOptionalArgClicked(key, val) + + def driverChanged(self): + """ Update driver information """ + driver_name = self.driversComboBox.currentText() + + if driver_name == self._prev_name: return None + if driver_name == '': + self.setStatus(f"Can't load driver associated with {self.deviceNickname.text()}", 10000, False) + return None + self._prev_name = driver_name + + try: + driver_lib = load_driver_lib(driver_name) + except Exception as e: + # If error with driver remove all layouts + self.setStatus(f"Can't load {driver_name}: {e}", 10000, False) + + self.connectionComboBox.clear() + + for layout in (self.layoutDriverArgs, self.layoutDriverOtherArgs): + for i in reversed(range(layout.count())): + layout.itemAt(i).widget().setParent(None) + + for i in reversed(range(self.layoutOptionalArg.count())): + layout = self.layoutOptionalArg.itemAt(i).layout() + for j in reversed(range(layout.count())): + layout.itemAt(j).widget().setParent(None) + layout.setParent(None) + + return None + + self.setStatus('') + + # Update available connections + connections = get_connection_names(driver_lib) + self.connectionComboBox.clear() + self.connectionComboBox.addItems(connections) + + # update selected connection information + self._prev_conn = '' + self.connectionChanged() + + # reset layoutDriverOtherArgs + for i in reversed(range(self.layoutDriverOtherArgs.count())): + self.layoutDriverOtherArgs.itemAt(i).widget().setParent(None) + + # used to skip doublon key + conn = self.connectionComboBox.currentText() + try: + driver_instance = get_connection_class(driver_lib, conn) + except: + connection_args = {} + else: + connection_args = get_class_args(driver_instance) + + # populate layoutDriverOtherArgs + driver_class = get_driver_class(driver_lib) + other_args = get_class_args(driver_class) + for key, val in other_args.items(): + if key in connection_args: continue + widget = QtWidgets.QLabel() + widget.setText(key) + self.layoutDriverOtherArgs.addWidget(widget) + widget = QtWidgets.QLineEdit() + widget.setText(str(val)) + self.layoutDriverOtherArgs.addWidget(widget) + + # reset layoutOptionalArg + for i in reversed(range(self.layoutOptionalArg.count())): + layout = self.layoutOptionalArg.itemAt(i).layout() + for j in reversed(range(layout.count())): + layout.itemAt(j).widget().setParent(None) + layout.setParent(None) + + # populate layoutOptionalArg + if hasattr(driver_class, 'slot_config'): + self.addOptionalArgClicked('slot1', f'{driver_class.slot_config}') + self.addOptionalArgClicked('slot1_name', 'my_') + + def connectionChanged(self): + """ Update connection information """ + conn = self.connectionComboBox.currentText() + + if conn == self._prev_conn: return None + self._prev_conn = conn + + driver_name = self.driversComboBox.currentText() + try: + driver_lib = load_driver_lib(driver_name) + except: + return None + + connection_args = get_class_args( + get_connection_class(driver_lib, conn)) + + # reset layoutDriverArgs + for i in reversed(range(self.layoutDriverArgs.count())): + self.layoutDriverArgs.itemAt(i).widget().setParent(None) + + conn_widget = None + # populate layoutDriverArgs + for key, val in connection_args.items(): + widget = QtWidgets.QLabel() + widget.setText(key) + self.layoutDriverArgs.addWidget(widget) + + widget = QtWidgets.QLineEdit() + widget.setText(str(val)) + self.layoutDriverArgs.addWidget(widget) + + if key == 'address': + conn_widget = widget + + if self.rm is not None and conn == 'VISA': + widget = QtWidgets.QComboBox() + widget.clear() + conn_list = ('Available connections',) + tuple(self.rm.list_resources()) + widget.addItems(conn_list) + if conn_widget is not None: + widget.activated.connect( + lambda item, conn_widget=conn_widget: conn_widget.setText( + widget.currentText()) if widget.currentText( + ) != 'Available connections' else conn_widget.text()) + self.layoutDriverArgs.addWidget(widget) + + def setStatus(self, message: str, timeout: int = 0, stdout: bool = True): + """ Modify the message displayed in the status bar and add error message to logger """ + self.statusBar.showMessage(message, timeout) + if not stdout: print(message, file=sys.stderr) + + def closeEvent(self, event): + """ Does some steps before the window is really killed """ + clearAddDevice() + + if not self.mainGui: + QtWidgets.QApplication.quit() # close the monitor app diff --git a/autolab/core/gui/GUI_driver_installer.py b/autolab/core/gui/GUI_driver_installer.py new file mode 100644 index 00000000..5f147c80 --- /dev/null +++ b/autolab/core/gui/GUI_driver_installer.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +""" +Created on Wed Sep 25 10:46:14 2024 + +@author: jonathan +""" +import sys + +from qtpy import QtWidgets, QtGui + +from .icons import icons +from .GUI_instances import clearDriverInstaller +from ..paths import DRIVER_SOURCES, DRIVER_REPOSITORY +from ..repository import install_drivers, _download_driver, _get_drivers_list_from_github +from ..drivers import update_drivers_paths + + +class DriverInstaller(QtWidgets.QMainWindow): + + def __init__(self, parent=None): + """ GUI to select which driver to install from the official github repo """ + + super().__init__() + + self.setWindowTitle("AUTOLAB - Driver Installer") + self.setWindowIcon(icons['autolab']) + self.setFocus() + self.activateWindow() + + self.statusBar = self.statusBar() + + official_folder = DRIVER_SOURCES['official'] + official_url = DRIVER_REPOSITORY[official_folder] + + list_driver = [] + try: + list_driver = _get_drivers_list_from_github(official_url) + except: + self.setStatus(f'Warning: Cannot access {official_url}', 10000, False) + + self.mainGui = parent + self.url = official_url # TODO: use dict DRIVER_REPOSITORY to have all urls + self.list_driver = list_driver + self.OUTPUT_DIR = official_folder + + self.init_ui() + + self.adjustSize() + + def init_ui(self): + centralWidget = QtWidgets.QWidget() + self.setCentralWidget(centralWidget) + + # OFFICIAL DRIVERS + formLayout = QtWidgets.QFormLayout() + centralWidget.setLayout(formLayout) + + self.masterCheckBox = QtWidgets.QCheckBox(f"From {DRIVER_REPOSITORY[DRIVER_SOURCES['official']]}:") + self.masterCheckBox.setChecked(False) + self.masterCheckBox.stateChanged.connect(self.masterCheckBoxChanged) + formLayout.addRow(self.masterCheckBox) + + # Init table size + sti = QtGui.QStandardItemModel() + for i in range(len(self.list_driver)): + sti.appendRow([QtGui.QStandardItem(str())]) + + # Create table + tab = QtWidgets.QTableView() + tab.setModel(sti) + tab.verticalHeader().setVisible(False) + tab.horizontalHeader().setVisible(False) + tab.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) + tab.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection) + tab.setAlternatingRowColors(True) + tab.setSizePolicy(QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding) + tab.setSizeAdjustPolicy( + QtWidgets.QAbstractScrollArea.AdjustToContents) + + if self.list_driver: # OPTIMIZE: c++ crash if no driver in list! + tab.horizontalHeader().setSectionResizeMode( + 0, QtWidgets.QHeaderView.ResizeToContents) + + # Init checkBox + self.list_checkBox = [] + for i, driver_name in enumerate(self.list_driver): + checkBox = QtWidgets.QCheckBox(f"{driver_name}") + checkBox.setChecked(False) + self.list_checkBox.append(checkBox) + tab.setIndexWidget(sti.index(i, 0), checkBox) + + formLayout.addRow(QtWidgets.QLabel(""), tab) + + download_pushButton = QtWidgets.QPushButton() + download_pushButton.clicked.connect(self.installListDriver) + download_pushButton.setText("Download") + formLayout.addRow(download_pushButton) + + def masterCheckBoxChanged(self): + """ Checked all the checkBox related to the official github repo """ + state = self.masterCheckBox.isChecked() + for checkBox in self.list_checkBox: + checkBox.setChecked(state) + + def installListDriver(self): + """ Install all the drivers for which the corresponding checkBox has been checked """ + list_bool = [ + checkBox.isChecked() for checkBox in self.list_checkBox] + list_driver_to_download = [ + driver_name for (driver_name, driver_bool) in zip( + self.list_driver, list_bool) if driver_bool] + + try: + # Better for all drivers + if all(list_bool): + install_drivers(skip_input=True) + # Better for several drivers + elif any(list_bool): + for driver_name in list_driver_to_download: + # self.setStatus(f"Downloading {driver_name}", 5000) # OPTIMIZE: currently thread blocked by installer so don't show anything until the end + e = _download_driver( + self.url, driver_name, self.OUTPUT_DIR, _print=False) + if e is not None: + print(e, file=sys.stderr) + # self.setStatus(e, 10000, False) + except Exception as e: + self.setStatus(f'Error: {e}', 10000, False) + else: + self.setStatus('Finished!', 5000) + + # Update available drivers + update_drivers_paths() + + def closeEvent(self, event): + """ This function does some steps before the window is really killed """ + clearDriverInstaller() + + super().closeEvent(event) + + if not self.mainGui: + QtWidgets.QApplication.quit() # close the app + + def setStatus(self, message: str, timeout: int = 0, stdout: bool = True): + """ Modify the message displayed in the status bar and add error message to logger """ + self.statusBar.showMessage(message, timeout) + if not stdout: print(message, file=sys.stderr) diff --git a/autolab/core/gui/GUI_instances.py b/autolab/core/gui/GUI_instances.py new file mode 100644 index 00000000..7b0b20e3 --- /dev/null +++ b/autolab/core/gui/GUI_instances.py @@ -0,0 +1,336 @@ +# -*- coding: utf-8 -*- +""" +Created on Sat Aug 3 20:40:00 2024 + +@author: jonathan +""" + +from typing import Union, Any + +import pandas as pd +from qtpy import QtWidgets, QtCore + +from ..devices import get_final_device_config + +from ..elements import Variable as Variable_og +from ..variables import Variable + +# Contains local import: +# from .monitoring.main import Monitor +# from .GUI_slider import Slider +# from .GUI_variables import VariablesMenu +# from .plotting.main import Plotter +# from .GUI_add_device import AddDeviceWindow +# from .GUI_about import AboutWindow +# Not yet or maybe never (too intertwined with mainGui) # from .scanning.main import Scanner + + +instances = { + 'monitors': {}, + 'sliders': {}, + 'variablesMenu': None, + 'plotter': None, + 'addDevice': None, + 'about': None, + 'preferences': None, + 'driverInstaller': None, + # 'scanner': None, +} + + +# ============================================================================= +# Monitor +# ============================================================================= +def openMonitor(variable: Union[Variable, Variable_og], + has_parent: bool = False): + """ Opens the monitor associated to the variable. """ + from .monitoring.main import Monitor # Inside to avoid circular import + + assert isinstance(variable, (Variable, Variable_og)), ( + f'Need type {Variable} or {Variable_og}, but given type is {type(variable)}') + assert variable.readable, f"The variable {variable.address()} is not readable" + + # If the monitor is not already running, create one + if id(variable) not in instances['monitors'].keys(): + instances['monitors'][id(variable)] = Monitor(variable, has_parent) + instances['monitors'][id(variable)].show() + # If the monitor is already running, just make as the front window + else: + monitor = instances['monitors'][id(variable)] + monitor.setWindowState( + monitor.windowState() + & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) + monitor.activateWindow() + + +def clearMonitor(variable: Union[Variable, Variable_og]): + """ Clears monitor instances reference when quitted """ + if id(variable) in list(instances['monitors']): + instances['monitors'].pop(id(variable)) + + +def closeMonitors(): + for monitor in list(instances['monitors'].values()): + monitor.close() + + +# ============================================================================= +# Slider +# ============================================================================= +def openSlider(variable: Union[Variable, Variable_og], + gui: QtWidgets.QMainWindow = None, + item: QtWidgets.QTreeWidgetItem = None): + """ Opend the slider associated to this variable. """ + from .GUI_slider import Slider # Inside to avoid circular import + + assert isinstance(variable, (Variable, Variable_og)), ( + f'Need type {Variable} or {Variable_og}, but given type is {type(variable)}') + assert variable.writable, f"The variable {variable.address()} is not writable" + + # If the slider is not already running, create one + if id(variable) not in instances['sliders'].keys(): + instances['sliders'][id(variable)] = Slider(variable, gui=gui, item=item) + instances['sliders'][id(variable)].show() + # If the slider is already running, just make as the front window + else: + slider = instances['sliders'][id(variable)] + slider.setWindowState( + slider.windowState() + & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) + slider.activateWindow() + + +def clearSlider(variable: Union[Variable, Variable_og]): + """ Clears the slider instances reference when quitted """ + if id(variable) in instances['sliders'].keys(): + instances['sliders'].pop(id(variable)) + + +def closeSliders(): + for slider in list(instances['sliders'].values()): + slider.close() + + +# ============================================================================= +# VariableMenu +# ============================================================================= +def openVariablesMenu(has_parent: bool = False): + """ Opens the variables menu. """ + from .GUI_variables import VariablesMenu # Inside to avoid circular import + if instances['variablesMenu'] is None: + instances['variablesMenu'] = VariablesMenu(has_parent) + instances['variablesMenu'].show() + else: + instances['variablesMenu'].refresh() + instances['variablesMenu'].setWindowState( + instances['variablesMenu'].windowState() + & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) + instances['variablesMenu'].activateWindow() + + +def clearVariablesMenu(): + """ Clears the variables menu instance reference when quitted """ + instances['variablesMenu'] = None + + +def closeVariablesMenu(): + if instances['variablesMenu'] is not None: + instances['variablesMenu'].close() + + +# ============================================================================= +# Plotter +# ============================================================================= +def openPlotter(variable: Union[Variable, Variable_og, pd.DataFrame, Any] = None, + has_parent: bool = False): + """ Opens the plotter. Can add variable. """ + from .plotting.main import Plotter # Inside to avoid circular import + # If the plotter is not already running, create one + if instances['plotter'] is None: + instances['plotter'] = Plotter(has_parent) + # If the plotter is not active open it (keep data if closed) + if not instances['plotter'].active: + instances['plotter'].show() + instances['plotter'].activateWindow() + instances['plotter'].active = True + # If the plotter is already running, just make as the front window + else: + instances['plotter'].setWindowState( + instances['plotter'].windowState() + & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) + instances['plotter'].activateWindow() + + if variable is not None: + instances['plotter'].refreshPlotData(variable) + + +def clearPlotter(): + """ Deactivates the plotter when quitted but keep the instance in memory """ + if instances['plotter'] is not None: + instances['plotter'].active = False # don't want to close plotter because want to keep data + + +def closePlotter(): + if instances['plotter'] is not None: + instances['plotter'].figureManager.fig.deleteLater() + for children in instances['plotter'].findChildren(QtWidgets.QWidget): + children.deleteLater() + + instances['plotter'].close() + instances['plotter'] = None # To remove plotter from memory + + +# ============================================================================= +# AddDevice +# ============================================================================= +def openAddDevice(gui: QtWidgets.QMainWindow = None, name: str = ''): + """ Opens the add device window. """ + from .GUI_add_device import AddDeviceWindow # Inside to avoid circular import + # If the add device window is not already running, create one + if instances['addDevice'] is None: + instances['addDevice'] = AddDeviceWindow(gui) + instances['addDevice'].show() + instances['addDevice'].activateWindow() + # If the add device window is already running, just make as the front window + else: + instances['addDevice'].setWindowState( + instances['addDevice'].windowState() + & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) + instances['addDevice'].activateWindow() + + # Modify existing device + if name != '': + try: + conf = get_final_device_config(name) + except Exception as e: + instances['addDevice'].setStatus(str(e), 10000, False) + else: + instances['addDevice'].modify(name, conf) + + +def clearAddDevice(): + """ Clears the addDevice instance reference when quitted """ + instances['addDevice'] = None + + +def closeAddDevice(): + if instances['addDevice'] is not None: + instances['addDevice'].close() + + +# ============================================================================= +# About +# ============================================================================= +def openAbout(gui: QtWidgets.QMainWindow = None): + """ Opens the about window. """ + # If the about window is not already running, create one + from .GUI_about import AboutWindow # Inside to avoid circular import + if instances['about'] is None: + instances['about'] = AboutWindow(gui) + instances['about'].show() + instances['about'].activateWindow() + # If the about window is already running, just make as the front window + else: + instances['about'].setWindowState( + instances['about'].windowState() + & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) + instances['about'].activateWindow() + + +def clearAbout(): + """ Clears the about instance reference when quitted """ + instances['about'] = None + + +def closeAbout(): + if instances['about'] is not None: + instances['about'].close() + + +# ============================================================================= +# Preferences +# ============================================================================= +def openPreferences(gui: QtWidgets.QMainWindow = None): + """ Opens the preferences window. """ + # If the about window is not already running, create one + from .GUI_preferences import PreferencesWindow # Inside to avoid circular import + if instances['preferences'] is None: + instances['preferences'] = PreferencesWindow(gui) + instances['preferences'].show() + instances['preferences'].activateWindow() + # If the about window is already running, just make as the front window + else: + instances['preferences'].setWindowState( + instances['preferences'].windowState() + & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) + instances['preferences'].activateWindow() + + +def clearPreferences(): + """ Clears the preferences instance reference when quitted """ + instances['preferences'] = None + + +def closePreferences(): + if instances['preferences'] is not None: + instances['preferences'].close() + + + +# ============================================================================= +# Driver installer +# ============================================================================= +def openDriverInstaller(gui: QtWidgets.QMainWindow = None): + """ Opens the driver installer. """ + # If the about window is not already running, create one + from .GUI_driver_installer import DriverInstaller # Inside to avoid circular import + if instances['driverInstaller'] is None: + instances['driverInstaller'] = DriverInstaller(gui) + instances['driverInstaller'].show() + instances['driverInstaller'].activateWindow() + # If the about window is already running, just make as the front window + else: + instances['driverInstaller'].setWindowState( + instances['driverInstaller'].windowState() + & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) + instances['driverInstaller'].activateWindow() + + +def clearDriverInstaller(): + """ Clears the driver unstaller instance reference when quitted """ + instances['driverInstaller'] = None + + +def closeDriverInstaller(): + if instances['driverInstaller'] is not None: + instances['driverInstaller'].close() + + +# ============================================================================= +# Scanner +# ============================================================================= +# def openScanner(gui: QtWidgets.QMainWindow, show=True): +# """ Opens the scanner. """ +# # If the scanner is not already running, create one +# from .scanning.main import Scanner # Inside to avoid circular import +# if instances['scanner'] is None: +# instances['scanner'] = Scanner(gui) +# instances['scanner'].show() +# instances['scanner'].activateWindow() +# gui.activateWindow() # Put main window back to the front +# # If the scanner is already running, just make as the front window +# elif show: +# instances['scanner'].setWindowState( +# instances['scanner'].windowState() +# & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) +# instances['scanner'].activateWindow() + + +# def clearScanner(): +# """ Clears the scanner instance reference when quitted """ +# instances['scanner'] = None + + +# def closeScanner(): +# if instances['scanner'] is not None: +# instances['scanner'].close() diff --git a/autolab/core/gui/GUI_preferences.py b/autolab/core/gui/GUI_preferences.py new file mode 100644 index 00000000..a6296e40 --- /dev/null +++ b/autolab/core/gui/GUI_preferences.py @@ -0,0 +1,524 @@ +# -*- coding: utf-8 -*- +""" +Created on Tue Sep 17 22:56:32 2024 + +@author: jonathan +""" + +from typing import Dict +import sys + +from qtpy import QtCore, QtWidgets + +from .icons import icons +from .GUI_instances import clearPreferences +from ..utilities import boolean +from ..config import (check_autolab_config, change_autolab_config, + check_plotter_config, change_plotter_config, + load_config, modify_config, set_temp_folder) + + +class PreferencesWindow(QtWidgets.QMainWindow): + + def __init__(self, parent: QtWidgets.QMainWindow = None): + + super().__init__() + self.mainGui = parent + self.setWindowTitle('AUTOLAB - Preferences') + self.setWindowIcon(icons['preference']) + + self.setFocus() + self.activateWindow() + self.statusBar = self.statusBar() + + self.init_ui() + + self.adjustSize() + + self.resize(500, 670) + + def init_ui(self): + # Update config if needed + check_autolab_config() + check_plotter_config() + + autolab_config = load_config('autolab_config') + plotter_config = load_config('plotter_config') + + centralWidget = QtWidgets.QWidget() + layout = QtWidgets.QVBoxLayout(centralWidget) + layout.setContentsMargins(0,0,0,0) + layout.setSpacing(0) + self.setCentralWidget(centralWidget) + + scrollAutolab = QtWidgets.QScrollArea() + scrollAutolab.setWidgetResizable(True) + frameAutolab = QtWidgets.QFrame() + scrollAutolab.setWidget(frameAutolab) + layoutAutolab = QtWidgets.QVBoxLayout(frameAutolab) + layoutAutolab.setAlignment(QtCore.Qt.AlignTop) + + scrollPlotter = QtWidgets.QScrollArea() + scrollPlotter.setWidgetResizable(True) + framePlotter = QtWidgets.QFrame() + scrollPlotter.setWidget(framePlotter) + layoutPlotter = QtWidgets.QVBoxLayout(framePlotter) + layoutPlotter.setAlignment(QtCore.Qt.AlignTop) + + tab = QtWidgets.QTabWidget(self) + tab.addTab(scrollAutolab, 'Autolab') + tab.addTab(scrollPlotter, 'Plotter') + + layout.addWidget(tab) + + # Create a frame for each main key in the dictionary + self.inputs_autolab = {} + self.inputs_plotter = {} + + ### Autolab + ## GUI + main_key = 'GUI' + group_box = QtWidgets.QGroupBox(main_key) + layoutAutolab.addWidget(group_box) + group_layout = QtWidgets.QFormLayout(group_box) + self.inputs_autolab[main_key] = {} + + sub_key = 'qt_api' + saved_value = autolab_config[main_key][sub_key] + input_widget = QtWidgets.QComboBox() + input_widget.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + input_widget.setToolTip('Select the GUI Qt binding') + input_widget.addItems(['default', 'pyqt5', 'pyqt6', 'pyside2', 'pyside6']) + index = input_widget.findText(saved_value) + input_widget.setCurrentIndex(index) + self.inputs_autolab[main_key][sub_key] = input_widget + group_layout.addRow(QtWidgets.QLabel(sub_key), input_widget) + + sub_key = 'theme' + saved_value = autolab_config[main_key][sub_key] + input_widget = QtWidgets.QComboBox() + input_widget.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + input_widget.setToolTip('Select the GUI theme') + input_widget.addItems(['default', 'dark']) + index = input_widget.findText(saved_value) + input_widget.setCurrentIndex(index) + self.inputs_autolab[main_key][sub_key] = input_widget + group_layout.addRow(QtWidgets.QLabel(sub_key), input_widget) + + sub_key = 'font_size' + saved_value = autolab_config[main_key][sub_key] + input_widget = QtWidgets.QSpinBox() + input_widget.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + input_widget.setToolTip('Select the GUI text font size') + input_widget.setValue(int(float(saved_value))) + self.inputs_autolab[main_key][sub_key] = input_widget + group_layout.addRow(QtWidgets.QLabel(sub_key), input_widget) + + sub_key = 'image_background' + saved_value = autolab_config[main_key][sub_key] + input_widget = QtWidgets.QLineEdit() + input_widget.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + input_widget.setToolTip('Select the plots background color (disabled if use dark theme)') + input_widget.setText(str(saved_value)) + self.inputs_autolab[main_key][sub_key] = input_widget + group_layout.addRow(QtWidgets.QLabel(sub_key), input_widget) + + sub_key = 'image_foreground' + saved_value = autolab_config[main_key][sub_key] + input_widget = QtWidgets.QLineEdit() + input_widget.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + input_widget.setToolTip('Select the plots foreground color (disabled if use dark theme)') + input_widget.setText(str(saved_value)) + self.inputs_autolab[main_key][sub_key] = input_widget + group_layout.addRow(QtWidgets.QLabel(sub_key), input_widget) + + ## control_center + main_key = 'control_center' + group_box = QtWidgets.QGroupBox(main_key) + layoutAutolab.addWidget(group_box) + group_layout = QtWidgets.QFormLayout(group_box) + self.inputs_autolab[main_key] = {} + + sub_key = 'precision' + saved_value = autolab_config[main_key][sub_key] + input_widget = QtWidgets.QSpinBox() + input_widget.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + input_widget.setToolTip('Select the displayed precision for variables in the control panel') + input_widget.setValue(int(float(saved_value))) + self.inputs_autolab[main_key][sub_key] = input_widget + group_layout.addRow(QtWidgets.QLabel(sub_key), input_widget) + + sub_key = 'print' + saved_value = autolab_config[main_key][sub_key] + input_widget = QtWidgets.QCheckBox(sub_key) + input_widget.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + input_widget.setToolTip('Select if print GUI information to console') + input_widget.setChecked(boolean(saved_value)) + self.inputs_autolab[main_key][sub_key] = input_widget + group_layout.addRow(input_widget) + + sub_key = 'logger' + saved_value = autolab_config[main_key][sub_key] + input_widget = QtWidgets.QCheckBox(sub_key) + input_widget.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + input_widget.setToolTip('Activate a logger to display GUI information') + input_widget.setChecked(boolean(saved_value)) + self.inputs_autolab[main_key][sub_key] = input_widget + group_layout.addRow(input_widget) + + sub_key = 'console' + saved_value = autolab_config[main_key][sub_key] + input_widget = QtWidgets.QCheckBox(sub_key) + input_widget.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + input_widget.setToolTip('Activate a console for debugging') + input_widget.setChecked(boolean(saved_value)) + self.inputs_autolab[main_key][sub_key] = input_widget + group_layout.addRow(input_widget) + + ## monitor + main_key = 'monitor' + group_box = QtWidgets.QGroupBox(main_key) + layoutAutolab.addWidget(group_box) + group_layout = QtWidgets.QFormLayout(group_box) + self.inputs_autolab[main_key] = {} + + sub_key = 'precision' + saved_value = autolab_config[main_key][sub_key] + input_widget = QtWidgets.QSpinBox() + input_widget.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + input_widget.setToolTip('Select the displayed precision for variables in monitors') + input_widget.setValue(int(float(saved_value))) + self.inputs_autolab[main_key][sub_key] = input_widget + group_layout.addRow(QtWidgets.QLabel(sub_key), input_widget) + + sub_key = 'save_figure' + saved_value = autolab_config[main_key][sub_key] + input_widget = QtWidgets.QCheckBox(sub_key) + input_widget.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + input_widget.setToolTip('Select if save figure image when saving monitor data') + input_widget.setChecked(boolean(saved_value)) + self.inputs_autolab[main_key][sub_key] = input_widget + group_layout.addRow(input_widget) + + ## scanner + main_key = 'scanner' + group_box = QtWidgets.QGroupBox(main_key) + layoutAutolab.addWidget(group_box) + group_layout = QtWidgets.QFormLayout(group_box) + self.inputs_autolab[main_key] = {} + + sub_key = 'precision' + saved_value = autolab_config[main_key][sub_key] + input_widget = QtWidgets.QSpinBox() + input_widget.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + input_widget.setToolTip('Select the displayed precision for variables in the scanner') + input_widget.setValue(int(float(saved_value))) + self.inputs_autolab[main_key][sub_key] = input_widget + group_layout.addRow(QtWidgets.QLabel(sub_key), input_widget) + + sub_key = 'save_config' + saved_value = autolab_config[main_key][sub_key] + input_widget = QtWidgets.QCheckBox(sub_key) + input_widget.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + input_widget.setToolTip('Select if save config file when saving scanner data') + input_widget.setChecked(boolean(saved_value)) + self.inputs_autolab[main_key][sub_key] = input_widget + group_layout.addRow(input_widget) + + sub_key = 'save_figure' + saved_value = autolab_config[main_key][sub_key] + input_widget = QtWidgets.QCheckBox(sub_key) + input_widget.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + input_widget.setToolTip('Select if save figure image when saving scanner data') + input_widget.setChecked(boolean(saved_value)) + self.inputs_autolab[main_key][sub_key] = input_widget + group_layout.addRow(input_widget) + + sub_key = 'save_temp' + saved_value = autolab_config[main_key][sub_key] + input_widget = QtWidgets.QCheckBox(sub_key) + input_widget.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + input_widget.setToolTip('Select if save temporary data. Should not be disable!') + input_widget.setChecked(boolean(saved_value)) + self.inputs_autolab[main_key][sub_key] = input_widget + group_layout.addRow(input_widget) + + sub_key = 'ask_close' + saved_value = autolab_config[main_key][sub_key] + input_widget = QtWidgets.QCheckBox(sub_key) + input_widget.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + input_widget.setToolTip('Set whether a warning message about unsaved data should be displayed when the scanner is closed.') + input_widget.setChecked(boolean(saved_value)) + self.inputs_autolab[main_key][sub_key] = input_widget + group_layout.addRow(input_widget) + + ## directories + main_key = 'directories' + group_box = QtWidgets.QGroupBox(main_key) + layoutAutolab.addWidget(group_box) + group_layout = QtWidgets.QFormLayout(group_box) + self.inputs_autolab[main_key] = {} + + sub_key = 'temp_folder' + saved_value = autolab_config[main_key][sub_key] + input_widget = QtWidgets.QLineEdit() + input_widget.setToolTip("Select the temporary folder. default correspond to os.environ['TEMP']") + input_widget.setText(str(saved_value)) + self.inputs_autolab[main_key][sub_key] = input_widget + group_layout.addRow(QtWidgets.QLabel(sub_key), input_widget) + + ## extra_driver_path + main_key = 'extra_driver_path' + group_box = QtWidgets.QFrame() + group_layout = QtWidgets.QVBoxLayout(group_box) + group_layout.setContentsMargins(0,0,0,0) + layoutAutolab.addWidget(group_box) + group_box_plugin = QtWidgets.QGroupBox(main_key) + group_layout.addWidget(group_box_plugin) + group_box.setToolTip('Add extra driver location') + folder_layout = QtWidgets.QFormLayout(group_box_plugin) + self.inputs_autolab[main_key] = {} + + addPluginButton = QtWidgets.QPushButton('Add driver folder') + addPluginButton.setIcon(icons['add']) + addPluginButton.clicked.connect(lambda: self.addOptionClicked( + folder_layout, self.inputs_autolab, 'extra_driver_path', + 'onedrive', + r'C:\Users\username\OneDrive\my_drivers')) + group_layout.addWidget(addPluginButton) + + for sub_key, saved_value in autolab_config[main_key].items(): + self.addOptionClicked( + folder_layout, self.inputs_autolab, main_key, sub_key, saved_value) + + ## extra_driver_url_repo + main_key = 'extra_driver_url_repo' + group_box = QtWidgets.QFrame() + group_layout = QtWidgets.QVBoxLayout(group_box) + group_layout.setContentsMargins(0,0,0,0) + layoutAutolab.addWidget(group_box) + group_box_plugin = QtWidgets.QGroupBox(main_key) + group_layout.addWidget(group_box_plugin) + group_box.setToolTip('Add extra url to download drivers from') + url_layout = QtWidgets.QFormLayout(group_box_plugin) + self.inputs_autolab[main_key] = {} + + addPluginButton = QtWidgets.QPushButton('Add driver url') + addPluginButton.setIcon(icons['add']) + addPluginButton.clicked.connect(lambda: self.addOptionClicked( + url_layout, self.inputs_autolab, 'extra_driver_url_repo', + r'C:\Users\username\OneDrive\my_drivers', + r'https://github.com/my_repo/my_drivers')) + group_layout.addWidget(addPluginButton) + + for sub_key, saved_value in autolab_config[main_key].items(): + self.addOptionClicked( + url_layout, self.inputs_autolab, main_key, sub_key, saved_value) + + ### Plotter + ## plugin + main_key = 'plugin' + group_box = QtWidgets.QFrame() + group_layout = QtWidgets.QVBoxLayout(group_box) + group_layout.setContentsMargins(0,0,0,0) + group_layout.setSpacing(0) + layoutPlotter.addWidget(group_box) + group_box_plugin = QtWidgets.QGroupBox(main_key) + group_layout.addWidget(group_box_plugin) + group_box_plugin.setToolTip('Add plugins to plotter') + plugin_layout = QtWidgets.QFormLayout(group_box_plugin) + self.inputs_plotter[main_key] = {} + + addPluginButton = QtWidgets.QPushButton('Add plugin') + addPluginButton.setIcon(icons['add']) + addPluginButton.clicked.connect(lambda: self.addOptionClicked( + plugin_layout, self.inputs_plotter, 'plugin', 'plugin', 'plotter')) + group_layout.addWidget(addPluginButton) + + for sub_key, saved_value in plotter_config[main_key].items(): + self.addOptionClicked( + plugin_layout, self.inputs_plotter, main_key, sub_key, saved_value) + # To disable plotter modification + input_key_widget, input_widget = self.inputs_plotter[main_key][sub_key] + if sub_key == 'plotter': + input_key_widget.setEnabled(False) + input_widget.setEnabled(False) + + ## device + main_key = 'device' + group_box = QtWidgets.QGroupBox(main_key) + layoutPlotter.addWidget(group_box) + group_layout = QtWidgets.QFormLayout(group_box) + self.inputs_plotter[main_key] = {} + + sub_key = 'address' + saved_value = plotter_config[main_key][sub_key] + input_widget = QtWidgets.QLineEdit() + input_widget.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + input_widget.setToolTip('Select the address of a device variable to be captured by the plotter.') + input_widget.setText(str(saved_value)) + self.inputs_plotter[main_key][sub_key] = input_widget + group_layout.addRow(QtWidgets.QLabel(sub_key), input_widget) + + ### Buttons + button_box = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok + | QtWidgets.QDialogButtonBox.Cancel + | QtWidgets.QDialogButtonBox.Apply, + self) + + layout.addWidget(button_box) + button_box.setContentsMargins(6,6,6,6) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + apply_button = button_box.button(QtWidgets.QDialogButtonBox.Apply) + apply_button.clicked.connect(self.apply) + + def addOptionClicked(self, main_layout: QtWidgets.QLayout, + main_inputs: dict, main_key: str, sub_key: str, val: str): + """ Add new option layout """ + basename = sub_key + names = main_inputs[main_key].keys() + compt = 0 + while True: + if sub_key in names: + compt += 1 + sub_key = basename + '_' + str(compt) + else: + break + + layout = QtWidgets.QHBoxLayout() + input_key_widget = QtWidgets.QLineEdit() + input_key_widget.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + input_key_widget.setText(sub_key) + layout.addWidget(input_key_widget) + input_widget = QtWidgets.QLineEdit() + input_widget.setText(val) + layout.addWidget(input_widget) + button_widget = QtWidgets.QPushButton() + button_widget.setIcon(icons['remove']) + button_widget.clicked.connect(lambda: self.removeOptionClicked( + layout, main_inputs, main_key, sub_key)) + layout.addWidget(button_widget) + + main_inputs[main_key][sub_key] = (input_key_widget, input_widget) + main_layout.addRow(layout) + + def removeOptionClicked(self, layout: QtWidgets.QLayout, + main_inputs: dict, main_key: str, + sub_key: str): + """ Remove optional argument layout """ + for j in reversed(range(layout.count())): + layout.itemAt(j).widget().setParent(None) + layout.setParent(None) + main_inputs[main_key].pop(sub_key) + + def accept(self): + try: + self.generate_new_configs() + except Exception as e: + self.setStatus(f'Config error: {e}', 10000, False) + else: + self.close() + + def apply(self): + try: + self.generate_new_configs() + except Exception as e: + self.setStatus(f'Config error: {e}', 10000, False) + + def reject(self): + self.close() + + @staticmethod + def generate_new_dict(input_widgets: Dict[str, QtWidgets.QWidget]) -> dict: + + new_dict = {} + for main_key, sub_dict in input_widgets.items(): + new_dict[main_key] = {} + for sub_key, widget in sub_dict.items(): + if isinstance(widget, QtWidgets.QSpinBox): + new_dict[main_key][sub_key] = widget.value() + elif isinstance(widget, QtWidgets.QDoubleSpinBox): + new_dict[main_key][sub_key] = widget.value() + elif isinstance(widget, QtWidgets.QCheckBox): + new_dict[main_key][sub_key] = widget.isChecked() + elif isinstance(widget, QtWidgets.QComboBox): + new_dict[main_key][sub_key] = widget.currentText() + elif isinstance(widget, tuple): + key_widget, value_widget = widget + new_dict[main_key][key_widget.text()] = value_widget.text() + else: + new_dict[main_key][sub_key] = widget.text() + + return new_dict + + def generate_new_configs(self): + new_autolab_dict = self.generate_new_dict(self.inputs_autolab) + new_autolab_config = modify_config('autolab_config', new_autolab_dict) + autolab_config = load_config('autolab_config') + + if new_autolab_config != autolab_config: + change_autolab_config(new_autolab_config) + + new_plotter_dict = self.generate_new_dict(self.inputs_plotter) + new_plotter_config = modify_config('plotter_config', new_plotter_dict) + plotter_config = load_config('plotter_config') + + if new_plotter_config != plotter_config: + change_plotter_config(new_plotter_config) + + if (new_autolab_config['directories']['temp_folder'] + != autolab_config['directories']['temp_folder']): + set_temp_folder() + + if (new_autolab_config['control_center']['logger'] != autolab_config['control_center']['logger'] + and hasattr(self.mainGui, 'activate_logger')): + self.mainGui.activate_logger(new_autolab_config['control_center']['logger']) + + if (new_autolab_config['control_center']['console'] != autolab_config['control_center']['console'] + and hasattr(self.mainGui, 'activate_console')): + self.mainGui.activate_console(new_autolab_config['control_center']['console']) + + if (new_autolab_config['GUI']['qt_api'] != autolab_config['GUI']['qt_api'] + or new_autolab_config['GUI']['theme'] != autolab_config['GUI']['theme'] + or new_autolab_config['GUI']['font_size'] != autolab_config['GUI']['font_size'] + or new_autolab_config['GUI']['image_background'] != autolab_config['GUI']['image_background'] + or new_autolab_config['GUI']['image_background'] != autolab_config['GUI']['image_background'] + ): + QtWidgets.QMessageBox.information( + self, + 'Information', + 'One or more of the settings you changed requires a restart to be applied', + QtWidgets.QMessageBox.Ok + ) + + def setStatus(self, message: str, timeout: int = 0, stdout: bool = True): + """ Modify the message displayed in the status bar and add error message to logger """ + self.statusBar.showMessage(message, timeout) + if not stdout: print(message, file=sys.stderr) + + def closeEvent(self, event): + """ Does some steps before the window is really killed """ + clearPreferences() + + if not self.mainGui: + QtWidgets.QApplication.quit() # close the app diff --git a/autolab/core/gui/slider.py b/autolab/core/gui/GUI_slider.py similarity index 76% rename from autolab/core/gui/slider.py rename to autolab/core/gui/GUI_slider.py index 5feffe65..443297dd 100644 --- a/autolab/core/gui/slider.py +++ b/autolab/core/gui/GUI_slider.py @@ -4,15 +4,18 @@ @author: jonathan """ -from typing import Any +from typing import Any, Union +import sys import numpy as np -from qtpy import QtCore, QtWidgets, QtGui +from qtpy import QtCore, QtWidgets from .icons import icons from .GUI_utilities import get_font_size, setLineEditBackground -from .. import config - +from .GUI_instances import clearSlider +from ..config import get_control_center_config +from ..elements import Variable as Variable_og +from ..variables import Variable if hasattr(QtCore.Qt.LeftButton, 'value'): LeftButton = QtCore.Qt.LeftButton.value @@ -20,34 +23,50 @@ LeftButton = QtCore.Qt.LeftButton +class ProxyStyle(QtWidgets.QProxyStyle): + """ https://stackoverflow.com/questions/67299834/pyqt-slider-not-come-to-a-specific-location-where-i-click-but-move-to-a-certain """ + def styleHint(self, hint, opt=None, widget=None, returnData=None): + res = super().styleHint(hint, opt, widget, returnData) + if hint == QtWidgets.QStyle.SH_Slider_AbsoluteSetButtons: + res |= LeftButton + return res + + class Slider(QtWidgets.QMainWindow): - changed = QtCore.Signal() + changed = QtCore.Signal() # Used by scanner to update filter when slider value changes - def __init__(self, var: Any, item: QtWidgets.QTreeWidgetItem = None): + def __init__(self, + variable: Union[Variable, Variable_og], + gui: QtWidgets.QMainWindow = None, + item: QtWidgets.QTreeWidgetItem = None): """ https://stackoverflow.com/questions/61717896/pyqt5-qslider-is-off-by-one-depending-on-which-direction-the-slider-is-moved """ - - self.is_main = not isinstance(item, QtWidgets.QTreeWidgetItem) - super().__init__() - self.variable = var + self.gui = gui # gui can have setStatus, threadManager + self.variable = variable self.item = item - self.main_gui = self.item.gui if hasattr(self.item, 'gui') else None + super().__init__() self.resize(self.minimumSizeHint()) self.setWindowTitle(self.variable.address()) - self.setWindowIcon(QtGui.QIcon(icons['slider'])) + self.setWindowIcon(icons['slider']) # Load configuration - control_center_config = config.get_control_center_config() - self.precision = int(control_center_config['precision']) + control_center_config = get_control_center_config() + self.precision = int(float(control_center_config['precision'])) - self._font_size = get_font_size() + 1 + self._font_size = get_font_size() # Slider self.slider_instantaneous = True + self._last_moved = False # Prevent double setting/readding after a slider has been moved with the slider_instantaneous=True - self.true_min = self.variable.type(0) - self.true_max = self.variable.type(10) - self.true_step = self.variable.type(1) + if self.is_writable(): + self.true_min = self.variable.type(0) + self.true_max = self.variable.type(10) + self.true_step = self.variable.type(1) + else: + self.true_min = 0 + self.true_max = 10 + self.true_step = 1 centralWidget = QtWidgets.QWidget() layoutWindow = QtWidgets.QVBoxLayout() @@ -61,7 +80,9 @@ def __init__(self, var: Any, item: QtWidgets.QTreeWidgetItem = None): layoutWindow.addLayout(layoutBottomValues) self.instantCheckBox = QtWidgets.QCheckBox() - self.instantCheckBox.setToolTip("True: Changes instantaneously the value.\nFalse: Changes the value when click released.") + self.instantCheckBox.setToolTip( + "True: Changes instantaneously the value.\n" \ + "False: Changes the value when click released.") self.instantCheckBox.setCheckState(QtCore.Qt.Checked) self.instantCheckBox.stateChanged.connect(self.instantChanged) @@ -136,9 +157,25 @@ def __init__(self, var: Any, item: QtWidgets.QTreeWidgetItem = None): self.resize(self.minimumSizeHint()) + def displayError(self, e: str): + """ Wrapper to display errors """ + self.gui.setStatus(e, 10000, False) if self.gui and hasattr( + self.gui, 'setStatus') else print(e, file=sys.stderr) + + def setVariableValue(self, value: Any): + """ Wrapper to change variable value """ + if self.gui and hasattr(self.gui, 'threadManager') and self.item: + self.gui.threadManager.start( + self.item, 'write', value=value) + else: + self.variable(value) + + def is_writable(self): + return self.variable.writable and self.variable.type in (int, float) + def updateStep(self): - if self.variable.type in (int, float): + if self.is_writable(): slider_points = 1 + int( np.floor((self.true_max - self.true_min) / self.true_step)) self.true_max = self.variable.type( @@ -162,7 +199,7 @@ def updateStep(self): def updateTrueValue(self, old_true_value: Any): - if self.variable.type in (int, float): + if self.is_writable(): new_cursor_step = round( (old_true_value - self.true_min) / self.true_step) slider_points = 1 + int( @@ -185,17 +222,15 @@ def updateTrueValue(self, old_true_value: Any): def stepWidgetValueChanged(self): - if self.variable.type in (int, float): + if self.is_writable(): old_true_value = self.variable.type(self.valueWidget.text()) try: true_step = self.variable.type(self.stepWidget.text()) assert true_step != 0, "Can't have step=0" self.true_step = true_step except Exception as e: - if self.main_gui: - self.main_gui.setStatus( - f"Variable {self.variable.name}: {e}", 10000, False) - # OPTIMIZE: else print ? + e = f"Variable {self.variable.address()}: {e}" + self.displayError(e) else: self.updateStep() self.updateTrueValue(old_true_value) @@ -203,14 +238,13 @@ def stepWidgetValueChanged(self): def minWidgetValueChanged(self): - if self.variable.type in (int, float): + if self.is_writable(): old_true_value = self.variable.type(self.valueWidget.text()) try: self.true_min = self.variable.type(self.minWidget.text()) except Exception as e: - if self.main_gui: - self.main_gui.setStatus( - f"Variable {self.variable.name}: {e}", 10000, False) + e = f"Variable {self.variable.address()}: {e}" + self.displayError(e) else: self.updateStep() self.updateTrueValue(old_true_value) @@ -218,14 +252,13 @@ def minWidgetValueChanged(self): def maxWidgetValueChanged(self): - if self.variable.type in (int, float): + if self.is_writable(): old_true_value = self.variable.type(self.valueWidget.text()) try: self.true_max = self.variable.type(self.maxWidget.text()) except Exception as e: - if self.main_gui: - self.main_gui.setStatus( - f"Variable {self.variable.name}: {e}", 10000, False) + e = f"Variable {self.variable.address()}: {e}" + self.displayError(e) else: self.updateStep() self.updateTrueValue(old_true_value) @@ -233,35 +266,32 @@ def maxWidgetValueChanged(self): def sliderReleased(self): """ Do something when the cursor is released """ - if self.variable.type in (int, float): + if self.is_writable(): + if self.slider_instantaneous and self._last_moved: + self._last_moved = False + return None + self._last_moved = False value = self.sliderWidget.value() true_value = self.variable.type( value*self.true_step + self.true_min) self.valueWidget.setText(f'{true_value:.{self.precision}g}') setLineEditBackground(self.valueWidget, 'synced', self._font_size) - if self.main_gui and hasattr(self.main_gui, 'threadManager'): - self.main_gui.threadManager.start( - self.item, 'write', value=true_value) - else: - self.variable(true_value) - + self.setVariableValue(true_value) self.changed.emit() self.updateStep() - else: self.badType() + else: + self.badType() def valueChanged(self, value: Any): """ Do something with the slider value when the cursor is moved """ - if self.variable.type in (int, float): + if self.is_writable(): + self._last_moved = True true_value = self.variable.type( value*self.true_step + self.true_min) self.valueWidget.setText(f'{true_value:.{self.precision}g}') if self.slider_instantaneous: setLineEditBackground(self.valueWidget, 'synced', self._font_size) - if self.main_gui and hasattr(self.main_gui, 'threadManager'): - self.main_gui.threadManager.start( - self.item, 'write', value=true_value) - else: - self.variable(true_value) + self.setVariableValue(true_value) self.changed.emit() else: setLineEditBackground(self.valueWidget, 'edited', self._font_size) @@ -273,10 +303,12 @@ def instantChanged(self, value): def minusClicked(self): self.sliderWidget.setSliderPosition(self.sliderWidget.value()-1) + self._last_moved = False if not self.slider_instantaneous: self.sliderReleased() def plusClicked(self): self.sliderWidget.setSliderPosition(self.sliderWidget.value()+1) + self._last_moved = False if not self.slider_instantaneous: self.sliderReleased() def badType(self): @@ -285,18 +317,12 @@ def badType(self): setLineEditBackground(self.stepWidget, 'edited', self._font_size) setLineEditBackground(self.maxWidget, 'edited', self._font_size) + e = f"Variable {self.variable.address()}: Variable should be writable int or float" + self.displayError(e) + def closeEvent(self, event): """ This function does some steps before the window is really killed """ - if hasattr(self.item, 'clearSlider'): self.item.clearSlider() + clearSlider(self.variable) - if self.is_main: + if self.gui is None: QtWidgets.QApplication.quit() # close the slider app - - -class ProxyStyle(QtWidgets.QProxyStyle): - """ https://stackoverflow.com/questions/67299834/pyqt-slider-not-come-to-a-specific-location-where-i-click-but-move-to-a-certain """ - def styleHint(self, hint, opt=None, widget=None, returnData=None): - res = super().styleHint(hint, opt, widget, returnData) - if hint == QtWidgets.QStyle.SH_Slider_AbsoluteSetButtons: - res |= LeftButton - return res diff --git a/autolab/core/gui/GUI_utilities.py b/autolab/core/gui/GUI_utilities.py index 4b2360a1..3f41fb3e 100644 --- a/autolab/core/gui/GUI_utilities.py +++ b/autolab/core/gui/GUI_utilities.py @@ -5,16 +5,24 @@ @author: jonathan """ -from typing import Tuple +import re import os import sys +from typing import Tuple, List, Union, Callable +from collections import defaultdict +import inspect import numpy as np +import pandas as pd from qtpy import QtWidgets, QtCore, QtGui import pyqtgraph as pg +from .icons import icons +from ..paths import PATHS from ..config import get_GUI_config - +from ..devices import DEVICES, get_element_by_address +from ..variables import has_eval, EVAL, VARIABLES +from ..utilities import SUPPORTED_EXTENSION # Fixes pyqtgraph/issues/3018 for pg<=0.13.7 (before pyqtgraph/pull/3070) from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem @@ -35,11 +43,10 @@ def _fourierTransform_fixed(self, x, y): def get_font_size() -> int: GUI_config = get_GUI_config() - if GUI_config['font_size'] != 'default': - font_size = int(GUI_config['font_size']) - else: - font_size = QtWidgets.QApplication.instance().font().pointSize() - return font_size + + return (int(float(GUI_config['font_size'])) + if GUI_config['font_size'] != 'default' + else QtWidgets.QApplication.instance().font().pointSize()) def setLineEditBackground(obj, state: str, font_size: int = None): @@ -48,13 +55,12 @@ def setLineEditBackground(obj, state: str, font_size: int = None): if state == 'edited': color='#FFE5AE' # orange if font_size is None: - obj.setStyleSheet( - "QLineEdit:enabled {background-color: %s}" % ( + "QLineEdit:enabled {background-color: %s; color: #000000;}" % ( color)) else: obj.setStyleSheet( - "QLineEdit:enabled {background-color: %s; font-size: %ipt}" % ( + "QLineEdit:enabled {background-color: %s; font-size: %ipt; color: #000000;}" % ( color, font_size)) @@ -70,9 +76,12 @@ def qt_object_exists(QtObject) -> bool: if not CHECK_ONCE: return True try: - if QT_API in ("pyqt5", "pyqt6"): + if QT_API == "pyqt5": import sip return not sip.isdeleted(QtObject) + if QT_API == "pyqt6": + from PyQt6 import sip + return not sip.isdeleted(QtObject) if QT_API == "pyside2": import shiboken2 return shiboken2.isValid(QtObject) @@ -98,8 +107,10 @@ def __init__(self): ax = self.addPlot() self.ax = ax - ax.setLabel("bottom", ' ', **{'color':0.4, 'font-size': '12pt'}) - ax.setLabel("left", ' ', **{'color':0.4, 'font-size': '12pt'}) + ax.setLabel("bottom", ' ', **{'color': pg.getConfigOption("foreground"), + 'font-size': '12pt'}) + ax.setLabel("left", ' ', **{'color': pg.getConfigOption("foreground"), + 'font-size': '12pt'}) # Set your custom font for both axes my_font = QtGui.QFont('Arial', 12) @@ -109,14 +120,14 @@ def __init__(self): ax.getAxis("bottom").setTickFont(my_font_tick) ax.getAxis("left").setTickFont(my_font_tick) ax.showGrid(x=True, y=True) - ax.setContentsMargins(10., 10., 10., 10.) vb = ax.getViewBox() vb.enableAutoRange(enable=True) - vb.setBorder(pg.mkPen(color=0.4)) + vb.setBorder(pg.mkPen(color=pg.getConfigOption("foreground"))) ## Text label for the data coordinates of the mouse pointer - dataLabel = pg.LabelItem(color='k', parent=ax.getAxis('bottom')) + dataLabel = pg.LabelItem(color=pg.getConfigOption("foreground"), + parent=ax.getAxis('bottom')) dataLabel.anchor(itemPos=(1,1), parentPos=(1,1), offset=(0,0)) def mouseMoved(point): @@ -205,6 +216,13 @@ def update_img(self, x, y, z): self.img = img self.ax.addItem(self.img) + def dragLeaveEvent(self, event): + # Pyside6 triggers a leave event resuling in error: + # QGraphicsView::dragLeaveEvent: drag leave received before drag enter + pass + # super().dragLeaveEvent(event) + + def pyqtgraph_fig_ax() -> Tuple[MyGraphicsLayoutWidget, pg.PlotItem]: """ Return a formated fig and ax pyqtgraph for a basic plot """ @@ -223,7 +241,7 @@ def __init__(self, *args, **kwargs): self.figLineROI, self.axLineROI = pyqtgraph_fig_ax() self.figLineROI.hide() - self.plot = self.axLineROI.plot([], [], pen='k') + self.plot = self.axLineROI.plot([], [], pen=pg.getConfigOption("foreground")) self.lineROI = pg.LineSegmentROI([[0, 100], [100, 100]], pen='r') self.lineROI.sigRegionChanged.connect(self.updateLineROI) @@ -274,6 +292,19 @@ def __init__(self, *args, **kwargs): centralWidget.setLayout(verticalLayoutMain) self.centralWidget = centralWidget + for splitter in (splitter, ): + for i in range(splitter.count()): + handle = splitter.handle(i) + handle.setStyleSheet("background-color: #DDDDDD;") + handle.installEventFilter(self) + + def eventFilter(self, obj, event): + if event.type() == QtCore.QEvent.Enter: + obj.setStyleSheet("background-color: #AAAAAA;") # Hover color + elif event.type() == QtCore.QEvent.Leave: + obj.setStyleSheet("background-color: #DDDDDD;") # Normal color + return super().eventFilter(obj, event) + def update_ticks(self): for tick in self.ui.histogram.gradient.ticks: tick.pen = pg.mkPen(pg.getConfigOption("foreground")) @@ -326,3 +357,533 @@ def pyqtgraph_image() -> Tuple[myImageView, QtWidgets.QWidget]: """ Return a formated ImageView and pyqtgraph widget for image plotting """ imageView = myImageView() return imageView, imageView.centralWidget + + +class MyLineEdit(QtWidgets.QLineEdit): + """https://stackoverflow.com/questions/28956693/pyqt5-qtextedit-auto-completion""" + + skip_has_eval = False + use_variables = True + use_devices = True + use_np_pd = True + completer: QtWidgets.QCompleter = None + + def __init__(self, *args): + super().__init__(*args) + + def create_keywords(self) -> List[str]: + """ Returns a list of all available keywords for completion """ + list_keywords = [] + + if self.use_variables: + list_keywords += list(VARIABLES) + ## Could use this to see attributes of Variable like .raw .value + # list_keywords += [f'{name}.{item}' + # for name, var in VARIABLES.items() + # for item in dir(var) + # if not item.startswith('_') and not item.isupper()] + if self.use_devices: + list_keywords += [str(get_element_by_address(elements[0]).address()) + for device in DEVICES.values() + if device.name not in list_keywords + for elements in device.get_structure()] + + if self.use_np_pd: + if 'np' not in list_keywords: + list_keywords += ['np'] + list_keywords += [f'np.{item}' + for item in dir(np) + if not item.startswith('_') and not item.isupper()] + + if 'pd' not in list_keywords: + list_keywords += ['pd'] + list_keywords += [f'pd.{item}' + for item in dir(pd) + if not item.startswith('_') and not item.isupper()] + return list_keywords + + def eventFilter(self, obj, event): + """ Used when installEventFilter active """ + if (event.type() == QtCore.QEvent.KeyPress + and event.key() == QtCore.Qt.Key_Tab): + self.keyPressEvent(event) + return True + return super().eventFilter(obj, event) + + def setCompleter(self, completer: QtWidgets.QCompleter): + """ Sets/removes completer """ + if self.completer: + if self.completer.popup().isVisible(): return None + self.completer.popup().close() + try: + self.completer.disconnect() # PyQT + except TypeError: + self.completer.disconnect(self) # Pyside + + if not completer: + self.removeEventFilter(self) + self.completer = None + return None + + self.installEventFilter(self) + completer.setWidget(self) + completer.setCompletionMode(QtWidgets.QCompleter.PopupCompletion) + self.completer = completer + self.completer.activated.connect(self.insertCompletion) + + def getCompletion(self) -> List[str]: + model = self.completer.model() + return model.stringList() if isinstance( + model, QtCore.QStringListModel) else [] + + def insertCompletion(self, completion: str, prefix: bool = True): + cursor_pos = self.cursorPosition() + text = self.text() + + prefix_length = (len(self.completer.completionPrefix()) + if (prefix and self.completer) else 0) + + # Replace the current word with the completion + new_text = (text[:cursor_pos - prefix_length] + + completion + + text[cursor_pos:]) + self.setText(new_text) + self.setCursorPosition(cursor_pos - prefix_length + len(completion)) + + def textUnderCursor(self) -> str: + text = self.text() + cursor_pos = self.cursorPosition() + start = text.rfind(' ', 0, cursor_pos) + 1 + return text[start:cursor_pos] + + def focusInEvent(self, event): + if self.completer: + self.completer.setWidget(self) + super().focusInEvent(event) + + def keyPressEvent(self, event): + controlPressed = event.modifiers() == QtCore.Qt.ControlModifier + tabPressed = event.key() == QtCore.Qt.Key_Tab + specialTabPressed = tabPressed and controlPressed + enterPressed = event.key() in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return) + specialEnterPressed = enterPressed and controlPressed + + if specialTabPressed: + self.insertCompletion('\t', prefix=False) + return None + + if specialEnterPressed: + self.insertCompletion('\n', prefix=False) + return None + + # Fixe issue if press control after an eval (issue appears if do self.completer = None) + if self.completer and controlPressed: + super().keyPressEvent(event) + return None + + if not self.completer or not tabPressed: + if (self.completer and enterPressed and self.completer.popup().isVisible()): + self.completer.activated.emit( + self.completer.popup().currentIndex().data()) + else: + super().keyPressEvent(event) + + if self.skip_has_eval or has_eval(self.text()): + if not self.completer: + self.setCompleter(QtWidgets.QCompleter(self.create_keywords())) + else: + if self.completer: + self.setCompleter(None) + + if self.completer and not tabPressed: + self.completer.popup().close() + + if not self.completer or not tabPressed: + return None + + completion_prefix = self.format_completion_prefix(self.textUnderCursor()) + + new_keywords = self.create_new_keywords( + self.create_keywords(), completion_prefix) + keywords = self.getCompletion() + + if new_keywords != keywords: + keywords = new_keywords + self.setCompleter(QtWidgets.QCompleter(keywords)) + + if completion_prefix != self.completer.completionPrefix(): + self.completer.setCompletionPrefix(completion_prefix) + self.completer.popup().setCurrentIndex( + self.completer.completionModel().index(0, 0)) + + if self.completer.completionModel().rowCount() == 1: + self.completer.setCompletionMode( + QtWidgets.QCompleter.InlineCompletion) + self.completer.complete() + + self.completer.activated.emit(self.completer.currentCompletion()) + self.completer.setCompletionMode( + QtWidgets.QCompleter.PopupCompletion) + else: + cr = self.cursorRect() + cr.setWidth(self.completer.popup().sizeHintForColumn(0) + + self.completer.popup().verticalScrollBar().sizeHint().width()) + self.completer.complete(cr) + + @staticmethod + def format_completion_prefix(completion_prefix: str) -> str: + """ Returns a simplified prefix for completion """ + if has_eval(completion_prefix): + completion_prefix = completion_prefix[len(EVAL):] + + pattern = r'[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*' + temp = [var for var in re.findall(pattern, completion_prefix)] + + if len(temp) > 0: + position = completion_prefix.rfind(temp[-1]) + if position != -1: + completion_prefix = completion_prefix[position:] + + special_char = ('(', '[', ',', ':', '-', '+', '^', '*', '/', '|') + if completion_prefix.endswith(special_char): + completion_prefix = '' + + return completion_prefix + + @staticmethod + def create_new_keywords(list_keywords: List[str], + completion_prefix: str) -> List[str]: + """ Returns a list with all available keywords and possible decomposition """ + # Create ordered list with all attributes and sub attributes + master_list = [] + master_list.append(list_keywords) + list_temp = list_keywords + while len(list_temp) > 1: + list_temp = list(set( + [var[:-len(var.split('.')[-1])-1] + for var in list_temp if len(var.split('.')) != 0])) + if '' in list_temp: list_temp.remove('') + master_list.append(list_temp) + + # Filter attributes that contained completion_prefix and remove doublons + flat_list = list(set(item + for sublist in master_list + for item in sublist + if item.startswith(completion_prefix))) + + # Group items by the number of dots + dot_groups = defaultdict(list) + for item in flat_list: + dot_count = item.count('.') + dot_groups[dot_count].append(item) + + # Sort the groups by the number of dots + sorted_groups = sorted( + dot_groups.items(), key=lambda x: x[0], reverse=True) + + # Extract items from each group and return as a list with sorted sublist + sorted_list = [sorted(group) for _, group in sorted_groups] + + # Create list of all available keywords and possible decomposition + new_keywords = [] + good = False + for level in reversed(sorted_list): + for item in level: + if completion_prefix in item: + new_keywords.append(item) + good = True + if good: break + + return new_keywords + + +class MyInputDialog(QtWidgets.QDialog): + + def __init__(self, parent: QtWidgets.QMainWindow, name: str): + + super().__init__(parent) + self.setWindowTitle(name) + + lineEdit = MyLineEdit() + lineEdit.setMaxLength(10000000) + self.lineEdit = lineEdit + + # Add OK and Cancel buttons + button_box = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, + self) + + # Connect buttons + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(QtWidgets.QLabel(f"Set {name} value")) + layout.addWidget(lineEdit) + layout.addWidget(button_box) + layout.addStretch() + layout.setContentsMargins(10, 5, 10, 10) + + self.textValue = lineEdit.text + self.setTextValue = lineEdit.setText + self.resize(self.minimumSizeHint()) + + def showEvent(self, event): + """Focus and select the text in the lineEdit.""" + super().showEvent(event) + self.lineEdit.setFocus() + self.lineEdit.selectAll() + + def closeEvent(self, event): + for children in self.findChildren(QtWidgets.QWidget): + children.deleteLater() + super().closeEvent(event) + + +class MyFileDialog(QtWidgets.QDialog): + + def __init__(self, parent: QtWidgets.QMainWindow, name: str, + mode: QtWidgets.QFileDialog): + + super().__init__(parent) + if mode == QtWidgets.QFileDialog.AcceptOpen: + self.setWindowTitle(f"Open file - {name}") + elif mode == QtWidgets.QFileDialog.AcceptSave: + self.setWindowTitle(f"Save file - {name}") + + file_dialog = QtWidgets.QFileDialog(self, QtCore.Qt.Widget) + file_dialog.setAcceptMode(mode) + file_dialog.setOption(QtWidgets.QFileDialog.DontUseNativeDialog) + file_dialog.setWindowFlags(file_dialog.windowFlags() & ~QtCore.Qt.Dialog) + file_dialog.setDirectory(PATHS['last_folder']) + file_dialog.setNameFilters(SUPPORTED_EXTENSION.split(";;")) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(file_dialog) + layout.addStretch() + layout.setSpacing(0) + layout.setContentsMargins(0,0,0,0) + + self.exec_ = file_dialog.exec_ + self.selectedFiles = file_dialog.selectedFiles + + def closeEvent(self, event): + for children in self.findChildren(QtWidgets.QWidget): + children.deleteLater() + + super().closeEvent(event) + + +class MyQCheckBox(QtWidgets.QCheckBox): + + def __init__(self, parent): + self.parent = parent + super().__init__() + + def mouseReleaseEvent(self, event): + super().mouseReleaseEvent(event) + self.parent.valueEdited() + try: + inspect.signature(self.parent.write) + except ValueError: pass # For built-in method (occurs for boolean for action parameter) + else: + self.parent.write() + + +class MyQComboBox(QtWidgets.QComboBox): + def __init__(self, parent=None): + super().__init__(parent) + self.readonly = False + self.wheel = True + self.key = True + + def mousePressEvent(self, event): + if not self.readonly: + super().mousePressEvent(event) + + def keyPressEvent(self, event): + if not self.readonly and self.key: + super().keyPressEvent(event) + + def wheelEvent(self, event): + if not self.readonly and self.wheel: + super().wheelEvent(event) + + +class CustomMenu(QtWidgets.QMenu): + """ Menu with action containing sub-menu for recipe and parameter selection """ + def __init__(self, + gui: QtWidgets.QMainWindow, + main_combobox: QtWidgets.QComboBox = None, + second_combobox: QtWidgets.QComboBox = None, + update_gui: Callable[[None], None] = None): + + super().__init__() + self.gui = gui # gui is scanner + self.main_combobox = main_combobox + self.second_combobox = second_combobox + self.update_gui = update_gui + + self.current_menu = 1 + self.selected_action = None + + self.recipe_names = [self.main_combobox.itemText(i) + for i in range(self.main_combobox.count()) + ] if self.main_combobox else [] + + self.HAS_RECIPE = len(self.recipe_names) > 1 + + self.HAS_PARAM = (self.second_combobox.count() > 1 + if self.second_combobox + else False) + + # To only show parameter if only one recipe + if not self.HAS_RECIPE and self.HAS_PARAM: + self.main_combobox = second_combobox + self.second_combobox = None + + self.recipe_names = [self.main_combobox.itemText(i) + for i in range(self.main_combobox.count()) + ] if self.main_combobox else [] + + + def addAnyAction(self, action_text='', icon_name='', + param_menu_active=False) -> Union[QtWidgets.QWidgetAction, + QtWidgets.QAction]: + + if self.HAS_RECIPE or (self.HAS_PARAM and param_menu_active): + action = self.addCustomAction(action_text, icon_name, + param_menu_active=param_menu_active) + else: + action = self.addAction(action_text) + if icon_name != '': + action.setIcon(icons[icon_name]) + + return action + + def addCustomAction(self, action_text='', icon_name='', + param_menu_active=False) -> QtWidgets.QWidgetAction: + """ Create an action with a sub menu for selecting a recipe and parameter """ + + def close_menu(): + self.selected_action = action_widget + self.close() + + def handle_hover(): + """ Fixe bad hover behavior and refresh radio_button """ + self.setActiveAction(action_widget) + recipe_name = self.main_combobox.currentText() + action = recipe_menu.actions()[self.recipe_names.index(recipe_name)] + radio_button = action.defaultWidget() + + if not radio_button.isChecked(): + radio_button.setChecked(True) + + def handle_radio_click(name): + """ Update parameters available, open parameter menu if available + and close main menu to validate the action """ + if self.current_menu == 1: + self.main_combobox.setCurrentIndex(self.recipe_names.index(name)) + if self.update_gui: + self.update_gui() + recipe_menu.close() + + if param_menu_active: + param_items = [self.second_combobox.itemText(i) + for i in range(self.second_combobox.count()) + ] if self.second_combobox else [] + + if len(param_items) > 1: + self.current_menu = 2 + setup_menu_parameter(param_menu) + return None + else: + update_parameter(name) + param_menu.close() + self.current_menu = 1 + action_button.setMenu(recipe_menu) + + close_menu() + + def reset_menu(button: QtWidgets.QToolButton): + QtWidgets.QApplication.sendEvent( + button, QtCore.QEvent(QtCore.QEvent.Leave)) + self.current_menu = 1 + action_button.setMenu(recipe_menu) + + def setup_menu_parameter(param_menu: QtWidgets.QMenu): + param_items = [self.second_combobox.itemText(i) + for i in range(self.second_combobox.count())] + param_name = self.second_combobox.currentText() + + param_menu.clear() + for param_name_i in param_items: + add_radio_button_to_menu(param_name_i, param_name, param_menu) + + action_button.setMenu(param_menu) + action_button.showMenu() + + def update_parameter(name: str): + param_items = [self.second_combobox.itemText(i) + for i in range(self.second_combobox.count())] + self.second_combobox.setCurrentIndex(param_items.index(name)) + if self.update_gui: + self.update_gui() + + def add_radio_button_to_menu(item_name: str, current_name: str, + target_menu: QtWidgets.QMenu): + widget = QtWidgets.QFrame() + radio_button = QtWidgets.QRadioButton(item_name, widget) + action = QtWidgets.QWidgetAction(self.gui) + action.setDefaultWidget(radio_button) + target_menu.addAction(action) + + if item_name == current_name: + radio_button.setChecked(True) + + radio_button.clicked.connect( + lambda: handle_radio_click(item_name)) + + # Add custom action + action_button = QtWidgets.QToolButton() + action_button.setPopupMode(QtWidgets.QToolButton.MenuButtonPopup) + action_button.setText(f" {action_text}") + action_button.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon) + action_button.setAutoRaise(True) + action_button.clicked.connect(close_menu) + action_button.enterEvent = lambda event: handle_hover() + if icon_name != '': + action_button.setIcon(icons[icon_name]) + + action_widget = QtWidgets.QWidgetAction(action_button) + action_widget.setDefaultWidget(action_button) + self.addAction(action_widget) + + recipe_menu = QtWidgets.QMenu() + # recipe_menu.aboutToShow.connect(lambda: self.set_clickable(False)) + recipe_menu.aboutToHide.connect(lambda: reset_menu(action_button)) + + if param_menu_active: + param_menu = QtWidgets.QMenu() + # param_menu.aboutToShow.connect(lambda: self.set_clickable(False)) + param_menu.aboutToHide.connect(lambda: reset_menu(action_button)) + + recipe_name = self.main_combobox.currentText() + + for recipe_name_i in self.recipe_names: + add_radio_button_to_menu(recipe_name_i, recipe_name, recipe_menu) + + action_button.setMenu(recipe_menu) + + return action_widget + + +class RecipeMenu(CustomMenu): + + def __init__(self, gui: QtWidgets.QMainWindow): # gui is scanner + + main_combobox = gui.selectRecipe_comboBox if gui else None + second_combobox = gui.selectParameter_comboBox if gui else None + update_gui = gui._updateSelectParameter if gui else None + + super().__init__(gui, main_combobox, second_combobox, update_gui) diff --git a/autolab/core/gui/GUI_variables.py b/autolab/core/gui/GUI_variables.py new file mode 100644 index 00000000..a6b1cb95 --- /dev/null +++ b/autolab/core/gui/GUI_variables.py @@ -0,0 +1,432 @@ +# -*- coding: utf-8 -*- +""" +Created on Mon Mar 4 14:54:41 2024 + +@author: Jonathan +""" + +from typing import Union +import sys +import re + +import numpy as np +import pandas as pd +from qtpy import QtCore, QtWidgets, QtGui + +from .GUI_utilities import setLineEditBackground, MyLineEdit +from .icons import icons +from ..devices import DEVICES +from ..utilities import data_to_str, str_to_data, clean_string +from ..variables import (VARIABLES, get_variable, set_variable, Variable, + rename_variable, remove_variable, is_Variable, + has_variable, has_eval, eval_variable, EVAL) +from ..elements import Variable as Variable_og +from ..devices import get_element_by_address +from .GUI_instances import (openMonitor, openSlider, openPlotter, + closeMonitors, closeSliders, closePlotter, + clearVariablesMenu) + + +class VariablesMenu(QtWidgets.QMainWindow): + + variableSignal = QtCore.Signal(object) + deviceSignal = QtCore.Signal(object) + + def __init__(self, has_parent: bool = False): + + super().__init__() + self.has_parent = has_parent # Only for closeEvent + self.setWindowTitle('AUTOLAB - Variables Menu') + self.setWindowIcon(icons['variables']) + + self.statusBar = self.statusBar() + + # Main widgets creation + self.variablesWidget = QtWidgets.QTreeWidget(self) + self.variablesWidget.setHeaderLabels( + ['', 'Name', 'Value', 'Evaluated value', 'Type', 'Action']) + self.variablesWidget.setAlternatingRowColors(True) + self.variablesWidget.setIndentation(0) + header = self.variablesWidget.header() + header.setMinimumSectionSize(20) + header.resizeSection(0, 20) + header.resizeSection(1, 90) + header.resizeSection(2, 120) + header.resizeSection(3, 120) + header.resizeSection(4, 50) + header.resizeSection(5, 100) + self.variablesWidget.itemDoubleClicked.connect(self.variableActivated) + self.variablesWidget.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection) + self.variablesWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.variablesWidget.customContextMenuRequested.connect(self.rightClick) + + addButton = QtWidgets.QPushButton('Add') + addButton.clicked.connect(self.addVariableAction) + + removeButton = QtWidgets.QPushButton('Remove') + removeButton.clicked.connect(self.removeVariableAction) + + self.devicesWidget = QtWidgets.QTreeWidget(self) + self.devicesWidget.setHeaderLabels(['Name']) + self.devicesWidget.setAlternatingRowColors(True) + self.devicesWidget.setIndentation(10) + self.devicesWidget.itemDoubleClicked.connect(self.deviceActivated) + self.devicesWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.devicesWidget.customContextMenuRequested.connect(self.rightClickDevice) + + # Main layout creation + layoutWindow = QtWidgets.QVBoxLayout() + layoutWindow.setContentsMargins(0,0,0,0) + layoutWindow.setSpacing(0) + layoutTab = QtWidgets.QVBoxLayout() + layoutWindow.addLayout(layoutTab) + + centralWidget = QtWidgets.QWidget() + centralWidget.setLayout(layoutWindow) + self.setCentralWidget(centralWidget) + + refreshButtonWidget = QtWidgets.QPushButton() + refreshButtonWidget.setText('Refresh Manager') + refreshButtonWidget.clicked.connect(self.refresh) + + # Main layout definition + layoutButton = QtWidgets.QHBoxLayout() + layoutButton.addWidget(addButton) + layoutButton.addWidget(removeButton) + layoutButton.addStretch() + + frameVariables = QtWidgets.QFrame() + layoutVariables = QtWidgets.QVBoxLayout(frameVariables) + layoutVariables.addWidget(self.variablesWidget) + layoutVariables.addLayout(layoutButton) + + frameDevices = QtWidgets.QFrame() + layoutDevices = QtWidgets.QVBoxLayout(frameDevices) + layoutDevices.addWidget(self.devicesWidget) + + tab = QtWidgets.QTabWidget(self) + tab.addTab(frameVariables, 'Variables') + tab.addTab(frameDevices, 'Devices') + + layoutTab.addWidget(tab) + layoutTab.addWidget(refreshButtonWidget) + + self.resize(550, 300) + self.refresh() + + # self.timer = QtCore.QTimer(self) + # self.timer.setInterval(400) # ms + # self.timer.timeout.connect(self.refresh_new) + # self.timer.start() + # VARIABLES.removeVarSignal.remove.connect(self.removeVarSignalChanged) + # VARIABLES.addVarSignal.add.connect(self.addVarSignalChanged) + + def variableActivated(self, item: QtWidgets.QTreeWidgetItem): + self.variableSignal.emit(item.name) + + def rightClick(self, position: QtCore.QPoint): + """ Provides a menu where the user right clicked to manage a variable """ + item = self.variablesWidget.itemAt(position) + if hasattr(item, 'menu'): item.menu(position) + + def rightClickDevice(self, position: QtCore.QPoint): + """ Provides a menu where the user right clicked to manage a variable """ + item = self.devicesWidget.itemAt(position) + if hasattr(item, 'menu'): item.menu(position) + + def deviceActivated(self, item: QtWidgets.QTreeWidgetItem): + if hasattr(item, 'name'): self.deviceSignal.emit(item.name) + + def removeVariableAction(self): + for variableItem in self.variablesWidget.selectedItems(): + remove_variable(variableItem.name) + self.removeVariableItem(variableItem) + + # def addVariableItem(self, name): + # MyQTreeWidgetItem(self.variablesWidget, name, self) + + def removeVariableItem(self, item: QtWidgets.QTreeWidgetItem): + index = self.variablesWidget.indexFromItem(item) + self.variablesWidget.takeTopLevelItem(index.row()) + + def addVariableAction(self): + basename = 'var' + name = basename + names = list(VARIABLES) + + compt = 0 + while True: + if name in names: + compt += 1 + name = basename + str(compt) + else: + break + + variable = set_variable(name, 0) + + MyQTreeWidgetItem(self.variablesWidget, name, variable, self) # not catched by VARIABLES signal + + # def addVarSignalChanged(self, key, value): + # print('got add signal', key, value) + # all_items = [self.variablesWidget.topLevelItem(i) for i in range( + # self.variablesWidget.topLevelItemCount())] + + # for variableItem in all_items: + # if variableItem.name == key: + # variableItem.raw_value = get_variable(variableItem.name) + # variableItem.refresh_rawValue() + # variableItem.refresh_value() + # break + # else: + # self.addVariableItem(key) + # # self.refresh() # TODO: check if item exists, create if not, update if yes + + # def removeVarSignalChanged(self, key): + # print('got remove signal', key) + # all_items = [self.variablesWidget.topLevelItem(i) for i in range( + # self.variablesWidget.topLevelItemCount())] + + # for variableItem in all_items: + # if variableItem.name == key: + # self.removeVariableItem(variableItem) + + # # self.refresh() # TODO: check if exists, remove if yes + + def refresh(self): + self.variablesWidget.clear() + for var_name in VARIABLES: + variable = get_variable(var_name) + MyQTreeWidgetItem(self.variablesWidget, var_name, variable, self) + + self.devicesWidget.clear() + for device_name, device in DEVICES.items(): + deviceItem = QtWidgets.QTreeWidgetItem( + self.devicesWidget, [device_name]) + deviceItem.setBackground(0, QtGui.QColor('#9EB7F5')) # blue + deviceItem.setExpanded(True) + for elements in device.get_structure(): + var = get_element_by_address(elements[0]) + MyQTreeWidgetItem(deviceItem, var.address(), var, self) + + def setStatus(self, message: str, timeout: int = 0, stdout: bool = True): + """ Modify the message displayed in the status bar and add error message to logger """ + self.statusBar.showMessage(message, timeout) + if not stdout: print(message, file=sys.stderr) + + def closeEvent(self, event): + # self.timer.stop() + clearVariablesMenu() + + self.variablesWidget.clear() + self.devicesWidget.clear() + + if not self.has_parent: + closePlotter() + closeMonitors() + closeSliders() + + import pyqtgraph as pg + try: + # Prevent 'RuntimeError: wrapped C/C++ object of type ViewBox has been deleted' when reloading gui + for view in pg.ViewBox.AllViews.copy().keys(): + pg.ViewBox.forgetView(id(view), view) + # OPTIMIZE: forget only view used in monitor/gui + pg.ViewBox.quit() + except: pass + + for children in self.findChildren(QtWidgets.QWidget): + children.deleteLater() + + super().closeEvent(event) + + if not self.has_parent: + QtWidgets.QApplication.quit() # close the app + + +class MyQTreeWidgetItem(QtWidgets.QTreeWidgetItem): + + def __init__(self, + itemParent: Union[QtWidgets.QTreeWidget, + QtWidgets.QTreeWidgetItem], + name: str, + variable: Union[Variable, Variable_og], + gui: QtWidgets.QMainWindow): + + self.name = name + self.variable = variable + self.gui = gui + + if is_Variable(self.variable): + super().__init__(itemParent, ['', name]) + else: + super().__init__(itemParent, [name]) + return None + + nameWidget = QtWidgets.QLineEdit() + nameWidget.setText(name) + nameWidget.setAlignment(QtCore.Qt.AlignCenter) + nameWidget.returnPressed.connect(self.renameVariable) + nameWidget.textEdited.connect(lambda: setLineEditBackground( + nameWidget, 'edited')) + setLineEditBackground(nameWidget, 'synced') + self.gui.variablesWidget.setItemWidget(self, 1, nameWidget) + self.nameWidget = nameWidget + + rawValueWidget = MyLineEdit() + rawValueWidget.setMaxLength(10000000) + rawValueWidget.setAlignment(QtCore.Qt.AlignCenter) + rawValueWidget.returnPressed.connect(self.changeRawValue) + rawValueWidget.textEdited.connect(lambda: setLineEditBackground( + rawValueWidget, 'edited')) + self.gui.variablesWidget.setItemWidget(self, 2, rawValueWidget) + self.rawValueWidget = rawValueWidget + + valueWidget = QtWidgets.QLineEdit() + valueWidget.setMaxLength(10000000) + valueWidget.setReadOnly(True) + palette = valueWidget.palette() + palette.setColor(QtGui.QPalette.Base, + palette.color(QtGui.QPalette.Base).darker(107)) + valueWidget.setPalette(palette) + valueWidget.setAlignment(QtCore.Qt.AlignCenter) + self.gui.variablesWidget.setItemWidget(self, 3, valueWidget) + self.valueWidget = valueWidget + + typeWidget = QtWidgets.QLabel() + typeWidget.setAlignment(QtCore.Qt.AlignCenter) + self.gui.variablesWidget.setItemWidget(self, 4, typeWidget) + self.typeWidget = typeWidget + + self.actionButtonWidget = None + + self.refresh_rawValue() + self.refresh_value() + + def menu(self, position: QtCore.QPoint): + """ This function provides the menu when the user right click on an item """ + menu = QtWidgets.QMenu() + monitoringAction = menu.addAction("Start monitoring") + monitoringAction.setIcon(icons['monitor']) + monitoringAction.setEnabled( + (hasattr(self.variable, 'readable') # Action don't have readable + and self.variable.readable + and self.variable.type in (int, float, np.ndarray, pd.DataFrame) + ) or ( + is_Variable(self.variable) + and (has_eval(self.variable.raw) or isinstance( + self.variable.value, (int, float, np.ndarray, pd.DataFrame))) + )) + + plottingAction = menu.addAction("Capture to plotter") + plottingAction.setIcon(icons['plotter']) + plottingAction.setEnabled(monitoringAction.isEnabled()) + + menu.addSeparator() + sliderAction = menu.addAction("Create a slider") + sliderAction.setIcon(icons['slider']) + sliderAction.setEnabled( + (hasattr(self.variable, 'writable') + and self.variable.writable + and self.variable.type in (int, float))) + + choice = menu.exec_( + self.gui.variablesWidget.viewport().mapToGlobal(position)) + if choice == monitoringAction: + openMonitor(self.variable, has_parent=True) + if choice == plottingAction: + openPlotter(variable=self.variable, has_parent=True) + elif choice == sliderAction: + openSlider(self.variable, gui=self.gui, item=self) + + def renameVariable(self) -> None: + new_name = self.nameWidget.text() + if new_name == self.name: + setLineEditBackground(self.nameWidget, 'synced') + return None + + if new_name in VARIABLES: + self.gui.setStatus( + f"Error: {new_name} already exist!", 10000, False) + return None + + new_name = clean_string(new_name) + + try: + rename_variable(self.name, new_name) + except Exception as e: + self.gui.setStatus(f'Error: {e}', 10000, False) + else: + self.name = new_name + new_name = self.nameWidget.setText(self.name) + setLineEditBackground(self.nameWidget, 'synced') + self.gui.setStatus('') + return None + + def refresh_rawValue(self): + raw_value_str = data_to_str(self.variable.raw) + + self.rawValueWidget.setText(raw_value_str) + setLineEditBackground(self.rawValueWidget, 'synced') + + if has_variable(self.variable.raw): # OPTIMIZE: use hide and show instead but doesn't hide on instantiation + if self.actionButtonWidget is None: + actionButtonWidget = QtWidgets.QPushButton() + actionButtonWidget.setText('Update value') + actionButtonWidget.setMinimumSize(0, 23) + actionButtonWidget.setMaximumSize(85, 23) + actionButtonWidget.clicked.connect(self.convertVariableClicked) + self.gui.variablesWidget.setItemWidget(self, 5, actionButtonWidget) + self.actionButtonWidget = actionButtonWidget + else: + self.gui.variablesWidget.removeItemWidget(self, 5) + self.actionButtonWidget = None + + def refresh_value(self): + value = self.variable.value + value_str = data_to_str(value) + + self.valueWidget.setText(value_str) + self.typeWidget.setText(str(type(value)).split("'")[1]) + + + def changeRawValue(self): + name = self.name + raw_value = self.rawValueWidget.text() + try: + if not has_eval(raw_value): + raw_value = str_to_data(raw_value) + else: + # get all variables + raw_value_check = raw_value[len(EVAL): ] # Allows variable with name 'eval' + pattern1 = r'[a-zA-Z][a-zA-Z0-9._]*' + matches1 = re.findall(pattern1, raw_value_check) + # get variables not unclosed by ' or " (gives bad name so needs to check with all variables) + pattern2 = r'(? str: """ Returns the name of the recipe that will receive the variables @@ -544,58 +615,32 @@ def getParameterName(self) -> str: return self.scanner.selectParameter_comboBox.currentText() - def clearScanner(self): - """ This clear the scanner instance reference when quitted """ - self.scanner = None - - def clearPlotter(self): - """ This deactivate the plotter when quitted but keep the instance in memory """ - if self.plotter is not None: - self.plotter.active = False # don't want to close plotter because want to keep data - - def clearAbout(self): - """ This clear the about instance reference when quitted """ - self.about = None - - def clearAddDevice(self): - """ This clear the addDevice instance reference when quitted """ - self.addDevice = None - def closeEvent(self, event): """ This function does some steps before the window is really killed """ - if self.scanner is not None: + if self.scanner: self.scanner.close() - if self.plotter is not None: - self.plotter.figureManager.fig.deleteLater() - for children in self.plotter.findChildren(QtWidgets.QWidget): - children.deleteLater() - - self.plotter.close() - - if self.about is not None: - self.about.close() - - if self.addDevice is not None: - self.addDevice.close() - - monitors = list(self.monitors.values()) - for monitor in monitors: - monitor.close() - - for slider in list(self.sliders.values()): - slider.close() + if self.scanner is not None and self.scanner.isVisible(): + event.ignore() + return None - devices.close() # close all devices + closePlotter() + closeAbout() + closeAddDevice() + closeMonitors() + closeSliders() + closeVariablesMenu() + closePreferences() + closeDriverInstaller() - QtWidgets.QApplication.quit() # close the control center interface + if self.close_device_on_exit: + close() # close all devices - if hasattr(self, 'stdout'): - sys.stdout = self.stdout._stream - sys.stderr = self.stderr._stream + self.remove_logger() + self.remove_console() - if hasattr(self, '_logger_dock'): self._logger_dock.deleteLater() - if hasattr(self, '_console_dock'): self._console_dock.deleteLater() + self.timerDevice.stop() + self.timerQueue.stop() try: # Prevent 'RuntimeError: wrapped C/C++ object of type ViewBox has been deleted' when reloading gui @@ -605,502 +650,14 @@ def closeEvent(self, event): pg.ViewBox.quit() except: pass - self.timerDevice.stop() - self.timerQueue.stop() - for children in self.findChildren(QtWidgets.QWidget): children.deleteLater() super().closeEvent(event) - VARIABLES.clear() # reset variables defined in the GUI - - -class addDeviceWindow(QtWidgets.QMainWindow): - - def __init__(self, parent: QtWidgets.QMainWindow = None): - - super().__init__(parent) - self.mainGui = parent - self.setWindowTitle('Autolab - Add device') - self.setWindowIcon(QtGui.QIcon(icons['autolab'])) - - self.statusBar = self.statusBar() - - self._prev_name = '' - self._prev_conn = '' - - try: - import pyvisa as visa - self.rm = visa.ResourceManager() - except: - self.rm = None - - self._font_size = get_font_size() + 1 - - # Main layout creation - layoutWindow = QtWidgets.QVBoxLayout() - layoutWindow.setAlignment(QtCore.Qt.AlignTop) - - centralWidget = QtWidgets.QWidget() - centralWidget.setLayout(layoutWindow) - self.setCentralWidget(centralWidget) - - # Device nickname - layoutDeviceNickname = QtWidgets.QHBoxLayout() - layoutWindow.addLayout(layoutDeviceNickname) - - label = QtWidgets.QLabel('Device') - label.setMinimumSize(60, 23) - label.setMaximumSize(60, 23) - - self.deviceNickname = QtWidgets.QLineEdit() - self.deviceNickname.setText('my_device') - - layoutDeviceNickname.addWidget(label) - layoutDeviceNickname.addWidget(self.deviceNickname) - - # Driver name - layoutDriverName = QtWidgets.QHBoxLayout() - layoutWindow.addLayout(layoutDriverName) - - label = QtWidgets.QLabel('Driver') - label.setMinimumSize(60, 23) - label.setMaximumSize(60, 23) - - self.driversComboBox = QtWidgets.QComboBox() - self.driversComboBox.addItems(drivers.list_drivers()) - self.driversComboBox.activated.connect(self.driverChanged) - - layoutDriverName.addWidget(label) - layoutDriverName.addWidget(self.driversComboBox) - - # Driver connection - layoutDriverConnection = QtWidgets.QHBoxLayout() - layoutWindow.addLayout(layoutDriverConnection) + QtWidgets.QApplication.quit() # close the app - label = QtWidgets.QLabel('Connection') - label.setMinimumSize(60, 23) - label.setMaximumSize(60, 23) - - self.connectionComboBox = QtWidgets.QComboBox() - self.connectionComboBox.activated.connect(self.connectionChanged) - - layoutDriverConnection.addWidget(label) - layoutDriverConnection.addWidget(self.connectionComboBox) - - # Driver arguments - self.layoutDriverArgs = QtWidgets.QHBoxLayout() - layoutWindow.addLayout(self.layoutDriverArgs) - - self.layoutDriverOtherArgs = QtWidgets.QHBoxLayout() - layoutWindow.addLayout(self.layoutDriverOtherArgs) - - # layout for optional args - self.layoutOptionalArg = QtWidgets.QVBoxLayout() - layoutWindow.addLayout(self.layoutOptionalArg) - - # Add argument - layoutButtonArg = QtWidgets.QHBoxLayout() - layoutWindow.addLayout(layoutButtonArg) - - addOptionalArg = QtWidgets.QPushButton('Add argument') - addOptionalArg.setMinimumSize(0, 23) - addOptionalArg.setMaximumSize(100, 23) - addOptionalArg.setIcon(QtGui.QIcon(icons['add'])) - addOptionalArg.clicked.connect(lambda state: self.addOptionalArgClicked()) - - layoutButtonArg.addWidget(addOptionalArg) - layoutButtonArg.setAlignment(QtCore.Qt.AlignLeft) - - # Add device - layoutButton = QtWidgets.QHBoxLayout() - layoutWindow.addLayout(layoutButton) - - self.addButton = QtWidgets.QPushButton('Add device') - self.addButton.clicked.connect(self.addButtonClicked) - - layoutButton.addWidget(self.addButton) - - # update driver name combobox - self.driverChanged() - - def addOptionalArgClicked(self, key: str = None, val: str = None): - """ Add new layout for optional argument """ - layout = QtWidgets.QHBoxLayout() - self.layoutOptionalArg.addLayout(layout) - - widget = QtWidgets.QLineEdit() - widget.setText(key) - layout.addWidget(widget) - widget = QtWidgets.QLineEdit() - widget.setText(val) - layout.addWidget(widget) - widget = QtWidgets.QPushButton() - widget.setIcon(QtGui.QIcon(icons['remove'])) - widget.clicked.connect(lambda: self.removeOptionalArgClicked(layout)) - layout.addWidget(widget) - - def removeOptionalArgClicked(self, layout): - """ Remove optional argument layout """ - for j in reversed(range(layout.count())): - layout.itemAt(j).widget().setParent(None) - layout.setParent(None) - - def addButtonClicked(self): - """ Add the device to the config file """ - device_name = self.deviceNickname.text() - driver_name = self.driversComboBox.currentText() - conn = self.connectionComboBox.currentText() - - device_dict = {} - device_dict['driver'] = driver_name - device_dict['connection'] = conn - - for layout in (self.layoutDriverArgs, self.layoutDriverOtherArgs): - for i in range(0, (layout.count()//2)*2, 2): - key = layout.itemAt(i).widget().text() - val = layout.itemAt(i+1).widget().text() - device_dict[key] = val - - for i in range(self.layoutOptionalArg.count()): - layout = self.layoutOptionalArg.itemAt(i).layout() - key = layout.itemAt(0).widget().text() - val = layout.itemAt(1).widget().text() - device_dict[key] = val - - # Update devices config - device_config = config.get_all_devices_configs() - new_device = {device_name: device_dict} - device_config.update(new_device) - config.save_config('devices', device_config) - - if hasattr(self.mainGui, 'initialize'): self.mainGui.initialize() - - self.close() - - def modify(self, nickname: str, conf: dict): - """ Modify existing driver (not optimized) """ - - self.setWindowTitle('Autolab - Modify device') - self.addButton.setText('Modify device') - - self.deviceNickname.setText(nickname) - self.deviceNickname.setEnabled(False) - driver_name = conf.pop('driver') - conn = conf.pop('connection') - index = self.driversComboBox.findText(driver_name) - self.driversComboBox.setCurrentIndex(index) - self.driverChanged() - - try: - driver_lib = drivers.load_driver_lib(driver_name) - except: pass - else: - list_conn = drivers.get_connection_names(driver_lib) - if conn not in list_conn: - if list_conn: - self.setStatus(f"Connection {conn} not found, switch to {list_conn[0]}", 10000, False) - conn = list_conn[0] - else: - self.setStatus(f"No connections available for driver '{driver_name}'", 10000, False) - conn = '' - - index = self.connectionComboBox.findText(conn) - self.connectionComboBox.setCurrentIndex(index) - self.connectionChanged() - - # Used to remove default value - try: - driver_lib = drivers.load_driver_lib(driver_name) - driver_class = drivers.get_driver_class(driver_lib) - assert hasattr(driver_class, 'slot_config') - except: - slot_config = '' - else: - slot_config = f'{driver_class.slot_config}' - - # Update args - for layout in (self.layoutDriverArgs, self.layoutDriverOtherArgs): - for i in range(0, (layout.count()//2)*2, 2): - key = layout.itemAt(i).widget().text() - if key in conf: - layout.itemAt(i+1).widget().setText(conf[key]) - conf.pop(key) - - # Update optional args - for i in reversed(range(self.layoutOptionalArg.count())): - layout = self.layoutOptionalArg.itemAt(i).layout() - key = layout.itemAt(0).widget().text() - val_tmp = layout.itemAt(1).widget().text() - # Remove default value - if ((key == 'slot1' and val_tmp == slot_config) - or (key == 'slot1_name' and val_tmp == 'my_')): - for j in reversed(range(layout.count())): - layout.itemAt(j).widget().setParent(None) - layout.setParent(None) - elif key in conf: - layout.itemAt(1).widget().setText(conf[key]) - conf.pop(key) - - # Add remaining optional args from config - for key, val in conf.items(): - self.addOptionalArgClicked(key, val) - - def driverChanged(self): - """ Update driver information """ - driver_name = self.driversComboBox.currentText() - - if driver_name == self._prev_name: return None - self._prev_name = driver_name - - try: - driver_lib = drivers.load_driver_lib(driver_name) - except Exception as e: - # If error with driver remove all layouts - self.setStatus(f"Can't load {driver_name}: {e}", 10000, False) - - self.connectionComboBox.clear() - - for layout in (self.layoutDriverArgs, self.layoutDriverOtherArgs): - for i in reversed(range(layout.count())): - layout.itemAt(i).widget().setParent(None) - - for i in reversed(range(self.layoutOptionalArg.count())): - layout = self.layoutOptionalArg.itemAt(i).layout() - for j in reversed(range(layout.count())): - layout.itemAt(j).widget().setParent(None) - layout.setParent(None) - - return None - - self.setStatus('') - - # Update available connections - connections = drivers.get_connection_names(driver_lib) - self.connectionComboBox.clear() - self.connectionComboBox.addItems(connections) - - # update selected connection information - self._prev_conn = '' - self.connectionChanged() - - # reset layoutDriverOtherArgs - for i in reversed(range(self.layoutDriverOtherArgs.count())): - self.layoutDriverOtherArgs.itemAt(i).widget().setParent(None) - - # used to skip doublon key - conn = self.connectionComboBox.currentText() - try: - driver_instance = drivers.get_connection_class(driver_lib, conn) - except: - connection_args = {} - else: - connection_args = drivers.get_class_args(driver_instance) - - # populate layoutDriverOtherArgs - driver_class = drivers.get_driver_class(driver_lib) - other_args = drivers.get_class_args(driver_class) - for key, val in other_args.items(): - if key in connection_args: continue - widget = QtWidgets.QLabel() - widget.setText(key) - self.layoutDriverOtherArgs.addWidget(widget) - widget = QtWidgets.QLineEdit() - widget.setText(str(val)) - self.layoutDriverOtherArgs.addWidget(widget) - - # reset layoutOptionalArg - for i in reversed(range(self.layoutOptionalArg.count())): - layout = self.layoutOptionalArg.itemAt(i).layout() - for j in reversed(range(layout.count())): - layout.itemAt(j).widget().setParent(None) - layout.setParent(None) - - # populate layoutOptionalArg - if hasattr(driver_class, 'slot_config'): - self.addOptionalArgClicked('slot1', f'{driver_class.slot_config}') - self.addOptionalArgClicked('slot1_name', 'my_') - - def connectionChanged(self): - """ Update connection information """ - conn = self.connectionComboBox.currentText() - - if conn == self._prev_conn: return None - self._prev_conn = conn - - driver_name = self.driversComboBox.currentText() - driver_lib = drivers.load_driver_lib(driver_name) - - connection_args = drivers.get_class_args( - drivers.get_connection_class(driver_lib, conn)) - - # reset layoutDriverArgs - for i in reversed(range(self.layoutDriverArgs.count())): - self.layoutDriverArgs.itemAt(i).widget().setParent(None) - - conn_widget = None - # populate layoutDriverArgs - for key, val in connection_args.items(): - widget = QtWidgets.QLabel() - widget.setText(key) - self.layoutDriverArgs.addWidget(widget) - - widget = QtWidgets.QLineEdit() - widget.setText(str(val)) - self.layoutDriverArgs.addWidget(widget) - - if key == 'address': - conn_widget = widget - - if self.rm is not None and conn == 'VISA': - widget = QtWidgets.QComboBox() - widget.clear() - conn_list = ('Available connections',) + tuple(self.rm.list_resources()) - widget.addItems(conn_list) - if conn_widget is not None: - widget.activated.connect( - lambda item, conn_widget=conn_widget: conn_widget.setText( - widget.currentText()) if widget.currentText( - ) != 'Available connections' else conn_widget.text()) - self.layoutDriverArgs.addWidget(widget) - - def setStatus(self, message: str, timeout: int = 0, stdout: bool = True): - """ Modify the message displayed in the status bar and add error message to logger """ - self.statusBar.showMessage(message, timeout) - if not stdout: print(message, file=sys.stderr) - - def closeEvent(self, event): - """ Does some steps before the window is really killed """ - # Delete reference of this window in the control center - if hasattr(self.mainGui, 'clearAddDevice'): self.mainGui.clearAddDevice() - - if self.mainGui is None: - QtWidgets.QApplication.quit() # close the monitor app - - -class AboutWindow(QtWidgets.QMainWindow): - - def __init__(self, parent: QtWidgets.QMainWindow = None): - - super().__init__(parent) - self.mainGui = parent - self.setWindowTitle('Autolab - About') - self.setWindowIcon(QtGui.QIcon(icons['autolab'])) - - versions = get_versions() - - # Main layout creation - layoutWindow = QtWidgets.QVBoxLayout() - layoutTab = QtWidgets.QHBoxLayout() - layoutWindow.addLayout(layoutTab) - - centralWidget = QtWidgets.QWidget() - centralWidget.setLayout(layoutWindow) - self.setCentralWidget(centralWidget) - - frameOverview = QtWidgets.QFrame() - layoutOverview = QtWidgets.QVBoxLayout(frameOverview) - - frameLegal = QtWidgets.QFrame() - layoutLegal = QtWidgets.QVBoxLayout(frameLegal) - - tab = QtWidgets.QTabWidget(self) - tab.addTab(frameOverview, 'Overview') - tab.addTab(frameLegal, 'Legal') - - label_pic = QtWidgets.QLabel() - label_pic.setPixmap(QtGui.QPixmap(icons['autolab'])) - - label_autolab = QtWidgets.QLabel(f"

Autolab {versions['autolab']}

") - label_autolab.setAlignment(QtCore.Qt.AlignCenter) - - frameIcon = QtWidgets.QFrame() - layoutIcon = QtWidgets.QVBoxLayout(frameIcon) - layoutIcon.addWidget(label_pic) - layoutIcon.addWidget(label_autolab) - layoutIcon.addStretch() - - layoutTab.addWidget(frameIcon) - layoutTab.addWidget(tab) - - label_versions = QtWidgets.QLabel( - f""" -

Autolab

- -

Python package for scientific experiments automation

- -

- {versions['system']} {versions['release']} -
- Python {versions['python']} - {versions['bitness']}-bit -
- {versions['qt_api']} {versions['qt_api_ver']} | - PyQtGraph {versions['pyqtgraph']} | - Numpy {versions['numpy']} | - Pandas {versions['pandas']} -

- -

- Project | - Drivers | - Documentation -

- """ - ) - label_versions.setOpenExternalLinks(True) - label_versions.setWordWrap(True) - - layoutOverview.addWidget(label_versions) - - label_legal = QtWidgets.QLabel( - f""" -

- Created by Quentin Chateiller, Python drivers originally from - Quentin Chateiller and Bruno Garbin, for the C2N-CNRS - (Center for Nanosciences and Nanotechnologies, Palaiseau, France) - ToniQ team. -
- Project continued by Jonathan Peltier, for the C2N-CNRS - Minaphot team and Mathieu Jeannin, for the C2N-CNRS - Odin team. -
-
- Distributed under the terms of the - GPL-3.0 licence -

""" - ) - label_legal.setOpenExternalLinks(True) - label_legal.setWordWrap(True) - layoutLegal.addWidget(label_legal) - - def closeEvent(self, event): - """ Does some steps before the window is really killed """ - # Delete reference of this window in the control center - if hasattr(self.mainGui, 'clearAbout'): self.mainGui.clearAbout() - - if self.mainGui is None: - QtWidgets.QApplication.quit() # close the about app - - -def get_versions() -> dict: - """Information about Autolab versions """ - - # Based on Spyder about.py (https://github.com/spyder-ide/spyder/blob/3ce32d6307302a93957594569176bc84d9c1612e/spyder/plugins/application/widgets/about.py#L40) - versions = { - 'autolab': __version__, - 'python': platform.python_version(), # "2.7.3" - 'bitness': 64 if sys.maxsize > 2**32 else 32, - 'qt_api': qtpy.API_NAME, # PyQt5 - 'qt_api_ver': (qtpy.PYSIDE_VERSION if 'pyside' in qtpy.API - else qtpy.PYQT_VERSION), - 'system': platform.system(), # Linux, Windows, ... - 'release': platform.release(), # XP, 10.6, 2.2.0, etc. - 'pyqtgraph': pg.__version__, - 'numpy': np.__version__, - 'pandas': pd.__version__, - } - if sys.platform == 'darwin': - versions.update(system='macOS', release=platform.mac_ver()[0]) - - return versions + # OPTIMIZE: don't know if should erase variables on exit or not. + # Currently decided to only erase variables defined by scanner itself, + # keeping the ones defined by user. + # VARIABLES.clear() # reset variables defined in the GUI diff --git a/autolab/core/gui/controlcenter/thread.py b/autolab/core/gui/controlcenter/thread.py index 3fdffb93..956b7a97 100644 --- a/autolab/core/gui/controlcenter/thread.py +++ b/autolab/core/gui/controlcenter/thread.py @@ -10,9 +10,11 @@ from typing import Any from qtpy import QtCore, QtWidgets + from ..GUI_utilities import qt_object_exists -from ... import devices -from ... import drivers +from ...devices import get_final_device_config, list_loaded_devices, DEVICES, Device +from ...drivers import load_driver_lib, get_driver +from ...variables import update_allowed_dict class ThreadManager: @@ -29,20 +31,15 @@ def start(self, item: QtWidgets.QTreeWidgetItem, intType: str, value = None): # GUI disabling item.setDisabled(True) - if hasattr(item, "execButton"): - if qt_object_exists(item.execButton): - item.execButton.setEnabled(False) - if hasattr(item, "readButton"): - if qt_object_exists(item.readButton): - item.readButton.setEnabled(False) - if hasattr(item, "valueWidget"): - if qt_object_exists(item.valueWidget): - item.valueWidget.setEnabled(False) + if hasattr(item, "execButton") and qt_object_exists(item.execButton): + item.execButton.setEnabled(False) + if hasattr(item, "readButton") and qt_object_exists(item.readButton): + item.readButton.setEnabled(False) + if hasattr(item, "valueWidget") and qt_object_exists(item.valueWidget): + item.valueWidget.setEnabled(False) # disabling valueWidget deselect item and select next one, need to disable all items and reenable item - list_item = self.gui.tree.selectedItems() - - for item_selected in list_item: + for item_selected in self.gui.tree.selectedItems(): item_selected.setSelected(False) item.setSelected(True) @@ -90,16 +87,17 @@ def threadFinished(self, tid: int, error: Exception): item = self.threads[tid].item if qt_object_exists(item): item.setDisabled(False) - - if hasattr(item, "execButton"): - if qt_object_exists(item.execButton): - item.execButton.setEnabled(True) - if hasattr(item, "readButton"): - if qt_object_exists(item.readButton): - item.readButton.setEnabled(True) - if hasattr(item, "valueWidget"): - if qt_object_exists(item.valueWidget): - item.valueWidget.setEnabled(True) + item.setValueKnownState(-1 if error else True) + + if hasattr(item, "execButton") and qt_object_exists(item.execButton): + item.execButton.setEnabled(True) + if hasattr(item, "readButton") and qt_object_exists(item.readButton): + item.readButton.setEnabled(True) + if hasattr(item, "valueWidget") and qt_object_exists(item.valueWidget): + item.valueWidget.setEnabled(True) + # Put back focus if item still selected (item.isSelected() doesn't work) + if item in self.gui.tree.selectedItems(): + item.valueWidget.setFocus() def delete(self, tid: int): """ This function is called when a thread is about to be deleted. @@ -142,24 +140,25 @@ def run(self): elif self.intType == 'load': # OPTIMIZE: is very similar to get_device() # Note that threadItemDict needs to be updated outside of thread to avoid timing error device_name = self.item.name - device_config = devices.get_final_device_config(device_name) + device_config = get_final_device_config(device_name) - if device_name in devices.list_loaded_devices(): - assert device_config == devices.DEVICES[device_name].device_config, 'You cannot change the configuration of an existing Device. Close it first & retry, or remove the provided configuration.' + if device_name in list_loaded_devices(): + assert device_config == DEVICES[device_name].device_config, 'You cannot change the configuration of an existing Device. Close it first & retry, or remove the provided configuration.' else: driver_kwargs = {k: v for k, v in device_config.items() if k not in ['driver', 'connection']} - driver_lib = drivers.load_driver_lib(device_config['driver']) + driver_lib = load_driver_lib(device_config['driver']) if hasattr(driver_lib, 'Driver') and 'gui' in [param.name for param in inspect.signature(driver_lib.Driver.__init__).parameters.values()]: driver_kwargs['gui'] = self.item.gui - instance = drivers.get_driver(device_config['driver'], - device_config['connection'], - **driver_kwargs) - devices.DEVICES[device_name] = devices.Device( + instance = get_driver( + device_config['driver'], device_config['connection'], + **driver_kwargs) + DEVICES[device_name] = Device( device_name, instance, device_config) + update_allowed_dict() - self.item.gui.threadDeviceDict[id(self.item)] = devices.DEVICES[device_name] + self.item.gui.threadDeviceDict[id(self.item)] = DEVICES[device_name] except Exception as e: error = e diff --git a/autolab/core/gui/controlcenter/treewidgets.py b/autolab/core/gui/controlcenter/treewidgets.py index 0f90c271..ffcb1811 100644 --- a/autolab/core/gui/controlcenter/treewidgets.py +++ b/autolab/core/gui/controlcenter/treewidgets.py @@ -5,172 +5,24 @@ @author: qchat """ - -import os from typing import Any, Union +import os import pandas as pd import numpy as np from qtpy import QtCore, QtWidgets, QtGui -from ..slider import Slider -from ..monitoring.main import Monitor -from .. import variables from ..icons import icons -from ..GUI_utilities import qt_object_exists -from ... import paths, config -from ...devices import close -from ...utilities import (SUPPORTED_EXTENSION, - str_to_array, array_to_str, - dataframe_to_str, str_to_dataframe, create_array) - - -class CustomMenu(QtWidgets.QMenu): - """ Menu with action containing sub-menu for recipe and parameter selection """ - - def __init__(self, gui): - super().__init__() - self.gui = gui - self.current_menu = 1 - self.selected_action = None - - self.recipe_cb = self.gui.scanner.selectRecipe_comboBox if ( - self.gui.scanner) else None - self.recipe_names = [self.recipe_cb.itemText(i) for i in range( - self.recipe_cb.count())] if self.recipe_cb else [] - self.param_cb = self.gui.scanner.selectParameter_comboBox if ( - self.gui.scanner) else None - - self.HAS_RECIPE = len(self.recipe_names) > 1 - self.HAS_PARAM = (self.param_cb.count() > 1 if self.param_cb is not None - else False) - - def addAnyAction(self, action_text='', icon_name='', - param_menu_active=False) -> Union[QtWidgets.QWidgetAction, - QtWidgets.QAction]: - - if self.HAS_RECIPE or (self.HAS_PARAM and param_menu_active): - action = self.addCustomAction(action_text, icon_name, - param_menu_active=param_menu_active) - else: - action = self.addAction(action_text) - if icon_name != '': - action.setIcon(QtGui.QIcon(icons[icon_name])) - - return action - - def addCustomAction(self, action_text='', icon_name='', - param_menu_active=False) -> QtWidgets.QWidgetAction: - """ Create an action with a sub menu for selecting a recipe and parameter """ - - def close_menu(): - self.selected_action = action_widget - self.close() - - def handle_hover(): - """ Fixe bad hover behavior and refresh radio_button """ - self.setActiveAction(action_widget) - recipe_name = self.recipe_cb.currentText() - action = recipe_menu.actions()[self.recipe_names.index(recipe_name)] - radio_button = action.defaultWidget() - - if not radio_button.isChecked(): - radio_button.setChecked(True) - - def handle_radio_click(name): - """ Update parameters available, open parameter menu if available - and close main menu to validate the action """ - if self.current_menu == 1: - self.recipe_cb.setCurrentIndex(self.recipe_names.index(name)) - self.gui.scanner._updateSelectParameter() - recipe_menu.close() - - if param_menu_active: - param_items = [self.param_cb.itemText(i) for i in range( - self.param_cb.count())] - - if len(param_items) > 1: - self.current_menu = 2 - setup_menu_parameter(param_menu) - return None - else: - update_parameter(name) - param_menu.close() - self.current_menu = 1 - action_button.setMenu(recipe_menu) - - close_menu() - - def reset_menu(button: QtWidgets.QToolButton): - QtWidgets.QApplication.sendEvent( - button, QtCore.QEvent(QtCore.QEvent.Leave)) - self.current_menu = 1 - action_button.setMenu(recipe_menu) - - def setup_menu_parameter(param_menu: QtWidgets.QMenu): - param_items = [self.param_cb.itemText(i) for i in range( - self.param_cb.count())] - param_name = self.param_cb.currentText() - - param_menu.clear() - for param_name_i in param_items: - add_radio_button_to_menu(param_name_i, param_name, param_menu) - - action_button.setMenu(param_menu) - action_button.showMenu() - - def update_parameter(name: str): - param_items = [self.param_cb.itemText(i) for i in range( - self.param_cb.count())] - self.param_cb.setCurrentIndex(param_items.index(name)) - self.gui.scanner._updateSelectParameter() - - def add_radio_button_to_menu(item_name: str, current_name: str, - target_menu: QtWidgets.QMenu): - widget = QtWidgets.QWidget() - radio_button = QtWidgets.QRadioButton(item_name, widget) - action = QtWidgets.QWidgetAction(self.gui) - action.setDefaultWidget(radio_button) - target_menu.addAction(action) - - if item_name == current_name: - radio_button.setChecked(True) - - radio_button.clicked.connect( - lambda: handle_radio_click(item_name)) - - # Add custom action - action_button = QtWidgets.QToolButton() - action_button.setPopupMode(QtWidgets.QToolButton.MenuButtonPopup) - action_button.setText(f" {action_text}") - action_button.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon) - action_button.setAutoRaise(True) - action_button.clicked.connect(close_menu) - action_button.enterEvent = lambda event: handle_hover() - if icon_name != '': - action_button.setIcon(QtGui.QIcon(icons[icon_name])) - - action_widget = QtWidgets.QWidgetAction(action_button) - action_widget.setDefaultWidget(action_button) - self.addAction(action_widget) - - recipe_menu = QtWidgets.QMenu() - # recipe_menu.aboutToShow.connect(lambda: self.set_clickable(False)) - recipe_menu.aboutToHide.connect(lambda: reset_menu(action_button)) - - if param_menu_active: - param_menu = QtWidgets.QMenu() - # param_menu.aboutToShow.connect(lambda: self.set_clickable(False)) - param_menu.aboutToHide.connect(lambda: reset_menu(action_button)) - - recipe_name = self.gui.scanner.selectRecipe_comboBox.currentText() - - for recipe_name_i in self.recipe_names: - add_radio_button_to_menu(recipe_name_i, recipe_name, recipe_menu) - - action_button.setMenu(recipe_menu) - - return action_widget +from ..GUI_utilities import (MyLineEdit, MyInputDialog, MyQCheckBox, MyQComboBox, + RecipeMenu, qt_object_exists) +from ..GUI_instances import openMonitor, openSlider, openPlotter, openAddDevice +from ...paths import PATHS +from ...config import get_control_center_config +from ...variables import eval_variable, has_eval +from ...devices import close, list_loaded_devices +from ...utilities import (SUPPORTED_EXTENSION, str_to_array, array_to_str, + dataframe_to_str, str_to_dataframe, create_array, + str_to_tuple) class TreeWidgetItemModule(QtWidgets.QTreeWidgetItem): @@ -186,6 +38,9 @@ def __init__(self, itemParent, name, gui): if self.is_not_submodule: super().__init__(itemParent, [name, 'Device']) + self.indicator = QtWidgets.QLabel() + self.indicator.setToolTip('Device not instantiated yet') + self.gui.tree.setItemWidget(self, 4, self.indicator) else: super().__init__(itemParent, [name, 'Module']) @@ -226,7 +81,7 @@ def menu(self, position: QtCore.QPoint): if self.loaded: menu = QtWidgets.QMenu() disconnectDevice = menu.addAction(f"Disconnect {self.name}") - disconnectDevice.setIcon(QtGui.QIcon(icons['disconnect'])) + disconnectDevice.setIcon(icons['disconnect']) choice = menu.exec_(self.gui.tree.viewport().mapToGlobal(position)) @@ -237,10 +92,15 @@ def menu(self, position: QtCore.QPoint): self.removeChild(self.child(0)) self.loaded = False + self.setValueKnownState(False) + + if not list_loaded_devices(): + self.gui._stop_timerQueue = True + elif id(self) in self.gui.threadManager.threads_conn: menu = QtWidgets.QMenu() cancelDevice = menu.addAction('Cancel loading') - cancelDevice.setIcon(QtGui.QIcon(icons['disconnect'])) + cancelDevice.setIcon(icons['disconnect']) choice = menu.exec_(self.gui.tree.viewport().mapToGlobal(position)) @@ -249,12 +109,27 @@ def menu(self, position: QtCore.QPoint): else: menu = QtWidgets.QMenu() modifyDeviceChoice = menu.addAction('Modify device') - modifyDeviceChoice.setIcon(QtGui.QIcon(icons['rename'])) + modifyDeviceChoice.setIcon(icons['rename']) choice = menu.exec_(self.gui.tree.viewport().mapToGlobal(position)) if choice == modifyDeviceChoice: - self.gui.openAddDevice(self) + openAddDevice(gui=self.gui, name=self.name) + + def setValueKnownState(self, state: Union[bool, float]): + """ Turn the color of the indicator depending of the known state of the value """ + if state == 0.5: + self.indicator.setStyleSheet("background-color:#FFFF00") # yellow + self.indicator.setToolTip('Device is being instantiated') + elif state == -1: + self.indicator.setStyleSheet("background-color:#FF0000") # red + self.indicator.setToolTip('Device connection error') + elif state: + self.indicator.setStyleSheet("background-color:#70db70") # green + self.indicator.setToolTip('Device instantiated') + else: + self.indicator.setStyleSheet("background-color:none") # none + self.indicator.setToolTip('Device closed') class TreeWidgetItemAction(QtWidgets.QTreeWidgetItem): @@ -272,8 +147,12 @@ def __init__(self, itemParent, action, gui): self.gui = gui self.action = action + # Import Autolab config + control_center_config = get_control_center_config() + self.precision = int(float(control_center_config['precision'])) + if self.action.has_parameter: - if self.action.type in [int, float, str, np.ndarray, pd.DataFrame]: + if self.action.type in [int, float, bool, str, bytes, tuple, np.ndarray, pd.DataFrame]: self.executable = True self.has_value = True else: @@ -292,91 +171,224 @@ def __init__(self, itemParent, action, gui): # Main - Column 3 : QlineEdit if the action has a parameter if self.has_value: - self.valueWidget = QtWidgets.QLineEdit() - self.valueWidget.setAlignment(QtCore.Qt.AlignCenter) - self.gui.tree.setItemWidget(self, 3, self.valueWidget) - self.valueWidget.returnPressed.connect(self.execute) + if self.action.type in [int, float, str, bytes, np.ndarray, pd.DataFrame]: + self.valueWidget = MyLineEdit() + self.valueWidget.setAlignment(QtCore.Qt.AlignCenter) + self.gui.tree.setItemWidget(self, 3, self.valueWidget) + self.valueWidget.returnPressed.connect(self.execute) + self.valueWidget.textEdited.connect(self.valueEdited) + + ## QCheckbox for boolean variables + elif self.action.type in [bool]: + self.valueWidget = MyQCheckBox(self) + hbox = QtWidgets.QHBoxLayout() + hbox.addWidget(self.valueWidget) + hbox.setAlignment(QtCore.Qt.AlignCenter) + hbox.setSpacing(0) + hbox.setContentsMargins(0,0,0,0) + widget = QtWidgets.QFrame() + widget.setLayout(hbox) + + self.gui.tree.setItemWidget(self, 3, widget) + + ## Combobox for tuples: Tuple[List[str], int] + elif self.action.type in [tuple]: + self.valueWidget = MyQComboBox() + self.valueWidget.wheel = False # prevent changing value by mistake + self.valueWidget.key = False + self.valueWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.valueWidget.customContextMenuRequested.connect(self.openInputDialog) + + self.gui.tree.setItemWidget(self, 3, self.valueWidget) + + # Main - column 4 : indicator (status of the actual value : known or not known) + self.indicator = QtWidgets.QLabel() + self.gui.tree.setItemWidget(self, 4, self.indicator) # Tooltip if self.action._help is None: tooltip = 'No help available for this action' else: tooltip = self.action._help + if hasattr(self.action, "type") and self.action.type is not None: + action_type = str(self.action.type).split("'")[1] + tooltip += f" ({action_type})" self.setToolTip(0, tooltip) + self.writeSignal = WriteSignal() + self.writeSignal.writed.connect(self.valueWrited) + self.action._write_signal = self.writeSignal + + def openInputDialog(self, position: QtCore.QPoint): + """ Only used for tuple """ + menu = QtWidgets.QMenu() + modifyTuple = menu.addAction("Modify tuple") + modifyTuple.setIcon(icons['tuple']) + + choice = menu.exec_(self.valueWidget.mapToGlobal(position)) + + if choice == modifyTuple: + main_dialog = MyInputDialog(self.gui, self.action.address()) + main_dialog.setWindowModality(QtCore.Qt.ApplicationModal) + if self.action.type in [tuple]: + main_dialog.setTextValue(str(self.action.value)) + main_dialog.show() + + if main_dialog.exec_() == QtWidgets.QInputDialog.Accepted: + response = main_dialog.textValue() + else: + response = '' + + if qt_object_exists(main_dialog): main_dialog.deleteLater() + + if response != '': + try: + if has_eval(response): + response = eval_variable(response) + if self.action.type in [tuple]: + response = str_to_tuple(str(response)) + except Exception as e: + self.gui.setStatus( + f"Variable {self.action.address()}: {e}", 10000, False) + return None + + self.action.value = response + self.valueWrited(response) + self.valueEdited() + + def writeGui(self, value): + """ This function displays a new value in the GUI """ + if hasattr(self, 'valueWidget') and qt_object_exists(self.valueWidget): # avoid crash if device closed and try to write gui (if close device before reading finished) + # Update value + if self.action.type in [int, float]: + self.valueWidget.setText(f'{value:.{self.precision}g}') # default is .6g + elif self.action.type in [str]: + self.valueWidget.setText(value) + elif self.action.type in [bytes]: + self.valueWidget.setText(value.decode()) + elif self.action.type in [bool]: + self.valueWidget.setChecked(value) + elif self.action.type in [tuple]: + items = [self.valueWidget.itemText(i) + for i in range(self.valueWidget.count())] + if value[0] != items: + self.valueWidget.clear() + self.valueWidget.addItems(value[0]) + self.valueWidget.setCurrentIndex(value[1]) + elif self.action.type in [np.ndarray]: + self.valueWidget.setText(array_to_str(value)) + elif self.action.type in [pd.DataFrame]: + self.valueWidget.setText(dataframe_to_str(value)) + else: + self.valueWidget.setText(value) + def readGui(self) -> Any: """ This function returns the value in good format of the value in the GUI """ - value = self.valueWidget.text() - - if value == '': - if self.action.unit in ('open-file', 'save-file', 'filename'): - if self.action.unit == "filename": # TODO: LEGACY (to remove later) - self.gui.setStatus("Using 'filename' as unit is depreciated in favor of 'open-file' and 'save-file'" \ - f"\nUpdate driver {self.action.name} to remove this warning", - 10000, False) - self.action.unit = "open-file" - - if self.action.unit == "open-file": - filename, _ = QtWidgets.QFileDialog.getOpenFileName( - self.gui, caption=f"Open file - {self.action.name}", - directory=paths.USER_LAST_CUSTOM_FOLDER, - filter=SUPPORTED_EXTENSION) - elif self.action.unit == "save-file": - filename, _ = QtWidgets.QFileDialog.getSaveFileName( - self.gui, caption=f"Save file - {self.action.name}", - directory=paths.USER_LAST_CUSTOM_FOLDER, - filter=SUPPORTED_EXTENSION) - - if filename != '': - path = os.path.dirname(filename) - paths.USER_LAST_CUSTOM_FOLDER = path - return filename - else: - self.gui.setStatus( - f"Action {self.action.name} cancel filename selection", - 10000) - elif self.action.unit == "user-input": - response, _ = QtWidgets.QInputDialog.getText( - self.gui, self.action.name, f"Set {self.action.name} value", - QtWidgets.QLineEdit.Normal) - - if response != '': - return response + if self.action.type in [int, float, str, bytes, np.ndarray, pd.DataFrame]: + value = self.valueWidget.text() + + if value == '': + if self.action.unit in ('open-file', 'save-file', 'filename'): + if self.action.unit == "filename": # TODO: LEGACY (to remove later) + self.gui.setStatus("Using 'filename' as unit is depreciated in favor of 'open-file' and 'save-file'" \ + f"\nUpdate driver '{self.action.address().split('.')[0]}' to remove this warning", + 10000, False) + self.action.unit = "open-file" + + if self.action.unit == "open-file": + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self.gui, caption=f"Open file - {self.action.address()}", + directory=PATHS['last_folder'], + filter=SUPPORTED_EXTENSION) + elif self.action.unit == "save-file": + filename, _ = QtWidgets.QFileDialog.getSaveFileName( + self.gui, caption=f"Save file - {self.action.address()}", + directory=PATHS['last_folder'], + filter=SUPPORTED_EXTENSION) + + if filename != '': + path = os.path.dirname(filename) + PATHS['last_folder'] = path + return filename + else: + self.gui.setStatus( + f"Action {self.action.address()} cancel filename selection", + 10000) + elif self.action.unit == "user-input": + main_dialog = MyInputDialog(self.gui, self.action.address()) + main_dialog.setWindowModality(QtCore.Qt.ApplicationModal) + main_dialog.show() + + if main_dialog.exec_() == QtWidgets.QInputDialog.Accepted: + response = main_dialog.textValue() + else: + response = '' + + if qt_object_exists(main_dialog): main_dialog.deleteLater() + + if response != '': + return response + else: + self.gui.setStatus( + f"Action {self.action.address()} cancel user input", + 10000) else: self.gui.setStatus( - f"Action {self.action.name} cancel user input", - 10000) + f"Action {self.action.address()} requires a value for its parameter", + 10000, False) else: - self.gui.setStatus( - f"Action {self.action.name} requires a value for its parameter", - 10000, False) + try: + value = eval_variable(value) + if self.action.type in [int]: + value = int(float(value)) + if self.action.type in [bytes]: + value = value.encode() + elif self.action.type in [np.ndarray]: + value = str_to_array(value) if isinstance( + value, str) else create_array(value) + elif self.action.type in [pd.DataFrame]: + if isinstance(value, str): + value = str_to_dataframe(value) + else: + value = self.action.type(value) + return value + except Exception as e: + self.gui.setStatus( + f"Action {self.action.address()}: {e}", + 10000, False) + elif self.action.type in [bool]: + value = self.valueWidget.isChecked() + return value + elif self.action.type in [tuple]: + items = [self.valueWidget.itemText(i) + for i in range(self.valueWidget.count())] + value = (items, self.valueWidget.currentIndex()) + return value + + def setValueKnownState(self, state: Union[bool, float]): + """ Turn the color of the indicator depending of the known state of the value """ + if state == 0.5: + self.indicator.setStyleSheet("background-color:#FFFF00") # yellow + self.indicator.setToolTip('Value written but not read') + elif state: + self.indicator.setStyleSheet("background-color:#70db70") # green + self.indicator.setToolTip('Value read') else: - try: - value = variables.eval_variable(value) - if self.action.type in [np.ndarray]: - if isinstance(value, str): value = str_to_array(value) - elif self.action.type in [pd.DataFrame]: - if isinstance(value, str): value = str_to_dataframe(value) - else: - value = self.action.type(value) - return value - except: - self.gui.setStatus( - f"Action {self.action.name}: Impossible to convert {value} to {self.action.type.__name__}", - 10000, False) + self.indicator.setStyleSheet("background-color:#ff8c1a") # orange + self.indicator.setToolTip('Value not up-to-date') def execute(self): """ Start a new thread to execute the associated action """ if not self.isDisabled(): if self.has_value: value = self.readGui() - if value is not None: self.gui.threadManager.start( - self, 'execute', value=value) + if value is not None: + self.gui.threadManager.start(self, 'execute', value=value) else: self.gui.threadManager.start(self, 'execute') def menu(self, position: QtCore.QPoint): """ This function provides the menu when the user right click on an item """ if not self.isDisabled(): - menu = CustomMenu(self.gui) + menu = RecipeMenu(self.gui.scanner) scanRecipe = menu.addAnyAction('Do in scan recipe', 'action') @@ -385,7 +397,22 @@ def menu(self, position: QtCore.QPoint): if choice == scanRecipe: recipe_name = self.gui.getRecipeName() - self.gui.addStepToScanRecipe(recipe_name, 'action', self.action) + value = self.action.value if self.action.type in [tuple] else None + self.gui.addStepToScanRecipe( + recipe_name, 'action', self.action, value=value) + + def valueEdited(self): + """ Change indicator state when editing action parameter """ + self.setValueKnownState(False) + + def valueWrited(self, value: Any): + """ Called when action parameter written """ + try: + if self.has_value: + self.writeGui(value) + self.setValueKnownState(0.5) + except Exception as e: + self.gui.setStatus(f"SHOULD NOT RAISE ERROR: {e}", 10000, False) class TreeWidgetItemVariable(QtWidgets.QTreeWidgetItem): @@ -404,20 +431,20 @@ def __init__(self, itemParent, variable, gui): self.variable = variable # Import Autolab config - control_center_config = config.get_control_center_config() - self.precision = int(control_center_config['precision']) + control_center_config = get_control_center_config() + self.precision = int(float(control_center_config['precision'])) # Signal creation and associations in autolab devices instances self.readSignal = ReadSignal() self.readSignal.read.connect(self.writeGui) self.variable._read_signal = self.readSignal self.writeSignal = WriteSignal() - self.writeSignal.writed.connect(self.valueEdited) + self.writeSignal.writed.connect(self.valueWrited) self.variable._write_signal = self.writeSignal # Main - Column 2 : Creation of a READ button if the variable is readable if self.variable.readable and self.variable.type in [ - int, float, bool, str, tuple, np.ndarray, pd.DataFrame]: + int, float, bool, str, bytes, tuple, np.ndarray, pd.DataFrame]: self.readButton = QtWidgets.QPushButton() self.readButton.setText("Read") self.readButton.clicked.connect(self.read) @@ -425,8 +452,11 @@ def __init__(self, itemParent, variable, gui): if not self.variable.writable and self.variable.type in [ np.ndarray, pd.DataFrame]: self.readButtonCheck = QtWidgets.QCheckBox() - self.readButtonCheck.stateChanged.connect(self.readButtonCheckEdited) - self.readButtonCheck.setToolTip('Toggle reading in text, careful can truncate data and impact performance') + self.readButtonCheck.stateChanged.connect( + self.readButtonCheckEdited) + self.readButtonCheck.setToolTip( + 'Toggle reading in text, ' \ + 'careful can truncate data and impact performance') self.readButtonCheck.setMaximumWidth(15) frameReadButton = QtWidgets.QFrame() @@ -442,9 +472,9 @@ def __init__(self, itemParent, variable, gui): # Main - column 3 : Creation of a VALUE widget, depending on the type ## QLineEdit or QLabel - if self.variable.type in [int, float, str, np.ndarray, pd.DataFrame]: + if self.variable.type in [int, float, str, bytes, np.ndarray, pd.DataFrame]: if self.variable.writable: - self.valueWidget = QtWidgets.QLineEdit() + self.valueWidget = MyLineEdit() self.valueWidget.setAlignment(QtCore.Qt.AlignCenter) self.valueWidget.returnPressed.connect(self.write) self.valueWidget.textEdited.connect(self.valueEdited) @@ -454,8 +484,10 @@ def __init__(self, itemParent, variable, gui): self.valueWidget = QtWidgets.QLineEdit() self.valueWidget.setMaxLength(10000000) self.valueWidget.setReadOnly(True) - self.valueWidget.setStyleSheet( - "QLineEdit {border: 1px solid #a4a4a4; background-color: #f4f4f4}") + palette = self.valueWidget.palette() + palette.setColor(QtGui.QPalette.Base, + palette.color(QtGui.QPalette.Base).darker(107)) + self.valueWidget.setPalette(palette) self.valueWidget.setAlignment(QtCore.Qt.AlignCenter) else: self.valueWidget = QtWidgets.QLabel() @@ -465,28 +497,13 @@ def __init__(self, itemParent, variable, gui): ## QCheckbox for boolean variables elif self.variable.type in [bool]: - - class MyQCheckBox(QtWidgets.QCheckBox): - - def __init__(self, parent): - self.parent = parent - super().__init__() - - def mouseReleaseEvent(self, event): - super().mouseReleaseEvent(event) - self.parent.valueEdited() - self.parent.write() - self.valueWidget = MyQCheckBox(self) - # self.valueWidget = QtWidgets.QCheckBox() - # self.valueWidget.stateChanged.connect(self.valueEdited) - # self.valueWidget.stateChanged.connect(self.write) # removed this to avoid setting a second time when reading a change hbox = QtWidgets.QHBoxLayout() hbox.addWidget(self.valueWidget) hbox.setAlignment(QtCore.Qt.AlignCenter) hbox.setSpacing(0) hbox.setContentsMargins(0,0,0,0) - widget = QtWidgets.QWidget() + widget = QtWidgets.QFrame() widget.setLayout(hbox) if not self.variable.writable: # Disable interaction is not writable self.valueWidget.setEnabled(False) @@ -495,31 +512,13 @@ def mouseReleaseEvent(self, event): ## Combobox for tuples: Tuple[List[str], int] elif self.variable.type in [tuple]: - - class MyQComboBox(QtWidgets.QComboBox): - def __init__(self): - super().__init__() - self.readonly = False - self.wheel = True - self.key = True - - def mousePressEvent(self, event): - if not self.readonly: - super().mousePressEvent(event) - - def keyPressEvent(self, event): - if not self.readonly and self.key: - super().keyPressEvent(event) - - def wheelEvent(self, event): - if not self.readonly and self.wheel: - super().wheelEvent(event) - if self.variable.writable: self.valueWidget = MyQComboBox() self.valueWidget.wheel = False # prevent changing value by mistake self.valueWidget.key = False self.valueWidget.activated.connect(self.write) + self.valueWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.valueWidget.customContextMenuRequested.connect(self.openInputDialog) elif self.variable.readable: self.valueWidget = MyQComboBox() self.valueWidget.readonly = True @@ -530,9 +529,8 @@ def wheelEvent(self, event): self.gui.tree.setItemWidget(self, 3, self.valueWidget) # Main - column 4 : indicator (status of the actual value : known or not known) - if self.variable.type in [int, float, str, bool, tuple, np.ndarray, pd.DataFrame]: - self.indicator = QtWidgets.QLabel() - self.gui.tree.setItemWidget(self, 4, self.indicator) + self.indicator = QtWidgets.QLabel() + self.gui.tree.setItemWidget(self, 4, self.indicator) # Tooltip if self.variable._help is None: tooltip = 'No help available for this variable' @@ -545,24 +543,64 @@ def wheelEvent(self, event): # disable read button if array/dataframe if hasattr(self, 'readButtonCheck'): self.readButtonCheckEdited() + def openInputDialog(self, position: QtCore.QPoint): + """ Only used for tuple """ + menu = QtWidgets.QMenu() + modifyTuple = menu.addAction("Modify tuple") + modifyTuple.setIcon(icons['tuple']) + + choice = menu.exec_(self.valueWidget.mapToGlobal(position)) + + if choice == modifyTuple: + main_dialog = MyInputDialog(self.gui, self.variable.address()) + main_dialog.setWindowModality(QtCore.Qt.ApplicationModal) + if self.variable.type in [tuple]: + main_dialog.setTextValue(str(self.variable.value)) + main_dialog.show() + + if main_dialog.exec_() == QtWidgets.QInputDialog.Accepted: + response = main_dialog.textValue() + else: + response = '' + + if qt_object_exists(main_dialog): main_dialog.deleteLater() + + if response != '': + try: + if has_eval(response): + response = eval_variable(response) + if self.variable.type in [tuple]: + response = str_to_tuple(str(response)) + except Exception as e: + self.gui.setStatus( + f"Variable {self.variable.address()}: {e}", 10000, False) + return None + + self.variable(response) + + if self.variable.readable: + self.variable() + def writeGui(self, value): """ This function displays a new value in the GUI """ - if qt_object_exists(self.valueWidget): # avoid crash if device closed and try to write gui (if close device before reading finished) + if hasattr(self, 'valueWidget') and qt_object_exists(self.valueWidget): # avoid crash if device closed and try to write gui (if close device before reading finished) # Update value if self.variable.numerical: self.valueWidget.setText(f'{value:.{self.precision}g}') # default is .6g elif self.variable.type in [str]: self.valueWidget.setText(value) + elif self.variable.type in [bytes]: + self.valueWidget.setText(value.decode()) elif self.variable.type in [bool]: self.valueWidget.setChecked(value) elif self.variable.type in [tuple]: - items = [self.valueWidget.itemText(i) for i in range(self.valueWidget.count())] + items = [self.valueWidget.itemText(i) + for i in range(self.valueWidget.count())] if value[0] != items: self.valueWidget.clear() self.valueWidget.addItems(value[0]) self.valueWidget.setCurrentIndex(value[1]) elif self.variable.type in [np.ndarray, pd.DataFrame]: - if self.variable.writable or self.readButtonCheck.isChecked(): if self.variable.type in [np.ndarray]: self.valueWidget.setText(array_to_str(value)) @@ -572,21 +610,26 @@ def writeGui(self, value): # self.valueWidget.setText('') # Change indicator light to green - if self.variable.type in [int, float, bool, str, tuple, np.ndarray, pd.DataFrame]: + if self.variable.type in [ + int, float, bool, str, bytes, tuple, np.ndarray, pd.DataFrame]: self.setValueKnownState(True) def readGui(self): """ This function returns the value in good format of the value in the GUI """ - if self.variable.type in [int, float, str, np.ndarray, pd.DataFrame]: + if self.variable.type in [int, float, str, bytes, np.ndarray, pd.DataFrame]: value = self.valueWidget.text() if value == '': self.gui.setStatus( - f"Variable {self.variable.name} requires a value to be set", + f"Variable {self.variable.address()} requires a value to be set", 10000, False) else: try: - value = variables.eval_variable(value) - if self.variable.type in [np.ndarray]: + value = eval_variable(value) + if self.variable.type in [int]: + value = int(float(value)) + if self.variable.type in [bytes]: + value = value.encode() + elif self.variable.type in [np.ndarray]: if isinstance(value, str): value = str_to_array(value) else: value = create_array(value) elif self.variable.type in [pd.DataFrame]: @@ -594,23 +637,31 @@ def readGui(self): else: value = self.variable.type(value) return value - except: + except Exception as e: self.gui.setStatus( - f"Variable {self.variable.name}: Impossible to convert {value} to {self.variable.type.__name__}", + f"Variable {self.variable.address()}: {e}", 10000, False) elif self.variable.type in [bool]: value = self.valueWidget.isChecked() return value elif self.variable.type in [tuple]: - items = [self.valueWidget.itemText(i) for i in range(self.valueWidget.count())] + items = [self.valueWidget.itemText(i) + for i in range(self.valueWidget.count())] value = (items, self.valueWidget.currentIndex()) return value - def setValueKnownState(self, state: bool): + def setValueKnownState(self, state: Union[bool, float]): """ Turn the color of the indicator depending of the known state of the value """ - if state: self.indicator.setStyleSheet("background-color:#70db70") # green - else: self.indicator.setStyleSheet("background-color:#ff8c1a") # orange + if state == 0.5: + self.indicator.setStyleSheet("background-color:#FFFF00") # yellow + self.indicator.setToolTip('Value written but not read') + elif state: + self.indicator.setStyleSheet("background-color:#70db70") # green + self.indicator.setToolTip('Value read') + else: + self.indicator.setStyleSheet("background-color:#ff8c1a") # orange + self.indicator.setToolTip('Value not up-to-date') def read(self): """ Start a new thread to READ the associated variable """ @@ -625,30 +676,39 @@ def write(self): if value is not None: self.gui.threadManager.start(self, 'write', value=value) + def valueWrited(self, value: Any): + """ Function call when the value displayed in not sure anymore. + The value has been modified either in the GUI (but not sent) + or by command line. + If variable not readable, write the value sent to the GUI """ + # BUG: I got an error when changing emit_write to set value, need to reproduce it + try: + self.writeGui(value) + self.setValueKnownState(0.5) + except Exception as e: + self.gui.setStatus(f"SHOULD NOT RAISE ERROR: {e}", 10000, False) + def valueEdited(self): """ Function call when the value displayed in not sure anymore. - The value has been modified either in the GUI (but not sent) or by command line """ + The value has been modified either in the GUI (but not sent) + or by command line """ self.setValueKnownState(False) def readButtonCheckEdited(self): state = bool(self.readButtonCheck.isChecked()) - self.readButton.setEnabled(state) - # if not self.variable.writable: - # self.valueWidget.setVisible(state) # doesn't work on instantiation, but not problem if start with visible - - # if not state: self.valueWidget.setText('') - def menu(self, position: QtCore.QPoint): """ This function provides the menu when the user right click on an item """ if not self.isDisabled(): - menu = CustomMenu(self.gui) + menu = RecipeMenu(self.gui.scanner) monitoringAction = menu.addAction("Start monitoring") - monitoringAction.setIcon(QtGui.QIcon(icons['monitor'])) + monitoringAction.setIcon(icons['monitor']) + plottingAction = menu.addAction("Capture to plotter") + plottingAction.setIcon(icons['plotter']) menu.addSeparator() sliderAction = menu.addAction("Create a slider") - sliderAction.setIcon(QtGui.QIcon(icons['slider'])) + sliderAction.setIcon(icons['slider']) menu.addSeparator() scanParameterAction = menu.addAnyAction( @@ -660,26 +720,28 @@ def menu(self, position: QtCore.QPoint): menu.addSeparator() saveAction = menu.addAction("Read and save as...") - saveAction.setIcon(QtGui.QIcon(icons['read-save'])) + saveAction.setIcon(icons['read-save']) monitoringAction.setEnabled( self.variable.readable and self.variable.type in [ int, float, np.ndarray, pd.DataFrame]) + plottingAction.setEnabled(monitoringAction.isEnabled()) sliderAction.setEnabled((self.variable.writable - #and self.variable.readable and self.variable.type in [int, float])) scanParameterAction.setEnabled(self.variable.parameter_allowed) scanMeasureStepAction.setEnabled(self.variable.readable) saveAction.setEnabled(self.variable.readable) - scanSetStepAction.setEnabled( - self.variable.writable if self.variable.type not in [ - tuple] else False) # OPTIMIZE: forbid setting tuple to scanner + scanSetStepAction.setEnabled(self.variable.writable) choice = menu.exec_(self.gui.tree.viewport().mapToGlobal(position)) if choice is None: choice = menu.selected_action - if choice == monitoringAction: self.openMonitor() - elif choice == sliderAction: self.openSlider() + if choice == monitoringAction: + openMonitor(self.variable, has_parent=True) + if choice == plottingAction: + openPlotter(variable=self.variable, has_parent=True) + elif choice == sliderAction: + openSlider(self.variable, gui=self.gui, item=self) elif choice == scanParameterAction: recipe_name = self.gui.getRecipeName() param_name = self.gui.getParameterName() @@ -689,64 +751,30 @@ def menu(self, position: QtCore.QPoint): self.gui.addStepToScanRecipe(recipe_name, 'measure', self.variable) elif choice == scanSetStepAction: recipe_name = self.gui.getRecipeName() - self.gui.addStepToScanRecipe(recipe_name, 'set', self.variable) + value = self.variable.value if self.variable.type in [tuple] else None + self.gui.addStepToScanRecipe( + recipe_name, 'set', self.variable, value=value) elif choice == saveAction: self.saveValue() def saveValue(self): """ Prompt user for filename to save data of the variable """ filename, _ = QtWidgets.QFileDialog.getSaveFileName( - self.gui, f"Save {self.variable.name} value", os.path.join( - paths.USER_LAST_CUSTOM_FOLDER, f'{self.variable.address()}.txt'), + self.gui, f"Save {self.variable.address()} value", os.path.join( + PATHS['last_folder'], f'{self.variable.address()}.txt'), filter=SUPPORTED_EXTENSION) path = os.path.dirname(filename) if path != '': - paths.USER_LAST_CUSTOM_FOLDER = path + PATHS['last_folder'] = path try: - self.gui.setStatus(f"Saving value of {self.variable.name}...", - 5000) + self.gui.setStatus( + f"Saving value of {self.variable.address()}...", 5000) self.variable.save(filename) self.gui.setStatus( - f"Value of {self.variable.name} successfully read and save at {filename}", - 5000) + f"Value of {self.variable.address()} successfully read " \ + f"and save at {filename}", 5000) except Exception as e: - self.gui.setStatus(f"An error occured: {str(e)}", 10000, False) - - def openMonitor(self): - """ This function open the monitor associated to this variable. """ - # If the monitor is not already running, create one - if id(self) not in self.gui.monitors.keys(): - self.gui.monitors[id(self)] = Monitor(self) - self.gui.monitors[id(self)].show() - # If the monitor is already running, just make as the front window - else: - monitor = self.gui.monitors[id(self)] - monitor.setWindowState( - monitor.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) - monitor.activateWindow() - - def openSlider(self): - """ This function open the slider associated to this variable. """ - # If the slider is not already running, create one - if id(self) not in self.gui.sliders.keys(): - self.gui.sliders[id(self)] = Slider(self.variable, self) - self.gui.sliders[id(self)].show() - # If the slider is already running, just make as the front window - else: - slider = self.gui.sliders[id(self)] - slider.setWindowState( - slider.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) - slider.activateWindow() - - def clearMonitor(self): - """ This clear monitor instances reference when quitted """ - if id(self) in self.gui.monitors.keys(): - self.gui.monitors.pop(id(self)) - - def clearSlider(self): - """ This clear the slider instances reference when quitted """ - if id(self) in self.gui.sliders.keys(): - self.gui.sliders.pop(id(self)) + self.gui.setStatus(f"An error occured: {e}", 10000, False) # Signals can be emitted only from QObjects @@ -757,6 +785,6 @@ def emit_read(self, value): self.read.emit(value) class WriteSignal(QtCore.QObject): - writed = QtCore.Signal() - def emit_write(self): - self.writed.emit() + writed = QtCore.Signal(object) + def emit_write(self, value): + self.writed.emit(value) diff --git a/autolab/core/gui/icons/__init__.py b/autolab/core/gui/icons/__init__.py index 74881ed0..8313d8f0 100644 --- a/autolab/core/gui/icons/__init__.py +++ b/autolab/core/gui/icons/__init__.py @@ -4,87 +4,72 @@ @author: Jonathan """ +from typing import Dict, Union import os -icons_folder = os.path.dirname(__file__) +from qtpy import QtWidgets, QtGui +ICONS_FOLDER = os.path.dirname(__file__) +standardIcon = QtWidgets.QApplication.style().standardIcon -ACTION_ICON_NAME = os.path.join(icons_folder, 'action-icon.svg').replace("\\", "/") -ADD_ICON_NAME = os.path.join(icons_folder, 'add-icon.svg').replace("\\", "/") -AUTOLAB_ICON_NAME = os.path.join(icons_folder, 'autolab-icon.ico').replace("\\", "/") -CONFIG_ICON_NAME = os.path.join(icons_folder, 'config-icon.svg').replace("\\", "/") -DISCONNECT_ICON_NAME = os.path.join(icons_folder, 'disconnect-icon.svg').replace("\\", "/") -DOWN_ICON_NAME = os.path.join(icons_folder, 'down-icon.svg').replace("\\", "/") -EXPORT_ICON_NAME = os.path.join(icons_folder, 'export-icon.svg').replace("\\", "/") -GITHUB_ICON_NAME = os.path.join(icons_folder, 'github-icon.svg').replace("\\", "/") -IMPORT_ICON_NAME = os.path.join(icons_folder, 'import-icon.svg').replace("\\", "/") -IS_DISABLE_ICON_NAME = os.path.join(icons_folder, 'is-disable-icon.svg').replace("\\", "/") -IS_ENABLE_ICON_NAME = os.path.join(icons_folder, 'is-enable-icon.svg').replace("\\", "/") -MEASURE_ICON_NAME = os.path.join(icons_folder, 'measure-icon.svg').replace("\\", "/") -MONITOR_ICON_NAME = os.path.join(icons_folder, 'monitor-icon.svg').replace("\\", "/") -PARAMETER_ICON_NAME = os.path.join(icons_folder, 'parameter-icon.svg').replace("\\", "/") -PDF_ICON_NAME = os.path.join(icons_folder, 'pdf-icon.svg').replace("\\", "/") -PLOTTER_ICON_NAME = os.path.join(icons_folder, 'plotter-icon.svg').replace("\\", "/") -READ_SAVE_ICON_NAME = os.path.join(icons_folder, 'read-save-icon.svg').replace("\\", "/") -READTHEDOCS_ICON_NAME = os.path.join(icons_folder, 'readthedocs-icon.svg').replace("\\", "/") -RECIPE_ICON_NAME = os.path.join(icons_folder, 'recipe-icon.svg').replace("\\", "/") -REDO_ICON_NAME = os.path.join(icons_folder, 'redo-icon.svg').replace("\\", "/") -REMOVE_ICON_NAME = os.path.join(icons_folder, 'remove-icon.svg').replace("\\", "/") -RENAME_ICON_NAME = os.path.join(icons_folder, 'rename-icon.svg').replace("\\", "/") -SCANNER_ICON_NAME = os.path.join(icons_folder, 'scanner-icon.svg').replace("\\", "/") -SLIDER_ICON_NAME = os.path.join(icons_folder, 'slider-icon.svg').replace("\\", "/") -UNDO_ICON_NAME = os.path.join(icons_folder, 'undo-icon.svg').replace("\\", "/") -UP_ICON_NAME = os.path.join(icons_folder, 'up-icon.svg').replace("\\", "/") -WRITE_ICON_NAME = os.path.join(icons_folder, 'write-icon.svg').replace("\\", "/") +def format_icon_path(name: str) -> str: + return os.path.join(ICONS_FOLDER, name).replace("\\", "/") -icons = {'action': ACTION_ICON_NAME, - 'add': ADD_ICON_NAME, - 'autolab': AUTOLAB_ICON_NAME, - 'config': CONFIG_ICON_NAME, - 'disconnect': DISCONNECT_ICON_NAME, - 'down': DOWN_ICON_NAME, - 'export': EXPORT_ICON_NAME, - 'github': GITHUB_ICON_NAME, - 'import': IMPORT_ICON_NAME, - 'is-disable': IS_DISABLE_ICON_NAME, - 'is-enable': IS_ENABLE_ICON_NAME, - 'measure': MEASURE_ICON_NAME, - 'monitor': MONITOR_ICON_NAME, - 'parameter': PARAMETER_ICON_NAME, - 'plotter': PLOTTER_ICON_NAME, - 'pdf': PDF_ICON_NAME, - 'read-save': READ_SAVE_ICON_NAME, - 'readthedocs': READTHEDOCS_ICON_NAME, - 'recipe': RECIPE_ICON_NAME, - 'redo': REDO_ICON_NAME, - 'remove': REMOVE_ICON_NAME, - 'rename': RENAME_ICON_NAME, - 'scanner': SCANNER_ICON_NAME, - 'slider': SLIDER_ICON_NAME, - 'undo': UNDO_ICON_NAME, - 'up': UP_ICON_NAME, - 'write': WRITE_ICON_NAME, - } +def create_icon(name: str) -> QtGui.QIcon: + return QtGui.QIcon(format_icon_path(name)) -INT_ICON_NAME = os.path.join(icons_folder, 'int-icon.svg').replace("\\", "/") -FLOAT_ICON_NAME = os.path.join(icons_folder, 'float-icon.svg').replace("\\", "/") -STR_ICON_NAME = os.path.join(icons_folder, 'str-icon.svg').replace("\\", "/") -BYTES_ICON_NAME = os.path.join(icons_folder, 'bytes-icon.svg').replace("\\", "/") -BOOL_ICON_NAME = os.path.join(icons_folder, 'bool-icon.svg').replace("\\", "/") -TUPLE_ICON_NAME = os.path.join(icons_folder, 'tuple-icon.svg').replace("\\", "/") -NDARRAY_ICON_NAME = os.path.join(icons_folder, 'ndarray-icon.svg').replace("\\", "/") -DATAFRAME_ICON_NAME = os.path.join(icons_folder, 'dataframe-icon.svg').replace("\\", "/") +def create_pixmap(name: str) -> QtGui.QPixmap: + return QtGui.QPixmap(format_icon_path(name)) + + +icons: Dict[str, Union[QtGui.QIcon, QtGui.QPixmap, standardIcon]] = { + 'action': create_icon('action-icon.svg'), + 'add': create_icon('add-icon.svg'), + 'autolab': create_icon('autolab-icon.ico'), + 'autolab-pixmap': create_pixmap('autolab-icon.ico'), + 'config': create_icon('config-icon.svg'), + 'copy': create_icon('copy-icon.svg'), + 'disconnect': create_icon('disconnect-icon.svg'), + 'down': create_icon('down-icon.svg'), + 'export': create_icon('export-icon.svg'), + 'file': standardIcon(QtWidgets.QStyle.SP_FileDialogDetailedView), + 'folder': standardIcon(QtWidgets.QStyle.SP_DirIcon), + 'github': create_icon('github-icon.svg'), + 'import': create_icon('import-icon.svg'), + 'is-disable': create_icon('is-enable-icon.svg'), + 'is-enable': create_icon('is-enable-icon.svg'), + 'measure': create_icon('measure-icon.svg'), + 'monitor': create_icon('monitor-icon.svg'), + 'parameter': create_icon('parameter-icon.svg'), + 'paste': create_icon('paste-icon.svg'), + 'pdf': create_icon('pdf-icon.svg'), + 'plotter': create_icon('plotter-icon.svg'), + 'preference': create_icon('preference-icon.svg'), + 'read-save': create_icon('read-save-icon.svg'), + 'readthedocs': create_icon('readthedocs-icon.svg'), + # 'recipe': create_icon('recipe-icon.svg'), + 'redo': create_icon('redo-icon.svg'), + 'reload': standardIcon(QtWidgets.QStyle.SP_BrowserReload), + 'remove': create_icon('remove-icon.svg'), + 'rename': create_icon('rename-icon.svg'), + 'scanner': create_icon('scanner-icon.svg'), + 'slider': create_icon('slider-icon.svg'), + 'undo': create_icon('undo-icon.svg'), + 'up': create_icon('up-icon.svg'), + 'variables': create_icon('variables-icon.svg'), + 'write': create_icon('write-icon.svg'), +} icons.update({ -'int': INT_ICON_NAME, -'float': FLOAT_ICON_NAME, -'str': STR_ICON_NAME, -'bytes': BYTES_ICON_NAME, -'bool': BOOL_ICON_NAME, -'tuple': TUPLE_ICON_NAME, -'ndarray': NDARRAY_ICON_NAME, -'DataFrame': DATAFRAME_ICON_NAME, + 'int': create_icon('int-icon.svg'), + 'float': create_icon('float-icon.svg'), + 'str': create_icon('str-icon.svg'), + 'bytes': create_icon('bytes-icon.svg'), + 'bool': create_icon('bool-icon.svg'), + 'tuple': create_icon('tuple-icon.svg'), + 'ndarray': create_icon('ndarray-icon.svg'), + 'DataFrame': create_icon('dataframe-icon.svg'), }) diff --git a/autolab/core/gui/icons/copy-icon.svg b/autolab/core/gui/icons/copy-icon.svg new file mode 100644 index 00000000..e5df5475 --- /dev/null +++ b/autolab/core/gui/icons/copy-icon.svg @@ -0,0 +1,58 @@ + + + + + + + + + diff --git a/autolab/core/gui/icons/github-icon.svg b/autolab/core/gui/icons/github-icon.svg index 37fa923d..e30c6f83 100644 --- a/autolab/core/gui/icons/github-icon.svg +++ b/autolab/core/gui/icons/github-icon.svg @@ -1 +1,45 @@ - \ No newline at end of file + + + + + + + diff --git a/autolab/core/gui/icons/measure-icon.svg b/autolab/core/gui/icons/measure-icon.svg index ab7ee5c7..4a181b23 100644 --- a/autolab/core/gui/icons/measure-icon.svg +++ b/autolab/core/gui/icons/measure-icon.svg @@ -32,6 +32,10 @@ inkscape:current-layer="Layer_1" /> + Wondicon - UI (Free) + style="stroke-width:0.444;stroke:#ffffff;stroke-opacity:1;stroke-dasharray:none" + sodipodi:nodetypes="sssss" /> + style="fill:#ffffff;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 13.239079,7.2955734 v 7.4552886 h 7.455289 V 7.2955734 Z" + id="path10" /> + style="stroke-width:0.444;stroke:#ffffff;stroke-opacity:1;stroke-dasharray:none" + sodipodi:nodetypes="sssss" /> + style="fill:#ffffff;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 13.239079,31.846695 v 7.455288 h 7.455288 v -7.455288 z" + id="path12" /> - + style="stroke-width:0.444;stroke:#ffffff;stroke-opacity:1;stroke-dasharray:none" + sodipodi:nodetypes="ccccc" /> + + style="stroke-width:0.444;stroke:#ffffff;stroke-opacity:1;stroke-dasharray:none" /> + style="stroke-width:0.444;stroke:#ffffff;stroke-opacity:1;stroke-dasharray:none" /> + style="stroke-width:0.444;stroke:#ffffff;stroke-opacity:1;stroke-dasharray:none" /> diff --git a/autolab/core/gui/icons/recipe-icon.svg b/autolab/core/gui/icons/paste-icon.svg similarity index 91% rename from autolab/core/gui/icons/recipe-icon.svg rename to autolab/core/gui/icons/paste-icon.svg index 856268e2..a41ada2e 100644 --- a/autolab/core/gui/icons/recipe-icon.svg +++ b/autolab/core/gui/icons/paste-icon.svg @@ -3,7 +3,7 @@ version="1.2" viewBox="0 0 48 48" id="svg50" - sodipodi:docname="recipe-icon.svg" + sodipodi:docname="paste-icon.svg" width="48" height="48" inkscape:version="1.3 (0e150ed6c4, 2023-07-21)" @@ -21,7 +21,7 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:zoom="11.313709" - inkscape:cx="39.332813" + inkscape:cx="39.377007" inkscape:cy="36.062444" inkscape:window-width="1419" inkscape:window-height="1038" @@ -31,6 +31,13 @@ inkscape:current-layer="svg50" /> + + + + diff --git a/autolab/core/gui/icons/readthedocs-icon.svg b/autolab/core/gui/icons/readthedocs-icon.svg index 304e27b2..fc31119c 100644 --- a/autolab/core/gui/icons/readthedocs-icon.svg +++ b/autolab/core/gui/icons/readthedocs-icon.svg @@ -1 +1,41 @@ - \ No newline at end of file + + + + + + + diff --git a/autolab/core/gui/icons/remove-icon.svg b/autolab/core/gui/icons/remove-icon.svg index 57525e04..4a478838 100644 --- a/autolab/core/gui/icons/remove-icon.svg +++ b/autolab/core/gui/icons/remove-icon.svg @@ -20,9 +20,9 @@ inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" - inkscape:zoom="5.7692308" - inkscape:cx="21.493333" - inkscape:cy="35.013333" + inkscape:zoom="8.1589244" + inkscape:cx="18.200928" + inkscape:cy="33.215162" inkscape:window-width="1419" inkscape:window-height="1038" inkscape:window-x="485" @@ -31,6 +31,11 @@ inkscape:current-layer="svg50" /> + + + + + + + + (x) + + diff --git a/autolab/core/gui/icons/write-icon.svg b/autolab/core/gui/icons/write-icon.svg index 4e51fb5d..e5712e57 100644 --- a/autolab/core/gui/icons/write-icon.svg +++ b/autolab/core/gui/icons/write-icon.svg @@ -22,18 +22,27 @@ inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" - inkscape:zoom="8.5520833" - inkscape:cx="5.729598" - inkscape:cy="12.336175" + inkscape:zoom="6.0472361" + inkscape:cx="35.388068" + inkscape:cy="59.614011" inkscape:window-width="1920" inkscape:window-height="1017" inkscape:window-x="-8" inkscape:window-y="-8" inkscape:window-maximized="1" inkscape:current-layer="svg1" /> + + + style="stroke-width:2.33333" + sodipodi:nodetypes="ccccccscccsscssccscccsssccsssssss" /> diff --git a/autolab/core/gui/monitoring/data.py b/autolab/core/gui/monitoring/data.py index 01eec36f..cecfcf0b 100644 --- a/autolab/core/gui/monitoring/data.py +++ b/autolab/core/gui/monitoring/data.py @@ -48,7 +48,8 @@ def addPoint(self, point: Tuple[Any, Any]): if isinstance(y, (np.ndarray, pd.DataFrame)): if self.gui.windowLength_lineEdit.isVisible(): - self.gui.figureManager.setLabel('x', 'x') + self.gui.xlabel = 'x' + self.gui.figureManager.setLabel('x', self.gui.xlabel) self.gui.windowLength_lineEdit.hide() self.gui.windowLength_label.hide() self.gui.dataDisplay.hide() @@ -62,7 +63,8 @@ def addPoint(self, point: Tuple[Any, Any]): self._addArray(y.values.T) else: if not self.gui.windowLength_lineEdit.isVisible(): - self.gui.figureManager.setLabel('x', 'Time [s]') + self.gui.xlabel = 'Time(s)' + self.gui.figureManager.setLabel('x', self.gui.xlabel) self.gui.windowLength_lineEdit.show() self.gui.windowLength_label.show() self.gui.dataDisplay.show() diff --git a/autolab/core/gui/monitoring/figure.py b/autolab/core/gui/monitoring/figure.py index 487b334e..b66d413b 100644 --- a/autolab/core/gui/monitoring/figure.py +++ b/autolab/core/gui/monitoring/figure.py @@ -9,11 +9,11 @@ import numpy as np import pyqtgraph as pg -import pyqtgraph.exporters +import pyqtgraph.exporters # Needed for pg.exporters.ImageExporter from qtpy import QtWidgets from ..GUI_utilities import pyqtgraph_fig_ax, pyqtgraph_image -from ... import config +from ...config import get_monitor_config from ...utilities import boolean @@ -24,7 +24,7 @@ def __init__(self, gui: QtWidgets.QMainWindow): self.gui = gui # Import Autolab config - monitor_config = config.get_monitor_config() + monitor_config = get_monitor_config() self.precision = int(monitor_config['precision']) self.do_save_figure = boolean(monitor_config['save_figure']) @@ -41,9 +41,9 @@ def __init__(self, gui: QtWidgets.QMainWindow): self.plot = self.ax.plot([], [], symbol='x', pen='r', symbolPen='r', symbolSize=10, symbolBrush='r') self.plot_mean = self.ax.plot([], [], pen=pg.mkPen( - color=0.4, width=2, style=pg.QtCore.Qt.DashLine)) - self.plot_min = self.ax.plot([], [], pen=pg.mkPen(color=0.4, width=2)) - self.plot_max = self.ax.plot([], [], pen=pg.mkPen(color=0.4, width=2)) + color=pg.getConfigOption("foreground"), width=2, style=pg.QtCore.Qt.DashLine)) + self.plot_min = self.ax.plot([], [], pen=pg.mkPen(color=pg.getConfigOption("foreground"), width=2)) + self.plot_max = self.ax.plot([], [], pen=pg.mkPen(color=pg.getConfigOption("foreground"), width=2)) self.ymin = None self.ymax = None @@ -125,7 +125,8 @@ def setLabel(self, axe: str, value: str): """ This function changes the label of the given axis """ axes = {'x':'bottom', 'y':'left'} if value == '': value = ' ' - self.ax.setLabel(axes[axe], value, **{'color':0.4, 'font-size': '12pt'}) + self.ax.setLabel(axes[axe], value, **{'color': pg.getConfigOption("foreground"), + 'font-size': '12pt'}) def clear(self): self.ymin = None diff --git a/autolab/core/gui/monitoring/interface.ui b/autolab/core/gui/monitoring/interface.ui index f011e5ea..2bb65f5c 100644 --- a/autolab/core/gui/monitoring/interface.ui +++ b/autolab/core/gui/monitoring/interface.ui @@ -14,237 +14,253 @@ MainWindow - + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + - - - - - - - - Qt::Horizontal + + + Qt::Vertical + + + + + + + + 0 - - - 40 - 20 - - - - - - - - - + + + + Qt::Horizontal + + - 100 - 16777215 + 40 + 20 - - - 9 - - - - Pause delay between each measure - - + - - - - - 9 - - - - Delay [s] : + + + + + + + 100 + 16777215 + + + + Pause delay between each measure + + + + + + + Delay [s] : + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Window length [s] : + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 100 + 16777215 + + + + Size of the current window in seconds + + + + + + + + + Qt::Horizontal - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + 20 + 20 + - + - - - - - 9 - + + + + + 0 + 0 + - - Window length [s] : - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + 70 + 20 + - - - - - 100 - 16777215 + 200 + 100 - - - 9 - + + ArrowCursor - - Size of the current window in seconds + + false - - - - - - - - Qt::Horizontal - - - - 20 - 20 - - - - - - - - - 0 - 0 - - - - - 70 - 20 - - - - - 200 - 100 - - - - ArrowCursor - - - false - - - Qt::NoFocus - - - false - - - Qt::AlignCenter - - - true - - - - - - - Qt::Horizontal - - - - 20 - 20 - - - - - - - - - - - 9 - - - - Pause (or resume) monitoring - - - Pause + + Qt::NoFocus - - - - - - Mean + + false - - - - - - Clear + + Qt::AlignCenter - - - - - - Min + + true - - - - - 9 - + + + + Qt::Horizontal - - Save data displayed - - - Save + + + 20 + 20 + - + + + + + + + + Save data displayed + + + Save + + + + + + + Mean + + + + + + + Max + + + + + + + Clear + + + + + + + Pause (or resume) monitoring + + + Pause + + + + + + + Min + + + + + + + + + Pause monitoring on scan start if contains this variable + + + Pause on scan start + + + + + + + Start on scan end + + + + + + - - - - Max + + + + Qt::Horizontal - + + + 40 + 20 + + + - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - + + diff --git a/autolab/core/gui/monitoring/main.py b/autolab/core/gui/monitoring/main.py index 77d19ccf..4f1862ae 100644 --- a/autolab/core/gui/monitoring/main.py +++ b/autolab/core/gui/monitoring/main.py @@ -4,36 +4,40 @@ @author: qchat """ +from typing import Union import os import sys import queue -from qtpy import QtCore, QtWidgets, uic, QtGui +from qtpy import QtCore, QtWidgets, uic from .data import DataManager from .figure import FigureManager from .monitor import MonitorManager from ..icons import icons -from ... import paths from ..GUI_utilities import get_font_size, setLineEditBackground +from ..GUI_instances import clearMonitor +from ...paths import PATHS from ...utilities import SUPPORTED_EXTENSION +from ...elements import Variable as Variable_og +from ...variables import Variable class Monitor(QtWidgets.QMainWindow): - def __init__(self, item: QtWidgets.QTreeWidgetItem): - self.gui = item if isinstance(item, QtWidgets.QTreeWidgetItem) else None - self.item = item - self.variable = item.variable - - self._font_size = get_font_size() + 1 + def __init__(self, + variable: Union[Variable, Variable_og], + has_parent: bool = False): + self.has_parent = has_parent # Only for closeEvent + self.variable = variable + self._font_size = get_font_size() # Configuration of the window super().__init__() ui_path = os.path.join(os.path.dirname(__file__), 'interface.ui') uic.loadUi(ui_path, self) - self.setWindowTitle(f"AUTOLAB - Monitor: Variable {self.variable.address()}") - self.setWindowIcon(QtGui.QIcon(icons['monitor'])) + self.setWindowTitle(f"AUTOLAB - Monitor: {self.variable.address()}") + self.setWindowIcon(icons['monitor']) # Queue self.queue = queue.Queue() self.timer = QtCore.QTimer(self) @@ -48,7 +52,7 @@ def __init__(self, item: QtWidgets.QTreeWidgetItem): setLineEditBackground( self.windowLength_lineEdit, 'synced', self._font_size) - self.xlabel = '' # defined in data according to data type + self.xlabel = 'Time(s)' # Is changed to x if ndarray or dataframe self.ylabel = f'{self.variable.address()}' # OPTIMIZE: could depend on 1D or 2D if self.variable.unit is not None: @@ -61,22 +65,11 @@ def __init__(self, item: QtWidgets.QTreeWidgetItem): self.delay_lineEdit, 'edited', self._font_size)) setLineEditBackground(self.delay_lineEdit, 'synced', self._font_size) - # Pause self.pauseButton.clicked.connect(self.pauseButtonClicked) - - # Save self.saveButton.clicked.connect(self.saveButtonClicked) - - # Clear self.clearButton.clicked.connect(self.clearButtonClicked) - - # Mean self.mean_checkBox.clicked.connect(self.mean_checkBoxClicked) - - # Min self.min_checkBox.clicked.connect(self.min_checkBoxClicked) - - # Max self.max_checkBox.clicked.connect(self.max_checkBoxClicked) # Managers @@ -90,6 +83,31 @@ def __init__(self, item: QtWidgets.QTreeWidgetItem): self.monitorManager.start() self.timer.start() + # Use to pause monitor on scan start + self.pause_on_scan = False + self.start_on_scan = False + if self.has_parent: + self.pause_on_scan_checkBox.clicked.connect( + self.pause_on_scan_checkBoxClicked) + self.start_on_scan_checkBox.clicked.connect( + self.start_on_scan_checkBoxClicked) + else: + self.pause_on_scan_checkBox.hide() + self.start_on_scan_checkBox.hide() + + for splitter in (self.splitter, ): + for i in range(splitter.count()): + handle = splitter.handle(i) + handle.setStyleSheet("background-color: #DDDDDD;") + handle.installEventFilter(self) + + def eventFilter(self, obj, event): + if event.type() == QtCore.QEvent.Enter: + obj.setStyleSheet("background-color: #AAAAAA;") # Hover color + elif event.type() == QtCore.QEvent.Leave: + obj.setStyleSheet("background-color: #DDDDDD;") # Normal color + return super().eventFilter(obj, event) + def sync(self): """ This function updates the data and then the figure. Function called by the time """ @@ -126,14 +144,14 @@ def saveButtonClicked(self): # Ask the filename of the output data filename, _ = QtWidgets.QFileDialog.getSaveFileName( self, caption="Save data", directory=os.path.join( - paths.USER_LAST_CUSTOM_FOLDER, + PATHS['last_folder'], f'{self.variable.address()}_monitor.txt'), filter=SUPPORTED_EXTENSION) path = os.path.dirname(filename) # Save the given path for future, the data and the figure if the path provided is valid if path != '': - paths.USER_LAST_CUSTOM_FOLDER = path + PATHS['last_folder'] = path self.setStatus('Saving data...', 5000) try: @@ -175,15 +193,24 @@ def max_checkBoxClicked(self): if len(xlist) > 0: self.figureManager.update(xlist, ylist) + def pause_on_scan_checkBoxClicked(self): + """ Change pause_on_scan variable """ + self.pause_on_scan = self.pause_on_scan_checkBox.isChecked() + + def start_on_scan_checkBoxClicked(self): + """ Change start_on_scan variable """ + self.start_on_scan = self.start_on_scan_checkBox.isChecked() + def closeEvent(self, event): """ This function does some steps before the window is really killed """ self.monitorManager.close() self.timer.stop() - if hasattr(self.item, 'clearMonitor'): self.item.clearMonitor() self.figureManager.fig.deleteLater() # maybe not useful for monitor but was source of crash in scanner if didn't close self.figureManager.figMap.deleteLater() - if self.gui is None: + clearMonitor(self.variable) + + if not self.has_parent: import pyqtgraph as pg try: # Prevent 'RuntimeError: wrapped C/C++ object of type ViewBox has been deleted' when reloading gui @@ -195,10 +222,11 @@ def closeEvent(self, event): for children in self.findChildren(QtWidgets.QWidget): children.deleteLater() + super().closeEvent(event) - if self.gui is None: - QtWidgets.QApplication.quit() # close the monitor app + if not self.has_parent: + QtWidgets.QApplication.quit() # close the app def windowLengthChanged(self): """ This function start the update of the window length in the data manager @@ -240,7 +268,7 @@ def updateDelayGui(self): self.delay_lineEdit.setText(f'{value:g}') setLineEditBackground(self.delay_lineEdit, 'synced', self._font_size) - def setStatus(self, message: str, timeout: int = 0, stdout: bool = True): + def setStatus(self, message: str, timeout: int = 0, stdout: bool = True): """ Modify the message displayed in the status bar and add error message to logger """ self.statusBar.showMessage(message, timeout) if not stdout: print(message, file=sys.stderr) diff --git a/autolab/core/gui/monitoring/monitor.py b/autolab/core/gui/monitoring/monitor.py index b95f3106..0a59291e 100644 --- a/autolab/core/gui/monitoring/monitor.py +++ b/autolab/core/gui/monitoring/monitor.py @@ -4,6 +4,7 @@ @author: qchat """ +from typing import Union import time import threading from queue import Queue @@ -12,7 +13,8 @@ import pandas as pd from qtpy import QtCore, QtWidgets -from ...devices import Device +from ...variables import Variable +from ...elements import Variable as Variable_og class MonitorManager: @@ -67,7 +69,7 @@ class MonitorThread(QtCore.QThread): errorSignal = QtCore.Signal(object) - def __init__(self, variable: Device, queue: Queue): + def __init__(self, variable: Union[Variable, Variable_og], queue: Queue): super().__init__() self.variable = variable diff --git a/autolab/core/gui/plotting/data.py b/autolab/core/gui/plotting/data.py index f0205ec9..90ba5822 100644 --- a/autolab/core/gui/plotting/data.py +++ b/autolab/core/gui/plotting/data.py @@ -5,6 +5,7 @@ @author: jonathan based on qchat """ +from typing import List, Union, Any import os import sys import csv @@ -17,11 +18,14 @@ except: no_default = None -from qtpy import QtCore, QtWidgets +from qtpy import QtWidgets -from ... import paths -from ... import config -from ... import utilities +from ...paths import PATHS +from ...config import load_config +from ...utilities import data_to_dataframe, SUPPORTED_EXTENSION +from ...devices import DEVICES, get_element_by_address, list_devices +from ...elements import Variable as Variable_og +from ...variables import list_variables, get_variable, Variable def find_delimiter(filename): @@ -62,7 +66,8 @@ def find_header(filename, sep=no_default, skiprows=None): else: if skiprows == 1: try: - df_columns = pd.read_csv(filename, sep=sep, header="infer", skiprows=0, nrows=0) + df_columns = pd.read_csv(filename, sep=sep, header="infer", + skiprows=0, nrows=0) except Exception: pass else: @@ -74,13 +79,16 @@ def find_header(filename, sep=no_default, skiprows=None): try: first_row = df.iloc[0].values.astype("float") - return ("infer", skiprows, no_default) if tuple(first_row) == tuple([i for i in range(len(first_row))]) else (None, skiprows, no_default) + return (("infer", skiprows, no_default) + if tuple(first_row) == tuple([i for i in range(len(first_row))]) + else (None, skiprows, no_default)) except: pass df_header = pd.read_csv(filename, sep=sep, nrows=5, skiprows=skiprows) - return ("infer", skiprows, no_default) if tuple(df.dtypes) != tuple(df_header.dtypes) else (None, skiprows, no_default) - + return (("infer", skiprows, no_default) + if tuple(df.dtypes) != tuple(df_header.dtypes) + else (None, skiprows, no_default)) def importData(filename): @@ -90,52 +98,52 @@ def importData(filename): sep = find_delimiter(filename) (header, skiprows, columns) = find_header(filename, sep, skiprows) try: - data = pd.read_csv(filename, sep=sep, header=header, skiprows=skiprows, names=columns) + data = pd.read_csv(filename, sep=sep, header=header, + skiprows=skiprows, names=columns) except TypeError: - data = pd.read_csv(filename, sep=sep, header=header, skiprows=skiprows, names=None) # for pandas 1.2: names=None but sep=no_default + data = pd.read_csv(filename, sep=sep, header=header, + skiprows=skiprows, names=None) # for pandas 1.2: names=None but sep=no_default except: - data = pd.read_csv(filename, sep="\t", header=header, skiprows=skiprows, names=columns) + data = pd.read_csv(filename, sep="\t", header=header, + skiprows=skiprows, names=columns) assert len(data) != 0, "Can't import empty DataFrame" - data = utilities.formatData(data) + data = data_to_dataframe(data) return data +class DataManager: -class DataManager : - - def __init__(self,gui): + def __init__(self, gui: QtWidgets.QMainWindow): self.gui = gui - self._clear() - self.overwriteData = True - plotter_config = config.load_config("plotter") - if 'device' in plotter_config.sections() and 'address' in plotter_config['device']: - self.deviceValue = str(plotter_config['device']['address']) + plotter_config = load_config("plotter_config") + if ('device' in plotter_config.sections() + and 'address' in plotter_config['device']): + self.variable_address = str(plotter_config['device']['address']) else: - self.deviceValue = '' + self.variable_address = '' def _clear(self): self.datasets = [] self.last_variables = [] - def setOverwriteData(self, value): + def setOverwriteData(self, value: bool): self.overwriteData = bool(value) - def getDatasetsNames(self): + def getDatasetsNames(self) -> List[str]: names = [] for dataset in self.datasets: names.append(dataset.name) return names @staticmethod - def getUniqueName(name, names_list): - - """ This function adds a number next to basename in case this basename is already taken """ - + def getUniqueName(name: str, names_list: List[str]): + """ This function adds a number next to basename in case this basename + is already taken """ raw_name, extension = os.path.splitext(name) if extension in (".txt", ".csv", ".dat"): @@ -146,48 +154,51 @@ def getUniqueName(name, names_list): putext = False compt = 0 - while True : - if name in names_list : + while True: + if name in names_list: compt += 1 if putext: - name = basename+'_'+str(compt)+extension + name = basename+'_' + str(compt) + extension else: - name = basename+'_'+str(compt) - else : + name = basename + '_' + str(compt) + else: break return name - def setDeviceValue(self,value): - """ This function set the value of the target device value """ - - try: - self.getDeviceName(value) - except: - raise NameError(f"The given value '{value}' is not a device variable or the device is closed") - else: - self.deviceValue = value + def set_variable_address(self, value: str): + """ This function set the address of the target variable """ + # Can raise errors + self.getVariable(value) + # If no errors + self.variable_address = value - def getDeviceValue(self): + def get_variable_address(self) -> str: """ This function returns the value of the target device value """ + return self.variable_address - return self.deviceValue - - def getDeviceName(self, name): + def getVariable(self, name: str) -> Union[Variable, Variable_og]: """ This function returns the name of the target device value """ + assert name != '', 'Need to provide a variable name' + device = name.split(".")[0] + + if device in list_variables(): + assert device == name, f"Can only use '{device}' directly" + variable = get_variable(name) + elif device in list_devices(): + assert device in DEVICES, f"Device '{device}' is closed" + variable = get_element_by_address(name) # Can raise 'name' not found in module 'device' + assert isinstance(variable, Variable_og), ( + f"Need a variable but '{name}' is a {str(type(variable).__name__)}") + assert variable.readable, f"Variable '{name}' is not readable" + var_type = str(variable.type).split("'")[1] + assert variable.type in (int, float, np.ndarray, pd.DataFrame), ( + f"Datatype '{var_type}' of '{name}' is not supported") + else: + assert False, f"'{device}' is neither a device nor a variable" - try: - module_name, *submodules_name, variable_name = name.split(".") - module = self.gui.mainGui.tree.findItems(module_name, QtCore.Qt.MatchExactly)[0].module - for submodule_name in submodules_name: - module = module.get_module(submodule_name) - variable = module.get_variable(variable_name) - except: - raise NameError(f"The given value '{name}' is not a device variable or the device is closed") return variable - def data_comboBoxClicked(self): - """ This function select a dataset """ if len(self.datasets) == 0: self.gui.save_pushButton.setEnabled(False) @@ -197,58 +208,63 @@ def data_comboBoxClicked(self): self.updateDisplayableResults() def importActionClicked(self): - """ This function prompts the user for a dataset filename, and import the dataset""" - filenames = QtWidgets.QFileDialog.getOpenFileNames( - self.gui, "Import data file", paths.USER_LAST_CUSTOM_FOLDER, - filter=utilities.SUPPORTED_EXTENSION)[0] + self.gui, "Import data file", PATHS['last_folder'], + filter=SUPPORTED_EXTENSION)[0] if not filenames: - return + return None else: self.importAction(filenames) - def importAction(self, filenames): + def importAction(self, filenames: List[str]): dataset = None for i, filename in enumerate(filenames): - - try : + try: dataset = self.importData(filename) - except Exception as error: - self.gui.setStatus(f"Impossible to load data from {filename}: {error}",10000, False) + except Exception as e: + self.gui.setStatus( + f"Impossible to load data from {filename}: {e}", + 10000, False) if len(filenames) != 1: - print(f"Impossible to load data from {filename}: {error}", file=sys.stderr) + print(f"Impossible to load data from {filename}: {e}", + file=sys.stderr) else: - self.gui.setStatus(f"File {filename} loaded successfully",5000) + self.gui.setStatus(f"File {filename} loaded successfully", 5000) if dataset is not None: self.gui.figureManager.start(dataset) path = os.path.dirname(filename) - paths.USER_LAST_CUSTOM_FOLDER = path + PATHS['last_folder'] = path - def importDeviceData(self, deviceVariable): + def importDeviceData(self, variable: Union[Variable, Variable_og, pd.DataFrame, Any]): """ This function open the data of the provided device """ - - data = deviceVariable() - - data = utilities.formatData(data) + if isinstance(variable, pd.DataFrame): + name = variable.name if hasattr(variable, 'name') else 'dataframe' + data = variable + elif isinstance(variable, (Variable, Variable_og)): + name = variable.address() + data = variable() # read value + else: + name = 'data' + data = variable + data = data_to_dataframe(data) # format value if self.overwriteData: - data_name = self.deviceValue + data_name = name else: names_list = self.getDatasetsNames() - data_name = DataManager.getUniqueName(self.deviceValue, names_list) + data_name = DataManager.getUniqueName( + name, names_list) dataset = self.newDataset(data_name, data) return dataset - def importData(self, filename): + def importData(self, filename: str): """ This function open the data with the provided filename """ # OPTIMIZE: could add option to choose in GUI all options - data = importData(filename) - name = os.path.basename(filename) if self.overwriteData: @@ -273,20 +289,18 @@ def _addData(self, new_dataset): # Prepare a new dataset in the plotter self.gui.dataManager.addDataset(new_dataset) - def getData(self,nbDataset,varList, selectedData=0): + def getData(self, nbDataset: int, var_list: List[str], selectedData: int = 0): """ This function returns to the figure manager the required data """ - dataList = [] - - for i in range(selectedData, nbDataset+selectedData) : - if i < len(self.datasets) : + for i in range(selectedData, nbDataset+selectedData): + if i < len(self.datasets): dataset = self.datasets[-(i+1)] try: - data = dataset.getData(varList) + data = dataset.getData(var_list) except: data = None dataList.append(data) - else : + else: break dataList.reverse() @@ -295,24 +309,22 @@ def getData(self,nbDataset,varList, selectedData=0): def saveButtonClicked(self): """ This function is called when the save button is clicked. It asks a path and starts the procedure to save the data """ - dataset = self.getLastSelectedDataset() - if dataset is not None : - - filename, _ = QtWidgets.QFileDialog.getSaveFileName( - self.gui, caption="Save data", - directory=paths.USER_LAST_CUSTOM_FOLDER, - filter=utilities.SUPPORTED_EXTENSION) + if dataset is not None: + filename = QtWidgets.QFileDialog.getSaveFileName( + self.gui, caption="Save data", directory=PATHS['last_folder'], + filter=SUPPORTED_EXTENSION)[0] path = os.path.dirname(filename) - if path != '' : - paths.USER_LAST_CUSTOM_FOLDER = path - self.gui.setStatus('Saving data...',5000) + if path != '': + PATHS['last_folder'] = path + self.gui.setStatus('Saving data...', 5000) dataset.save(filename) self.gui.figureManager.save(filename) - self.gui.setStatus(f'Last dataset successfully saved in {filename}',5000) + self.gui.setStatus( + f'Last dataset successfully saved in {filename}', 5000) def clear(self): """ Clear displayed dataset """ @@ -325,8 +337,8 @@ def clear(self): self.deleteData(dataset) self.gui.data_comboBox.removeItem(index) self.gui.setStatus(f"Removed {data_name}", 5000) - except Exception as error: - self.gui.setStatus(f"Can't delete: {error}", 10000, False) + except Exception as e: + self.gui.setStatus(f"Can't delete: {e}", 10000, False) pass if self.gui.data_comboBox.count() == 0: @@ -334,7 +346,8 @@ def clear(self): return else: - if index == (nbr_data-1) and index != 0: # if last point but exist other data takes previous data else keep index + # if last point but exist other data takes previous data else keep index + if index == (nbr_data-1) and index != 0: index -= 1 self.gui.data_comboBox.setCurrentIndex(index) @@ -344,7 +357,6 @@ def clear(self): def clear_all(self): """ This reset any recorded data, and the GUI accordingly """ - self._clear() self.gui.figureManager.clearData() @@ -357,70 +369,71 @@ def clear_all(self): def deleteData(self, dataset): """ This function remove dataset from the datasets""" - self.datasets.remove(dataset) def getLastSelectedDataset(self): """ This return the current (last selected) dataset """ - if len(self.datasets) > 0: return self.datasets[self.gui.data_comboBox.currentIndex()] def addDataset(self, dataset): """ This function add the given dataset to datasets list """ - self.datasets.append(dataset) - def newDataset(self, name, data): + def newDataset(self, name: str, data: pd.DataFrame): """ This function creates a new dataset """ - - dataset = Dataset(self.gui, name, data) + dataset = Dataset(name, data) self._addData(dataset) return dataset def updateDisplayableResults(self): - """ This function update the combobox in the GUI that displays the names of - the results that can be plotted """ - + """ This function update the combobox in the GUI that displays the + names of the results that can be plotted """ dataset = self.getLastSelectedDataset() variables_list = list(dataset.data.columns) if variables_list != self.last_variables: self.last_variables = variables_list - resultNamesList = [] + result_names = [] - for resultName in variables_list: + for result_name in variables_list: try: - float(dataset.data.iloc[0][resultName]) - resultNamesList.append(resultName) - except Exception as er: - self.gui.setStatus(f"Can't plot data: {er}", 10000, False) - return + float(dataset.data.iloc[0][result_name]) + result_names.append(result_name) + except Exception as e: + self.gui.setStatus(f"Can't plot data: {e}", 10000, False) + return None variable_x = self.gui.variable_x_comboBox.currentText() variable_y = self.gui.variable_y_comboBox.currentText() # If can, put back previous x and y variable in combobox - is_id = variables_list[0] == "id" and len(variables_list) > 2 - - if is_id: # move id form begin to end - name=resultNamesList.pop(0) - resultNamesList.append(name) + is_id = (len(variables_list) != 0 + and variables_list[0] == "id" + and len(variables_list) > 2) - AllItems = [self.gui.variable_x_comboBox.itemText(i) for i in range(self.gui.variable_x_comboBox.count())] + if is_id: + # move id form begin to end + name=result_names.pop(0) + result_names.append(name) - if resultNamesList != AllItems: # only refresh if change labels, to avoid gui refresh that prevent user to click on combobox + AllItems = [self.gui.variable_x_comboBox.itemText(i) + for i in range(self.gui.variable_x_comboBox.count())] + # only refresh if change labels, to avoid gui refresh that prevent user to click on combobox + if result_names != AllItems: self.gui.variable_x_comboBox.clear() self.gui.variable_y_comboBox.clear() - self.gui.variable_x_comboBox.addItems(resultNamesList) # slow (0.25s) + self.gui.variable_x_comboBox.addItems(result_names) # slow (0.25s) + + if len(result_names) != 0: + # move first item to end (only for y axis) + name=result_names.pop(0) + result_names.append(name) - # move first item to end (only for y axis) - name=resultNamesList.pop(0) - resultNamesList.append(name) - self.gui.variable_y_comboBox.addItems(resultNamesList) + self.gui.variable_y_comboBox.addItems(result_names) if variable_x in variables_list: index = self.gui.variable_x_comboBox.findText(variable_x) @@ -437,34 +450,28 @@ def updateDisplayableResults(self): self.gui.figureManager.reloadData() # 0.1s - class Dataset(): - def __init__(self,gui,name, data): + def __init__(self, name: str, data: pd.DataFrame): - self.gui = gui self.name = name self.data = data - def getData(self,varList): - """ This function returns a dataframe with two columns : the parameter value, - and the requested result value """ - - if varList[0] == varList[1] : return self.data.loc[:,[varList[0]]] - else : return self.data.loc[:,varList] + def getData(self, var_list: List[str]): + """ This function returns a dataframe with two columns: + the parameter value, and the requested result value """ + if var_list[0] == var_list[1]: return self.data.loc[:, [var_list[0]]] + else: return self.data.loc[:, var_list] def update(self, dataset): """ Change name and data of this dataset """ - self.name = dataset.name self.data = dataset.data - def save(self,filename): + def save(self,filename: str): """ This function saved the dataset in the provided path """ - - self.data.to_csv(filename,index=False) + self.data.to_csv(filename, index=False) def __len__(self): """ Returns the number of data point of this dataset """ - return len(self.data) diff --git a/autolab/core/gui/plotting/figure.py b/autolab/core/gui/plotting/figure.py index bab3ff27..538e5034 100644 --- a/autolab/core/gui/plotting/figure.py +++ b/autolab/core/gui/plotting/figure.py @@ -7,14 +7,16 @@ import os import pyqtgraph as pg -import pyqtgraph.exporters +import pyqtgraph.exporters # Needed for pg.exporters.ImageExporter + +from qtpy import QtWidgets from ..GUI_utilities import pyqtgraph_fig_ax class FigureManager: - def __init__(self, gui): + def __init__(self, gui: QtWidgets.QMainWindow): self.gui = gui self.curves = [] @@ -29,17 +31,17 @@ def __init__(self, gui): def start(self, new_dataset=None): """ This function display data and ajust buttons """ try: - resultNamesList = [dataset.name for dataset in self.gui.dataManager.datasets] - AllItems = [self.gui.data_comboBox.itemText(i) for i in range(self.gui.data_comboBox.count())] + result_names = [dataset.name for dataset in self.gui.dataManager.datasets] + all_items = [self.gui.data_comboBox.itemText(i) for i in range(self.gui.data_comboBox.count())] index = self.gui.data_comboBox.currentIndex() - if resultNamesList != AllItems: # only refresh if change labels, to avoid gui refresh that prevent user to click on combobox + if result_names != all_items: # only refresh if change labels, to avoid gui refresh that prevent user to click on combobox self.gui.data_comboBox.clear() - self.gui.data_comboBox.addItems(resultNamesList) # slow (0.25s) + self.gui.data_comboBox.addItems(result_names) # slow (0.25s) if new_dataset is None: - if (index + 1) > len(resultNamesList) or index == -1: index = 0 + if (index + 1) > len(result_names) or index == -1: index = 0 self.gui.data_comboBox.setCurrentIndex(index) else: index = self.gui.data_comboBox.findText(new_dataset.name) @@ -57,21 +59,19 @@ def start(self, new_dataset=None): self.gui.setStatus(f'Data {data_name} plotted!', 5000) except Exception as e: - self.gui.setStatus(f'ERROR The data cannot be plotted with the given dataset: {str(e)}', - 10000, False) + self.gui.setStatus( + f'ERROR The data cannot be plotted with the given dataset: {e}', + 10000, False) # AXE LABEL ########################################################################### - def getLabel(self, axe: str): - """ This function get the label of the given axis """ - return getattr(self.gui, f"variable_{axe}_comboBox").currentText() - def setLabel(self, axe: str, value: str): """ This function changes the label of the given axis """ - axes = {'x':'bottom', 'y':'left'} + axes = {'x': 'bottom', 'y': 'left'} if value == '': value = ' ' - self.ax.setLabel(axes[axe], value, **{'color':0.4, 'font-size': '12pt'}) + self.ax.setLabel(axes[axe], value, **{'color': pg.getConfigOption("foreground"), + 'font-size': '12pt'}) # PLOT DATA @@ -79,7 +79,7 @@ def setLabel(self, axe: str, value: str): def clearData(self): """ This function removes any plotted curves """ - for curve in self.curves : + for curve in self.curves: self.ax.removeItem(curve) self.curves = [] @@ -90,8 +90,8 @@ def reloadData(self): self.clearData() # Get current displayed result - variable_x = self.getLabel("x") - variable_y = self.getLabel("y") + variable_x = self.gui.variable_x_comboBox.currentText() + variable_y = self.gui.variable_y_comboBox.currentText() data_id = int(self.gui.data_comboBox.currentIndex()) + 1 data_len = len(self.gui.dataManager.datasets) selectedData = data_len - data_id @@ -103,16 +103,17 @@ def reloadData(self): try : # OPTIMIZE: currently load all data and plot more than self.nbtraces if in middle # Should change to only load nbtraces and plot nbtraces - data = self.gui.dataManager.getData(data_len, [variable_x,variable_y], selectedData=0) + data = self.gui.dataManager.getData( + data_len, [variable_x,variable_y], selectedData=0) # data = self.gui.dataManager.getData(self.nbtraces,[variable_x,variable_y], selectedData=selectedData) - except : + except: data = None # Plot them - if data is not None : + if data is not None: - for i in range(len(data)) : - if i != (data_id-1): + for i in range(len(data)): + if i != (data_id - 1): # Data subdata = data[i] if subdata is None: @@ -123,37 +124,44 @@ def reloadData(self): y = subdata.loc[:,variable_y] # Apprearance: - color = 'k' - alpha = (self.nbtraces-abs(data_id-1-i))/self.nbtraces + color = pg.getConfigOption("foreground") + alpha = (self.nbtraces - abs(data_id - 1 - i)) / self.nbtraces if alpha < 0: alpha = 0 # Plot # OPTIMIZE: keep previous style to avoid overwriting it everytime - if i < (data_id-1): + if i < (data_id - 1): if len(x) > 300: curve = self.ax.plot(x, y, pen=color) curve.setAlpha(alpha, False) else: - curve = self.ax.plot(x, y, symbol='x', symbolPen=color, symbolSize=10, pen=color, symbolBrush=color) + curve = self.ax.plot( + x, y, symbol='x', symbolPen=color, + symbolSize=10, pen=color, symbolBrush=color) curve.setAlpha(alpha, False) - elif i > (data_id-1): + elif i > (data_id - 1): if len(x) > 300: - curve = self.ax.plot(x, y, pen=pg.mkPen(color=color, style=pg.QtCore.Qt.DashLine)) + curve = self.ax.plot( + x, y, pen=pg.mkPen(color=color, + style=pg.QtCore.Qt.DashLine)) curve.setAlpha(alpha, False) else: - curve = self.ax.plot(x, y, symbol='x', symbolPen=color, symbolSize=10, pen=pg.mkPen(color=color, style=pg.QtCore.Qt.DashLine), symbolBrush=color) + curve = self.ax.plot( + x, y, symbol='x', symbolPen=color, symbolSize=10, + pen=pg.mkPen(color=color, style=pg.QtCore.Qt.DashLine), + symbolBrush=color) curve.setAlpha(alpha, False) self.curves.append(curve) # Data - i = (data_id-1) + i = (data_id - 1) subdata = data[i] if subdata is not None: subdata = subdata.astype(float) - x = subdata.loc[:,variable_x] - y = subdata.loc[:,variable_y] + x = subdata.loc[:, variable_x] + y = subdata.loc[:, variable_y] # Apprearance: color = '#1f77b4' @@ -164,7 +172,9 @@ def reloadData(self): curve = self.ax.plot(x, y, pen=color) curve.setAlpha(alpha, False) else: - curve = self.ax.plot(x, y, symbol='x', symbolPen=color, symbolSize=10, pen=color, symbolBrush=color) + curve = self.ax.plot( + x, y, symbol='x', symbolPen=color, symbolSize=10, + pen=color, symbolBrush=color) curve.setAlpha(alpha, False) self.curves.append(curve) @@ -174,7 +184,7 @@ def reloadData(self): # SAVE FIGURE ########################################################################### - def save(self,filename): + def save(self, filename: str): """ This function save the figure with the provided filename """ raw_name, extension = os.path.splitext(filename) diff --git a/autolab/core/gui/plotting/interface.ui b/autolab/core/gui/plotting/interface.ui index 28d7f16e..a17b55dc 100644 --- a/autolab/core/gui/plotting/interface.ui +++ b/autolab/core/gui/plotting/interface.ui @@ -10,20 +10,32 @@ 734 - - - 9 - - - - + + + 0 + + + 0 + + + 0 + + + 0 + + Qt::Vertical - - + + + Qt::Horizontal + + + + @@ -33,9 +45,15 @@ 20 + + 0 + 0 + + 0 + 0 @@ -53,11 +71,6 @@ - - - 9 - - Open data @@ -152,27 +165,28 @@ + + + + Qt::Vertical + + + + 20 + 40 + + + + - + QFrame::StyledPanel - - - - - Auto get data - - - + - - - 9 - - Display data from the given variable address @@ -181,42 +195,10 @@ - - - - Variable address i.g. ct400.scan.data - - - - - - - - - - Qt::Horizontal - - - - 0 - 20 - - - - - - - - - 75 - true - - + + - Device : - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + Address: @@ -228,12 +210,6 @@ 0 - - - 50 - 0 - - 75 @@ -251,25 +227,10 @@ - - - - - 9 - - - - Delay [s] : - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - + + - Variable address + Auto get data @@ -286,6 +247,32 @@ + + + + + 75 + true + + + + Variable: + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + Delay [s] : + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + @@ -294,54 +281,35 @@ - - - - - QComboBox::AdjustToContents - - - - - - - - 75 - true - - - - Y axis - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - QComboBox::AdjustToContents - - - - - - - - 75 - true - - - - X axis - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - + + + + + + + + 75 + true + + + + X axis + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QComboBox::AdjustToContents + + + + - + Qt::Horizontal @@ -354,70 +322,81 @@ + + + + + + + 75 + true + + + + Y axis + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + QComboBox::AdjustToContents + + + + + - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 0 - 0 - - - - Nb traces : - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter - - - - - - - - 0 - 0 - - - - Number of visible traces - - - 1 - - - Qt::AlignCenter - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - + + + + + + 0 + 0 + + + + Nb traces + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + + 0 + 0 + + + + + 100 + 16777215 + + + + Number of visible traces + + + 1 + + + Qt::AlignCenter + + + + @@ -463,12 +442,9 @@ clear_pushButton data_comboBox overwriteDataButton - device_lineEdit plotDataButton delay_lineEdit auto_plotDataButton - variable_x_comboBox - variable_y_comboBox diff --git a/autolab/core/gui/plotting/main.py b/autolab/core/gui/plotting/main.py index 2259fa66..ff1a532b 100644 --- a/autolab/core/gui/plotting/main.py +++ b/autolab/core/gui/plotting/main.py @@ -9,8 +9,9 @@ import queue import time import uuid -from typing import Any, Type +from typing import Type, Union, Any +import pandas as pd from qtpy import QtCore, QtWidgets, uic, QtGui from .figure import FigureManager @@ -18,34 +19,37 @@ from .thread import ThreadManager from .treewidgets import TreeWidgetItemModule from ..icons import icons -from ... import devices -from ... import config -from ..GUI_utilities import get_font_size, setLineEditBackground +from ..GUI_utilities import get_font_size, setLineEditBackground, MyLineEdit +from ..GUI_instances import clearPlotter, closePlotter +from ...devices import list_devices +from ...elements import Variable as Variable_og +from ...variables import Variable +from ...config import load_config class MyQTreeWidget(QtWidgets.QTreeWidget): - reorderSignal = QtCore.Signal(object) - - def __init__(self, parent, plotter): - self.plotter = plotter + def __init__(self, gui, parent=None): + self.gui = gui super().__init__(parent) self.setAcceptDrops(True) def dropEvent(self, event): """ This function is used to add a plugin to the plotter """ - variable = event.source().last_drag - if isinstance(variable, str): - self.plotter.addPlugin(variable) + plugin_name = event.source().last_drag + if isinstance(plugin_name, str): + self.gui.addPlugin(plugin_name, plugin_name) self.setGraphicsEffect(None) def dragEnterEvent(self, event): if (event.source() is self) or ( - hasattr(event.source(), "last_drag") and isinstance(event.source().last_drag, str)): + hasattr(event.source(), "last_drag") + and isinstance(event.source().last_drag, str)): event.accept() - shadow = QtWidgets.QGraphicsDropShadowEffect(blurRadius=25, xOffset=3, yOffset=3) + shadow = QtWidgets.QGraphicsDropShadowEffect( + blurRadius=25, xOffset=3, yOffset=3) self.setGraphicsEffect(shadow) else: event.ignore() @@ -53,24 +57,53 @@ def dragEnterEvent(self, event): def dragLeaveEvent(self, event): self.setGraphicsEffect(None) + def keyPressEvent(self, event): + if (event.key() == QtCore.Qt.Key_C + and event.modifiers() == QtCore.Qt.ControlModifier): + self.copy_item(event) + else: + super().keyPressEvent(event) + + def copy_item(self, event): + if len(self.selectedItems()) == 0: + super().keyPressEvent(event) + return None + item = self.selectedItems()[0] # assume can select only one item + if hasattr(item, 'variable'): + text = item.variable.address() + elif hasattr(item, 'action'): + text = item.action.address() + elif hasattr(item, 'module'): + if hasattr(item.module, 'address'): + text = item.module.address() + else: + text = item.name + else: + print(f'Should not be possible: {item}') + super().keyPressEvent(event) + return None + + # Copy the text to the system clipboard + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(text) class Plotter(QtWidgets.QMainWindow): - def __init__(self, mainGui): + def __init__(self, has_parent: bool = False): self.active = False - self.mainGui = mainGui + self.has_parent = has_parent # Only for closeEvent self.all_plugin_list = [] self.active_plugin_dict = {} - self._font_size = get_font_size() + 1 + self._font_size = get_font_size() # Configuration of the window super().__init__() ui_path = os.path.join(os.path.dirname(__file__), 'interface.ui') uic.loadUi(ui_path, self) self.setWindowTitle("AUTOLAB - Plotter") - self.setWindowIcon(QtGui.QIcon(icons['plotter'])) + self.setWindowIcon(icons['plotter']) # Loading of the different centers self.figureManager = FigureManager(self) @@ -104,36 +137,40 @@ def __init__(self, mainGui): setLineEditBackground(self.nbTraces_lineEdit, 'synced', self._font_size) self.variable_x_comboBox.currentIndexChanged.connect( - self.variableChanged) + self.axisChanged) self.variable_y_comboBox.currentIndexChanged.connect( - self.variableChanged) - - if self.mainGui is not None: - self.device_lineEdit.setText(f'{self.dataManager.deviceValue}') - self.device_lineEdit.returnPressed.connect(self.deviceChanged) - self.device_lineEdit.textEdited.connect(lambda: setLineEditBackground( - self.device_lineEdit, 'edited', self._font_size)) - setLineEditBackground(self.device_lineEdit, 'synced', self._font_size) - - # Plot button - self.plotDataButton.clicked.connect(self.refreshPlotData) - - # Timer - self.timer_time = 0.5 # This plotter is not meant for fast plotting like the monitor, be aware it may crash with too high refreshing rate - self.timer = QtCore.QTimer(self) - self.timer.setInterval(int(self.timer_time*1000)) # ms - self.timer.timeout.connect(self.autoRefreshPlotData) - - self.auto_plotDataButton.clicked.connect(self.autoRefreshChanged) - - # Delay - self.delay_lineEdit.setText(str(self.timer_time)) - self.delay_lineEdit.returnPressed.connect(self.delayChanged) - self.delay_lineEdit.textEdited.connect(lambda: setLineEditBackground( - self.delay_lineEdit, 'edited', self._font_size)) - setLineEditBackground(self.delay_lineEdit, 'synced', self._font_size) - else: - self.frame_device.hide() + self.axisChanged) + + # Variable frame + self.variable_lineEdit = MyLineEdit() + self.variable_lineEdit.skip_has_eval = True + self.variable_lineEdit.use_np_pd = False + self.variable_lineEdit.setToolTip('Variable address e.g. ct400.scan.data') + self.layout_variable.addWidget(self.variable_lineEdit, 1, 2) + self.variable_lineEdit.setText(f'{self.dataManager.variable_address}') + self.variable_lineEdit.returnPressed.connect(self.variableChanged) + self.variable_lineEdit.textEdited.connect(lambda: setLineEditBackground( + self.variable_lineEdit, 'edited', self._font_size)) + setLineEditBackground(self.variable_lineEdit, 'synced', self._font_size) + + # Plot button + self.plotDataButton.clicked.connect(lambda state: self.refreshPlotData()) + + # Timer + self.timer_time = 0.5 # This plotter is not meant for fast plotting like the monitor, be aware it may crash with too high refreshing rate + self.timer = QtCore.QTimer(self) + self.timer.setInterval(int(self.timer_time*1000)) # ms + self.timer.timeout.connect(self.autoRefreshPlotData) + + self.auto_plotDataButton.clicked.connect(self.autoRefreshChanged) + + # Delay + self.delay_lineEdit.setText(str(self.timer_time)) + self.delay_lineEdit.returnPressed.connect(self.delayChanged) + self.delay_lineEdit.textEdited.connect(lambda: setLineEditBackground( + self.delay_lineEdit, 'edited', self._font_size)) + setLineEditBackground(self.delay_lineEdit, 'synced', self._font_size) + # / Variable frame self.overwriteDataButton.clicked.connect(self.overwriteDataChanged) @@ -150,10 +187,26 @@ def __init__(self, mainGui): self.timerQueue = QtCore.QTimer(self) self.timerQueue.setInterval(int(50)) # ms self.timerQueue.timeout.connect(self._queueDriverHandler) - self.timerQueue.start() # OPTIMIZE: should be started only when needed but difficult to know it before openning device which occurs in a diff thread! (can't start timer on diff thread) + self._stop_timerQueue = False self.processPlugin() + self.splitter.setSizes([600, 100]) # height + self.splitter_2.setSizes([310, 310, 310]) # width + + for splitter in (self.splitter, self.splitter_2, self.splitter_3): + for i in range(splitter.count()): + handle = splitter.handle(i) + handle.setStyleSheet("background-color: #DDDDDD;") + handle.installEventFilter(self) + + def eventFilter(self, obj, event): + if event.type() == QtCore.QEvent.Enter: + obj.setStyleSheet("background-color: #AAAAAA;") # Hover color + elif event.type() == QtCore.QEvent.Leave: + obj.setStyleSheet("background-color: #DDDDDD;") # Normal color + return super().eventFilter(obj, event) + def createWidget(self, widget: Type, *args, **kwargs): """ Function used by a driver to add a widget. Mainly used to open a figure outside the GUI from a driver. """ @@ -196,19 +249,25 @@ def _queueDriverHandler(self): if widget is not None: widget_pos = list(d.values()).index(widget) if widget_pos is not None: - widget_name = list(d.keys())[widget_pos] + widget_name = list(d)[widget_pos] widget = d.get(widget_name) if widget is not None: widget = d.pop(widget_name) try: self.figureManager.ax.removeItem(widget) - except Exception as e: self.setStatus(str(e), 10000, False) + except Exception as e: + self.setStatus(str(e), 10000, False) + + if self._stop_timerQueue: + self.timerQueue.stop() + self._stop_timerQueue = False def timerAction(self): - """ This function checks if a module has been loaded and put to the queue. If so, associate item and module """ + """ This function checks if a module has been loaded and put to the queue. + If so, associate item and module """ threadItemDictTemp = self.threadItemDict.copy() threadDeviceDictTemp = self.threadDeviceDict.copy() - for item_id in threadDeviceDictTemp.keys(): + for item_id in threadDeviceDictTemp: item = threadItemDictTemp[item_id] module = threadDeviceDictTemp[item_id] @@ -225,10 +284,13 @@ def timerAction(self): def itemClicked(self, item): """ Function called when a normal click has been detected in the tree. Check the association if it is a main item """ - if item.parent() is None and item.loaded is False and id(item) not in self.threadItemDict.keys(): + if (item.parent() is None + and item.loaded is False + and id(item) not in self.threadItemDict): self.threadItemDict[id(item)] = item # needed before start of timer to avoid bad timing and to stop thread before loading is done self.threadManager.start(item, 'load') # load device and add it to queue for timer to associate it later (doesn't block gui while device is openning) self.timerPlugin.start() + self.timerQueue.start() def rightClick(self, position): """ Function called when a right click has been detected in the tree """ @@ -236,27 +298,42 @@ def rightClick(self, position): if hasattr(item,'menu'): item.menu(position) - def processPlugin(self): + def hide_plugin_frame(self): + sizes = self.splitter_3.sizes() + self.splitter_3.setSizes([0, sizes[0]]) + def processPlugin(self): # Create frame - self.frame = QtWidgets.QFrame() - self.splitter_2.insertWidget(1, self.frame) - self.frame.setFrameShape(QtWidgets.QFrame.StyledPanel) - layout = QtWidgets.QVBoxLayout(self.frame) + frame = QtWidgets.QFrame() + self.splitter_3.insertWidget(0, frame) + frame.setFrameShape(QtWidgets.QFrame.StyledPanel) + layout = QtWidgets.QVBoxLayout(frame) + + frame2 = QtWidgets.QFrame() + layout2 = QtWidgets.QHBoxLayout(frame2) + layout2.setContentsMargins(0,0,0,0) - label = QtWidgets.QLabel('Plugin:', self.frame) + label = QtWidgets.QLabel('Plugin:', frame) label.setToolTip("Drag and drop a device from the control panel to add a plugin to the plugin tree") - layout.addWidget(label) + layout2.addWidget(label) font = QtGui.QFont() font.setBold(True) label.setFont(font) + hide_plugin_button = QtWidgets.QPushButton('-') + hide_plugin_button.setMaximumSize(40, 23) + hide_plugin_button.clicked.connect(self.hide_plugin_frame) + layout2.addWidget(hide_plugin_button) + + layout.addWidget(frame2) + # Tree widget configuration - self.tree = MyQTreeWidget(self.frame, self) + self.tree = MyQTreeWidget(self, frame) layout.addWidget(self.tree) - self.tree.setHeaderLabels(['Plugin','Type','Actions','Values','']) + self.tree.setHeaderLabels(['Plugin', 'Type', 'Actions', 'Values', '']) self.tree.header().setDefaultAlignment(QtCore.Qt.AlignCenter) - self.tree.header().resizeSection(0, 170) + self.tree.header().setMinimumSectionSize(15) + self.tree.header().resizeSection(0, 160) self.tree.header().hideSection(1) self.tree.header().resizeSection(2, 50) self.tree.header().resizeSection(3, 70) @@ -268,31 +345,26 @@ def processPlugin(self): self.tree.itemClicked.connect(self.itemClicked) self.tree.customContextMenuRequested.connect(self.rightClick) - plotter_config = config.load_config("plotter") + plotter_config = load_config("plotter_config") - if 'plugin' in plotter_config.sections() and len(plotter_config['plugin']) != 0: - self.splitter_2.setSizes([200,300,80,80]) - for plugin_nickname in plotter_config['plugin'].keys() : - plugin_name = plotter_config['plugin'][plugin_nickname] - self.addPlugin(plugin_name, plugin_nickname) - else: - self.splitter.setSizes([400,40]) - self.splitter_2.setSizes([200,80,80,80]) + self.splitter_3.setSizes([280, 800]) - def addPlugin(self, plugin_name, plugin_nickname=None): + if ('plugin' in plotter_config.sections() + and len(plotter_config['plugin']) != 0): + for plugin_nickname, plugin_name in plotter_config['plugin'].items(): + self.addPlugin(plugin_name, plugin_nickname) - if plugin_nickname is None: - plugin_nickname = plugin_name + def addPlugin(self, plugin_name: str, plugin_nickname: str): - if plugin_name in devices.list_devices(): + if plugin_name in list_devices(): plugin_nickname = self.getUniqueName(plugin_nickname) self.all_plugin_list.append(plugin_nickname) item = TreeWidgetItemModule(self.tree,plugin_name,plugin_nickname,self) item.setBackground(0, QtGui.QColor('#9EB7F5')) # blue - - self.itemClicked(item) else: - self.setStatus(f"Error: plugin {plugin_name} not found in devices_config.ini",10000, False) + self.setStatus( + f"Error: plugin {plugin_name} not found in devices_config.ini", + 10000, False) def associate(self, item, module): @@ -306,16 +378,20 @@ def associate(self, item, module): try: variable() except: - self.setStatus(f"Can't read variable {variable.address()} on instantiation", 10000, False) + self.setStatus( + f"Can't read variable {variable.address()} on instantiation", + 10000, False) try: data = self.dataManager.getLastSelectedDataset().data - data = data[[self.figureManager.getLabel("x"),self.figureManager.getLabel("y")]].copy() + data = data[[self.variable_x_comboBox.currentText(), + self.variable_y_comboBox.currentText()]].copy() module.instance.refresh(data) except Exception: pass def getUniqueName(self, basename: str): - """ This function adds a number next to basename in case this basename is already taken """ + """ This function adds a number next to basename in case this basename + is already taken """ names = self.all_plugin_list name = basename @@ -340,7 +416,9 @@ def dropEvent(self, event): def dragEnterEvent(self, event): """ Check that drop filenames """ # only accept if there is at least one filename in the dropped filenames -> refuse folders - if event.mimeData().hasUrls() and any([os.path.isfile(e.toLocalFile()) for e in event.mimeData().urls()]): + if (event.mimeData().hasUrls() + and any([os.path.isfile(e.toLocalFile()) + for e in event.mimeData().urls()])): event.accept() qwidget_children = self.findChildren(QtWidgets.QWidget) @@ -360,9 +438,15 @@ def dragLeaveEvent(self, event): def plugin_refresh(self): if self.active_plugin_dict: self.clearStatus() - if hasattr(self.dataManager.getLastSelectedDataset(),"data"): - data = self.dataManager.getLastSelectedDataset().data - data = data[[self.figureManager.getLabel("x"),self.figureManager.getLabel("y")]].copy() + dataset = self.dataManager.getLastSelectedDataset() + if hasattr(dataset, "data"): + data = dataset.data + variable_x = self.variable_x_comboBox.currentText() + variable_y = self.variable_y_comboBox.currentText() + if (variable_x in data.columns and variable_y in data.columns): + data = data[[variable_x, variable_y]].copy() + else: + data = None else: data = None @@ -370,8 +454,9 @@ def plugin_refresh(self): if hasattr(module.instance, "refresh"): try: module.instance.refresh(data) - except Exception as error: - self.setStatus(f"Error in plugin {module.name}: '{error}'",10000, False) + except Exception as e: + self.setStatus(f"Error in plugin {module.name}: {e}", + 10000, False) def overwriteDataChanged(self): """ Set overwrite name for data import """ @@ -389,28 +474,27 @@ def autoRefreshPlotData(self): # OPTIMIZE: timer should not call a heavy function, idealy just take data to plot self.refreshPlotData() - def refreshPlotData(self): + def refreshPlotData(self, variable: Union[Variable, Variable_og, pd.DataFrame, Any] = None): """ This function get the last dataset data and display it onto the Plotter GUI """ - deviceValue = self.dataManager.getDeviceValue() - try: - deviceVariable = self.dataManager.getDeviceName(deviceValue) - dataset = self.dataManager.importDeviceData(deviceVariable) - data_name = dataset.name + if variable is None: + variable_address = self.dataManager.get_variable_address() + variable = self.dataManager.getVariable(variable_address) + dataset = self.dataManager.importDeviceData(variable) self.figureManager.start(dataset) - self.setStatus(f"Display the data: '{data_name}'", 5000) - except Exception as error: - self.setStatus(f"Can't refresh data: {error}", 10000, False) + self.setStatus(f"Display the data: '{dataset.name}'", 5000) + except Exception as e: + self.setStatus(f"Can't refresh data: {e}", 10000, False) - def deviceChanged(self): + def variableChanged(self): """ This function start the update of the target value in the data manager when a changed has been detected """ # Send the new value try: - value = str(self.device_lineEdit.text()) - self.dataManager.setDeviceValue(value) - except Exception as er: - self.setStatus(f"ERROR Can't change device variable: {er}", 10000, False) + value = str(self.variable_lineEdit.text()) + self.dataManager.set_variable_address(value) + except Exception as e: + self.setStatus(f"Can't change variable name: {e}", 10000, False) else: # Rewrite the GUI with the current value self.updateDeviceValueGui() @@ -418,16 +502,17 @@ def deviceChanged(self): def updateDeviceValueGui(self): """ This function ask the current value of the target value in the data manager, and then update the GUI """ - value = self.dataManager.getDeviceValue() - self.device_lineEdit.setText(f'{value}') - setLineEditBackground(self.device_lineEdit, 'synced', self._font_size) + value = self.dataManager.get_variable_address() + self.variable_lineEdit.setText(f'{value}') + setLineEditBackground(self.variable_lineEdit, 'synced', self._font_size) - def variableChanged(self,index): + def axisChanged(self, index): """ This function is called when the displayed result has been changed in the combo box. It proceeds to the change. """ self.figureManager.clearData() - if self.variable_x_comboBox.currentIndex() != -1 and self.variable_y_comboBox.currentIndex() != -1 : + if (self.variable_x_comboBox.currentIndex() != -1 + and self.variable_y_comboBox.currentIndex() != -1): self.figureManager.reloadData() def nbTracesChanged(self): @@ -450,18 +535,20 @@ def nbTracesChanged(self): if check is True and self.variable_y_comboBox.currentIndex() != -1: self.figureManager.reloadData() - def closeEvent(self,event): + def closeEvent(self, event): """ This function does some steps before the window is closed (not killed) """ if hasattr(self, 'timer'): self.timer.stop() self.timerPlugin.stop() self.timerQueue.stop() - if hasattr(self.mainGui, 'clearPlotter'): - self.mainGui.clearPlotter() + clearPlotter() + + if not self.has_parent: + closePlotter() super().closeEvent(event) - if self.mainGui is None: + if not self.has_parent: QtWidgets.QApplication.quit() # close the plotter app def close(self): @@ -494,7 +581,7 @@ def updateDelayGui(self): self.timer.setInterval(int(value*1000)) # ms setLineEditBackground(self.delay_lineEdit, 'synced', self._font_size) - def setStatus(self,message, timeout=0, stdout=True): + def setStatus(self, message: str, timeout: int = 0, stdout: bool = True): """ Modify the message displayed in the status bar and add error message to logger """ self.statusBar.showMessage(message, timeout) if not stdout: print(message, file=sys.stderr) diff --git a/autolab/core/gui/plotting/thread.py b/autolab/core/gui/plotting/thread.py index 93a0fed2..a4f6eaea 100644 --- a/autolab/core/gui/plotting/thread.py +++ b/autolab/core/gui/plotting/thread.py @@ -5,32 +5,28 @@ @author: qchat """ +import sys import inspect +from typing import Any -from qtpy import QtCore +from qtpy import QtCore, QtWidgets -from ... import devices -from ... import drivers from ..GUI_utilities import qt_object_exists +from ...devices import get_final_device_config, Device +from ...drivers import load_driver_lib, get_driver -class ThreadManager : - +class ThreadManager: """ This class is dedicated to manage the different threads, from their creation, to their deletion after they have been used """ - - def __init__(self,gui): + def __init__(self, gui: QtWidgets.QMainWindow): self.gui = gui self.threads = {} - - - def start(self,item,intType,value=None): - + def start(self, item: QtWidgets.QTreeWidgetItem, intType: str, value = None): """ This function is called when a new thread is requested, for a particular intType interaction type """ - # GUI disabling item.setDisabled(True) @@ -42,114 +38,107 @@ def start(self,item,intType,value=None): item.valueWidget.setEnabled(False) # disabling valueWidget deselect item and select next one, need to disable all items and reenable item - list_item = self.gui.tree.selectedItems() - for item_selected in list_item: + for item_selected in self.gui.tree.selectedItems(): item_selected.setSelected(False) item.setSelected(True) # Status writing - if intType == 'read' : status = f'Reading {item.variable.address()}...' - elif intType == 'write' : status = f'Writing {item.variable.address()}...' - elif intType == 'execute' : status = f'Executing {item.action.address()}...' - elif intType == 'load' : status = f'Loading plugin {item.name}...' + if intType == 'read': status = f'Reading {item.variable.address()}...' + elif intType == 'write': status = f'Writing {item.variable.address()}...' + elif intType == 'execute': status = f'Executing {item.action.address()}...' + elif intType == 'load': status = f'Loading plugin {item.name}...' self.gui.setStatus(status) # Thread configuration thread = InteractionThread(item,intType,value) tid = id(thread) self.threads[tid] = thread - thread.endSignal.connect(lambda error, x=tid : self.threadFinished(x,error)) - thread.finished.connect(lambda x=tid : self.delete(x)) + thread.endSignal.connect(lambda error, x=tid: self.threadFinished(x, error)) + thread.finished.connect(lambda x=tid: self.delete(x)) # Starting thread thread.start() - - def threadFinished(self,tid,error): - + def threadFinished(self, tid: int, error: Exception): """ This function is called when a thread has finished its job, with an error or not It updates the status bar of the GUI in consequence and enabled back the correspondig item """ - - if error is None : self.gui.clearStatus() - else : self.gui.setStatus(str(error), 10000, False) + if error: + if qt_object_exists(self.gui.statusBar): + self.gui.setStatus(str(error), 10000, False) + else: + print(str(error), file=sys.stderr) + else: + if qt_object_exists(self.gui.statusBar): + self.gui.clearStatus() item = self.threads[tid].item item.setDisabled(False) - if hasattr(item, "execButton"): - if qt_object_exists(item.execButton): - item.execButton.setEnabled(True) - if hasattr(item, "readButton"): - if qt_object_exists(item.readButton): - item.readButton.setEnabled(True) - if hasattr(item, "valueWidget"): - if qt_object_exists(item.valueWidget): - item.valueWidget.setEnabled(True) - - - def delete(self,tid): - + if hasattr(item, "execButton") and qt_object_exists(item.execButton): + item.execButton.setEnabled(True) + if hasattr(item, "readButton") and qt_object_exists(item.readButton): + item.readButton.setEnabled(True) + if hasattr(item, "valueWidget") and qt_object_exists(item.valueWidget): + item.valueWidget.setEnabled(True) + # Put back focus if item still selected (item.isSelected() doesn't work) + if item in self.gui.tree.selectedItems(): + item.valueWidget.setFocus() + + def delete(self, tid: int): """ This function is called when a thread is about to be deleted. This removes it from the dictionnary self.threads, for a complete deletion """ - self.threads.pop(tid) - - - - class InteractionThread(QtCore.QThread): - """ This class is dedicated to operation interaction with the devices, in a new thread """ - endSignal = QtCore.Signal(object) - def __init__(self, item, intType, value): + def __init__(self, item: QtWidgets.QTreeWidgetItem, intType: str, value: Any): super().__init__() self.item = item self.intType = intType self.value = value def run(self): - - """ Depending on the interaction type requested, this function reads or writes a variable, - or execute an action. """ - + """ Depending on the interaction type requested, this function reads or + writes a variable, or execute an action. """ error = None - - try : - if self.intType == 'read' : self.item.variable() - elif self.intType == 'write' : + try: + if self.intType == 'read': self.item.variable() + elif self.intType == 'write': self.item.variable(self.value) - if self.item.variable.readable : self.item.variable() - elif self.intType == 'execute' : - if self.value is not None : + if self.item.variable.readable: self.item.variable() + elif self.intType == 'execute': + if self.value is not None: self.item.action(self.value) - else : + else: self.item.action() - elif self.intType == 'load' : + elif self.intType == 'load': # Note that threadItemDict needs to be updated outside of thread to avoid timing error plugin_name = self.item.name - device_config = devices.get_final_device_config(plugin_name) + device_config = get_final_device_config(plugin_name) - driver_kwargs = { k:v for k,v in device_config.items() if k not in ['driver','connection']} - driver_lib = drivers.load_driver_lib(device_config['driver']) + driver_kwargs = {k: v for k, v in device_config.items() + if k not in ['driver', 'connection']} + driver_lib = load_driver_lib(device_config['driver']) - if hasattr(driver_lib, 'Driver') and 'gui' in [param.name for param in inspect.signature(driver_lib.Driver.__init__).parameters.values()]: + if hasattr(driver_lib, 'Driver') and 'gui' in [ + param.name for param in inspect.signature( + driver_lib.Driver.__init__).parameters.values()]: driver_kwargs['gui'] = self.item.gui - instance = drivers.get_driver(device_config['driver'], - device_config['connection'], - **driver_kwargs) - module = devices.Device(plugin_name, instance, device_config) + instance = get_driver( + device_config['driver'], device_config['connection'], + **driver_kwargs) + module = Device(plugin_name, instance, device_config) self.item.gui.threadDeviceDict[id(self.item)] = module except Exception as e: error = e - if self.intType == 'load' : - error = f'An error occured when loading device {self.item.name} : {str(e)}' + if self.intType == 'load': + error = f'An error occured when loading device {self.item.name}: {str(e)}' if id(self.item) in self.item.gui.threadItemDict.keys(): self.item.gui.threadItemDict.pop(id(self.item)) self.endSignal.emit(error) diff --git a/autolab/core/gui/plotting/treewidgets.py b/autolab/core/gui/plotting/treewidgets.py index 6e4dc453..01f07235 100644 --- a/autolab/core/gui/plotting/treewidgets.py +++ b/autolab/core/gui/plotting/treewidgets.py @@ -5,34 +5,40 @@ @author: qchat """ - +from typing import Any, Union import os import pandas as pd import numpy as np +from qtpy import QtCore, QtWidgets, QtGui -from qtpy import QtCore, QtWidgets - -from .. import variables -from ..GUI_utilities import qt_object_exists -from ... import paths, config -from ...utilities import SUPPORTED_EXTENSION +from ..icons import icons +from ..GUI_utilities import (MyLineEdit, MyInputDialog, MyQCheckBox, MyQComboBox, + qt_object_exists) +from ...paths import PATHS +from ...config import get_control_center_config +from ...variables import eval_variable, has_eval +from ...utilities import (SUPPORTED_EXTENSION, str_to_array, array_to_str, + dataframe_to_str, str_to_dataframe, create_array, + str_to_tuple) +# OPTIMIZE: Could merge treewidgets from control panel and plotter (or common version & subclass) class TreeWidgetItemModule(QtWidgets.QTreeWidgetItem): """ This class represents a module in an item of the tree """ def __init__(self, itemParent, name, nickname, gui): - super().__init__(itemParent, [nickname, 'Module']) - self.setTextAlignment(1, QtCore.Qt.AlignHCenter) self.name = name - self.nickname = nickname self.module = None self.loaded = False self.gui = gui - self.is_not_submodule = isinstance(gui.tree, type(itemParent)) + self.nickname = nickname + + super().__init__(itemParent, [nickname, 'Module']) + + self.setTextAlignment(1, QtCore.Qt.AlignHCenter) def load(self, module): """ This function loads the entire module (submodules, variables, actions) """ @@ -41,15 +47,15 @@ def load(self, module): # Submodules subModuleNames = self.module.list_modules() for subModuleName in subModuleNames: - subModule = getattr(self.module,subModuleName) - item = TreeWidgetItemModule(self, subModuleName,subModuleName,self.gui) + subModule = getattr(self.module, subModuleName) + item = TreeWidgetItemModule(self, subModuleName, subModuleName, self.gui) item.load(subModule) # Variables varNames = self.module.list_variables() for varName in varNames: variable = getattr(self.module,varName) - TreeWidgetItemVariable(self, variable,self.gui) + TreeWidgetItemVariable(self, variable, self.gui) # Actions actNames = self.module.list_actions() @@ -81,6 +87,9 @@ def menu(self, position): self.removeChild(self.child(0)) self.loaded = False + if not self.gui.active_plugin_dict: + self.gui._stop_timerQueue = True + class TreeWidgetItemAction(QtWidgets.QTreeWidgetItem): """ This class represents an action in an item of the tree """ @@ -97,8 +106,12 @@ def __init__(self, itemParent, action, gui): self.gui = gui self.action = action + # Import Autolab config + control_center_config = get_control_center_config() + self.precision = int(float(control_center_config['precision'])) + if self.action.has_parameter: - if self.action.type in [int, float, str, pd.DataFrame, np.ndarray]: + if self.action.type in [int, float, bool, str, bytes, tuple, np.ndarray, pd.DataFrame]: self.executable = True self.has_value = True else: @@ -117,77 +130,229 @@ def __init__(self, itemParent, action, gui): # Main - Column 3 : QlineEdit if the action has a parameter if self.has_value: - self.valueWidget = QtWidgets.QLineEdit() - self.valueWidget.setAlignment(QtCore.Qt.AlignCenter) - self.gui.tree.setItemWidget(self, 3, self.valueWidget) - self.valueWidget.returnPressed.connect(self.execute) + if self.action.type in [int, float, str, bytes, np.ndarray, pd.DataFrame]: + self.valueWidget = MyLineEdit() + self.valueWidget.setAlignment(QtCore.Qt.AlignCenter) + self.gui.tree.setItemWidget(self, 3, self.valueWidget) + self.valueWidget.returnPressed.connect(self.execute) + self.valueWidget.textEdited.connect(self.valueEdited) + + ## QCheckbox for boolean variables + elif self.action.type in [bool]: + self.valueWidget = MyQCheckBox(self) + hbox = QtWidgets.QHBoxLayout() + hbox.addWidget(self.valueWidget) + hbox.setAlignment(QtCore.Qt.AlignCenter) + hbox.setSpacing(0) + hbox.setContentsMargins(0,0,0,0) + widget = QtWidgets.QWidget() + widget.setLayout(hbox) + + self.gui.tree.setItemWidget(self, 3, widget) + + ## Combobox for tuples: Tuple[List[str], int] + elif self.action.type in [tuple]: + self.valueWidget = MyQComboBox() + self.valueWidget.wheel = False # prevent changing value by mistake + self.valueWidget.key = False + self.valueWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.valueWidget.customContextMenuRequested.connect(self.openInputDialog) + + self.gui.tree.setItemWidget(self, 3, self.valueWidget) + + # Main - column 4 : indicator (status of the actual value : known or not known) + self.indicator = QtWidgets.QLabel() + self.gui.tree.setItemWidget(self, 4, self.indicator) # Tooltip if self.action._help is None: tooltip = 'No help available for this action' else: tooltip = self.action._help - self.setToolTip(0,tooltip) + self.setToolTip(0, tooltip) - def readGui(self): - """ This function returns the value in good format of the value in the GUI """ - value = self.valueWidget.text() - - if value == '': - if self.action.unit in ('open-file', 'save-file', 'filename'): - if self.action.unit == "filename": # TODO: LEGACY (to remove later) - self.gui.setStatus("Using 'filename' as unit is depreciated in favor of 'open-file' and 'save-file'" \ - f"\nUpdate driver {self.action.name} to remove this warning", - 10000, False) - self.action.unit = "open-file" - - if self.action.unit == "open-file": - filename, _ = QtWidgets.QFileDialog.getOpenFileName( - self.gui, caption=f"Open file - {self.action.name}", - directory=paths.USER_LAST_CUSTOM_FOLDER, - filter=SUPPORTED_EXTENSION) - elif self.action.unit == "save-file": - filename, _ = QtWidgets.QFileDialog.getSaveFileName( - self.gui, caption=f"Save file - {self.action.name}", - directory=paths.USER_LAST_CUSTOM_FOLDER, - filter=SUPPORTED_EXTENSION) - - if filename != '': - path = os.path.dirname(filename) - paths.USER_LAST_CUSTOM_FOLDER = path - return filename - else: + self.writeSignal = WriteSignal() + self.writeSignal.writed.connect(self.valueWrited) + self.action._write_signal = self.writeSignal + + def openInputDialog(self, position: QtCore.QPoint): + """ Only used for tuple """ + menu = QtWidgets.QMenu() + modifyTuple = menu.addAction("Modify tuple") + modifyTuple.setIcon(icons['tuple']) + + choice = menu.exec_(self.valueWidget.mapToGlobal(position)) + + if choice == modifyTuple: + main_dialog = MyInputDialog(self.gui, self.action.address()) + main_dialog.setWindowModality(QtCore.Qt.ApplicationModal) + if self.action.type in [tuple]: + main_dialog.setTextValue(str(self.action.value)) + main_dialog.show() + + if main_dialog.exec_() == QtWidgets.QInputDialog.Accepted: + response = main_dialog.textValue() + else: + response = '' + + if qt_object_exists(main_dialog): main_dialog.deleteLater() + + if response != '': + try: + if has_eval(response): + response = eval_variable(response) + if self.action.type in [tuple]: + response = str_to_tuple(str(response)) + except Exception as e: self.gui.setStatus( - f"Action {self.action.name} cancel filename selection", - 10000) - elif self.action.unit == "user-input": - response, _ = QtWidgets.QInputDialog.getText( - self.gui, self.action.name, f"Set {self.action.name} value", - QtWidgets.QLineEdit.Normal) - - if response != '': - return response + f"Variable {self.action.address()}: {e}", 10000, False) + return None + + self.action.value = response + self.valueWrited(response) + self.valueEdited() + + def writeGui(self, value): + """ This function displays a new value in the GUI """ + if hasattr(self, 'valueWidget') and qt_object_exists(self.valueWidget): # avoid crash if device closed and try to write gui (if close device before reading finished) + # Update value + if self.action.type in [int, float]: + self.valueWidget.setText(f'{value:.{self.precision}g}') # default is .6g + elif self.action.type in [str]: + self.valueWidget.setText(value) + elif self.action.type in [bytes]: + self.valueWidget.setText(value.decode()) + elif self.action.type in [bool]: + self.valueWidget.setChecked(value) + elif self.action.type in [tuple]: + items = [self.valueWidget.itemText(i) + for i in range(self.valueWidget.count())] + if value[0] != items: + self.valueWidget.clear() + self.valueWidget.addItems(value[0]) + self.valueWidget.setCurrentIndex(value[1]) + elif self.action.type in [np.ndarray]: + self.valueWidget.setText(array_to_str(value)) + elif self.action.type in [pd.DataFrame]: + self.valueWidget.setText(dataframe_to_str(value)) + else: + self.valueWidget.setText(value) + + def readGui(self) -> Any: + """ This function returns the value in good format of the value in the GUI """ + if self.action.type in [int, float, str, bytes, np.ndarray, pd.DataFrame]: + value = self.valueWidget.text() + + if value == '': + if self.action.unit in ('open-file', 'save-file', 'filename'): + if self.action.unit == "filename": # TODO: LEGACY (to remove later) + self.gui.setStatus("Using 'filename' as unit is depreciated in favor of 'open-file' and 'save-file'" \ + f"\nUpdate driver '{self.action.address().split('.')[0]}' to remove this warning", + 10000, False) + self.action.unit = "open-file" + + if self.action.unit == "open-file": + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self.gui, caption=f"Open file - {self.action.address()}", + directory=PATHS['last_folder'], + filter=SUPPORTED_EXTENSION) + elif self.action.unit == "save-file": + filename, _ = QtWidgets.QFileDialog.getSaveFileName( + self.gui, caption=f"Save file - {self.action.address()}", + directory=PATHS['last_folder'], + filter=SUPPORTED_EXTENSION) + + if filename != '': + path = os.path.dirname(filename) + PATHS['last_folder'] = path + return filename + else: + self.gui.setStatus( + f"Action {self.action.address()} cancel filename selection", + 10000) + elif self.action.unit == "user-input": + main_dialog = MyInputDialog(self.gui, self.action.address()) + main_dialog.setWindowModality(QtCore.Qt.ApplicationModal) + main_dialog.show() + + if main_dialog.exec_() == QtWidgets.QInputDialog.Accepted: + response = main_dialog.textValue() + else: + response = '' + + if qt_object_exists(main_dialog): main_dialog.deleteLater() + + if response != '': + return response + else: + self.gui.setStatus( + f"Action {self.action.address()} cancel user input", + 10000) else: self.gui.setStatus( - f"Action {self.action.name} cancel user input", - 10000) + f"Action {self.action.address()} requires a value for its parameter", + 10000, False) else: - self.gui.setStatus( - f"Action {self.action.name} requires a value for its parameter", - 10000, False) + try: + value = eval_variable(value) + if self.action.type in [int]: + value = int(float(value)) + if self.action.type in [bytes]: + value = value.encode() + elif self.action.type in [np.ndarray]: + value = str_to_array(value) if isinstance( + value, str) else create_array(value) + elif self.action.type in [pd.DataFrame]: + if isinstance(value, str): + value = str_to_dataframe(value) + else: + value = self.action.type(value) + return value + except Exception as e: + self.gui.setStatus( + f"Action {self.action.address()}: {e}", + 10000, False) + elif self.action.type in [bool]: + value = self.valueWidget.isChecked() + return value + elif self.action.type in [tuple]: + items = [self.valueWidget.itemText(i) + for i in range(self.valueWidget.count())] + value = (items, self.valueWidget.currentIndex()) + return value + + def setValueKnownState(self, state: Union[bool, float]): + """ Turn the color of the indicator depending of the known state of the value """ + if state == 0.5: + self.indicator.setStyleSheet("background-color:#FFFF00") # yellow + self.indicator.setToolTip('Value written but not read') + elif state: + self.indicator.setStyleSheet("background-color:#70db70") # green + self.indicator.setToolTip('Value read') else: - try: - value = variables.eval_variable(value) - value = self.action.type(value) - return value - except: - self.gui.setStatus(f"Action {self.action.name}: Impossible to convert {value} in type {self.action.type.__name__}",10000, False) + self.indicator.setStyleSheet("background-color:#ff8c1a") # orange + self.indicator.setToolTip('Value not up-to-date') def execute(self): """ Start a new thread to execute the associated action """ - if self.has_value: - value = self.readGui() - if value is not None: self.gui.threadManager.start(self, 'execute', value=value) - else: - self.gui.threadManager.start(self, 'execute') + if not self.isDisabled(): + if self.has_value: + value = self.readGui() + if value is not None: + self.gui.threadManager.start(self, 'execute', value=value) + else: + self.gui.threadManager.start(self, 'execute') + + def valueEdited(self): + """ Change indicator state when editing action parameter """ + self.setValueKnownState(False) + + def valueWrited(self, value: Any): + """ Called when action parameter written """ + try: + if self.has_value: + self.writeGui(value) + self.setValueKnownState(0.5) + except Exception as e: + self.gui.setStatus(f"SHOULD NOT RAISE ERROR: {e}", 10000, False) class TreeWidgetItemVariable(QtWidgets.QTreeWidgetItem): @@ -203,44 +368,66 @@ def __init__(self, itemParent, variable, gui): self.setTextAlignment(1, QtCore.Qt.AlignHCenter) self.gui = gui - self.variable = variable # Import Autolab config - control_center_config = config.get_control_center_config() - self.precision = int(control_center_config['precision']) + control_center_config = get_control_center_config() + self.precision = int(float(control_center_config['precision'])) # Signal creation and associations in autolab devices instances self.readSignal = ReadSignal() self.readSignal.read.connect(self.writeGui) self.variable._read_signal = self.readSignal self.writeSignal = WriteSignal() - self.writeSignal.writed.connect(self.valueEdited) + self.writeSignal.writed.connect(self.valueWrited) self.variable._write_signal = self.writeSignal # Main - Column 2 : Creation of a READ button if the variable is readable - if self.variable.readable and self.variable.type in [int, float, bool, str]: + if self.variable.readable and self.variable.type in [ + int, float, bool, str, bytes, tuple, np.ndarray, pd.DataFrame]: self.readButton = QtWidgets.QPushButton() self.readButton.setText("Read") self.readButton.clicked.connect(self.read) - self.gui.tree.setItemWidget(self, 2, self.readButton) + + if not self.variable.writable and self.variable.type in [ + np.ndarray, pd.DataFrame]: + self.readButtonCheck = QtWidgets.QCheckBox() + self.readButtonCheck.stateChanged.connect( + self.readButtonCheckEdited) + self.readButtonCheck.setToolTip( + 'Toggle reading in text, ' \ + 'careful can truncate data and impact performance') + self.readButtonCheck.setMaximumWidth(15) + + frameReadButton = QtWidgets.QFrame() + hbox = QtWidgets.QHBoxLayout(frameReadButton) + hbox.setSpacing(0) + hbox.setContentsMargins(0,0,0,0) + hbox.addWidget(self.readButtonCheck) + hbox.addWidget(self.readButton) + self.gui.tree.setItemWidget(self, 2, frameReadButton) + else: + self.gui.tree.setItemWidget(self, 2, self.readButton) # Main - column 3 : Creation of a VALUE widget, depending on the type ## QLineEdit or QLabel - if self.variable.type in [int, float, str, pd.DataFrame, np.ndarray]: - + if self.variable.type in [int, float, str, bytes, np.ndarray, pd.DataFrame]: if self.variable.writable: - self.valueWidget = QtWidgets.QLineEdit() + self.valueWidget = MyLineEdit() self.valueWidget.setAlignment(QtCore.Qt.AlignCenter) self.valueWidget.returnPressed.connect(self.write) self.valueWidget.textEdited.connect(self.valueEdited) - # self.valueWidget.setPlaceholderText(self.variable._help) # OPTIMIZE: Could be nice but take too much place. Maybe add it as option - elif self.variable.readable and self.variable.type in [int, float, str]: + self.valueWidget.setMaxLength(10000000) # default is 32767, not enought for array and dataframe + # self.valueWidget.setPlaceholderText(self.variable._help) # Could be nice but take too much place. Maybe add it as option + elif self.variable.readable: self.valueWidget = QtWidgets.QLineEdit() + self.valueWidget.setMaxLength(10000000) self.valueWidget.setReadOnly(True) - self.valueWidget.setStyleSheet( - "QLineEdit {border : 1px solid #a4a4a4; background-color : #f4f4f4}") + palette = self.valueWidget.palette() + palette.setColor(QtGui.QPalette.Base, + palette.color(QtGui.QPalette.Base).darker(107)) + self.valueWidget.setPalette(palette) self.valueWidget.setAlignment(QtCore.Qt.AlignCenter) else: self.valueWidget = QtWidgets.QLabel() @@ -250,22 +437,7 @@ def __init__(self, itemParent, variable, gui): ## QCheckbox for boolean variables elif self.variable.type in [bool]: - - class MyQCheckBox(QtWidgets.QCheckBox): - - def __init__(self, parent): - self.parent = parent - super().__init__() - - def mouseReleaseEvent(self, event): - super().mouseReleaseEvent(event) - self.parent.valueEdited() - self.parent.write() - self.valueWidget = MyQCheckBox(self) - # self.valueWidget = QtWidgets.QCheckBox() - # self.valueWidget.stateChanged.connect(self.valueEdited) - # self.valueWidget.stateChanged.connect(self.write) # removed this to avoid setting a second time when reading a change hbox = QtWidgets.QHBoxLayout() hbox.addWidget(self.valueWidget) hbox.setAlignment(QtCore.Qt.AlignCenter) @@ -273,14 +445,32 @@ def mouseReleaseEvent(self, event): hbox.setContentsMargins(0,0,0,0) widget = QtWidgets.QWidget() widget.setLayout(hbox) - if not self.variable.writable: # Disable interaction is not writable + if not self.variable.writable: # Disable interaction is not writable self.valueWidget.setEnabled(False) + self.gui.tree.setItemWidget(self, 3, widget) + ## Combobox for tuples: Tuple[List[str], int] + elif self.variable.type in [tuple]: + if self.variable.writable: + self.valueWidget = MyQComboBox() + self.valueWidget.wheel = False # prevent changing value by mistake + self.valueWidget.key = False + self.valueWidget.activated.connect(self.write) + self.valueWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.valueWidget.customContextMenuRequested.connect(self.openInputDialog) + elif self.variable.readable: + self.valueWidget = MyQComboBox() + self.valueWidget.readonly = True + else: + self.valueWidget = QtWidgets.QLabel() + self.valueWidget.setAlignment(QtCore.Qt.AlignCenter) + + self.gui.tree.setItemWidget(self, 3, self.valueWidget) + # Main - column 4 : indicator (status of the actual value : known or not known) - if self.variable.type in [int, float, str, bool, np.ndarray, pd.DataFrame]: - self.indicator = QtWidgets.QLabel() - self.gui.tree.setItemWidget(self, 4, self.indicator) + self.indicator = QtWidgets.QLabel() + self.gui.tree.setItemWidget(self, 4, self.indicator) # Tooltip if self.variable._help is None: tooltip = 'No help available for this variable' @@ -290,62 +480,161 @@ def mouseReleaseEvent(self, event): tooltip += f" ({variable_type})" self.setToolTip(0, tooltip) + def openInputDialog(self, position: QtCore.QPoint): + """ Only used for tuple """ + menu = QtWidgets.QMenu() + modifyTuple = menu.addAction("Modify tuple") + modifyTuple.setIcon(icons['tuple']) + + choice = menu.exec_(self.valueWidget.mapToGlobal(position)) + + if choice == modifyTuple: + main_dialog = MyInputDialog(self.gui, self.variable.address()) + main_dialog.setWindowModality(QtCore.Qt.ApplicationModal) + if self.variable.type in [tuple]: + main_dialog.setTextValue(str(self.variable.value)) + main_dialog.show() + + if main_dialog.exec_() == QtWidgets.QInputDialog.Accepted: + response = main_dialog.textValue() + else: + response = '' + + if qt_object_exists(main_dialog): main_dialog.deleteLater() + + if response != '': + try: + if has_eval(response): + response = eval_variable(response) + if self.variable.type in [tuple]: + response = str_to_tuple(str(response)) + except Exception as e: + self.gui.setStatus( + f"Variable {self.variable.address()}: {e}", 10000, False) + return None + + self.variable(response) + + if self.variable.readable: + self.variable() + def writeGui(self, value): """ This function displays a new value in the GUI """ - if hasattr(self, 'valueWidget') and qt_object_exists(self.valueWidget): # avoid crash if device closed and try to write gui (if close device before reading finihsed) + if hasattr(self, 'valueWidget') and qt_object_exists(self.valueWidget): # avoid crash if device closed and try to write gui (if close device before reading finished) # Update value if self.variable.numerical: self.valueWidget.setText(f'{value:.{self.precision}g}') # default is .6g elif self.variable.type in [str]: self.valueWidget.setText(value) + elif self.variable.type in [bytes]: + self.valueWidget.setText(value.decode()) elif self.variable.type in [bool]: self.valueWidget.setChecked(value) + elif self.variable.type in [tuple]: + items = [self.valueWidget.itemText(i) + for i in range(self.valueWidget.count())] + if value[0] != items: + self.valueWidget.clear() + self.valueWidget.addItems(value[0]) + self.valueWidget.setCurrentIndex(value[1]) + elif self.variable.type in [np.ndarray, pd.DataFrame]: + if self.variable.writable or self.readButtonCheck.isChecked(): + if self.variable.type in [np.ndarray]: + self.valueWidget.setText(array_to_str(value)) + if self.variable.type in [pd.DataFrame]: + self.valueWidget.setText(dataframe_to_str(value)) + # else: + # self.valueWidget.setText('') # Change indicator light to green - if self.variable.type in [int, float, bool, str, np.ndarray, pd.DataFrame]: + if self.variable.type in [ + int, float, bool, str, bytes, tuple, np.ndarray, pd.DataFrame]: self.setValueKnownState(True) def readGui(self): """ This function returns the value in good format of the value in the GUI """ - if self.variable.type in [int, float, str, np.ndarray, pd.DataFrame]: + if self.variable.type in [int, float, str, bytes, np.ndarray, pd.DataFrame]: value = self.valueWidget.text() if value == '': self.gui.setStatus( - f"Variable {self.variable.name} requires a value to be set", + f"Variable {self.variable.address()} requires a value to be set", 10000, False) else: try: - value = variables.eval_variable(value) - value = self.variable.type(value) + value = eval_variable(value) + if self.variable.type in [int]: + value = int(float(value)) + if self.variable.type in [bytes]: + value = value.encode() + elif self.variable.type in [np.ndarray]: + if isinstance(value, str): value = str_to_array(value) + else: value = create_array(value) + elif self.variable.type in [pd.DataFrame]: + if isinstance(value, str): value = str_to_dataframe(value) + else: + value = self.variable.type(value) return value - except: - self.gui.setStatus(f"Variable {self.variable.name}: Impossible to convert {value} in type {self.variable.type.__name__}",10000, False) + except Exception as e: + self.gui.setStatus( + f"Variable {self.variable.address()}: {e}", + 10000, False) elif self.variable.type in [bool]: value = self.valueWidget.isChecked() return value + elif self.variable.type in [tuple]: + items = [self.valueWidget.itemText(i) + for i in range(self.valueWidget.count())] + value = (items, self.valueWidget.currentIndex()) + return value - def setValueKnownState(self, state): + def setValueKnownState(self, state: Union[bool, float]): """ Turn the color of the indicator depending of the known state of the value """ - if state: self.indicator.setStyleSheet("background-color:#70db70") # green - else: self.indicator.setStyleSheet("background-color:#ff8c1a") # orange + if state == 0.5: + self.indicator.setStyleSheet("background-color:#FFFF00") # yellow + self.indicator.setToolTip('Value written but not read') + elif state: + self.indicator.setStyleSheet("background-color:#70db70") # green + self.indicator.setToolTip('Value read') + else: + self.indicator.setStyleSheet("background-color:#ff8c1a") # orange + self.indicator.setToolTip('Value not up-to-date') def read(self): """ Start a new thread to READ the associated variable """ - self.setValueKnownState(False) - self.gui.threadManager.start(self, 'read') + if not self.isDisabled(): + self.setValueKnownState(False) + self.gui.threadManager.start(self, 'read') def write(self): """ Start a new thread to WRITE the associated variable """ - value = self.readGui() - if value is not None: - self.gui.threadManager.start(self, 'write', value=value) + if not self.isDisabled(): + value = self.readGui() + if value is not None: + self.gui.threadManager.start(self, 'write', value=value) + + def valueWrited(self, value: Any): + """ Function call when the value displayed in not sure anymore. + The value has been modified either in the GUI (but not sent) + or by command line. + If variable not readable, write the value sent to the GUI """ + # BUG: I got an error when changing emit_write to set value, need to reproduce it + try: + self.writeGui(value) + self.setValueKnownState(0.5) + except Exception as e: + self.gui.setStatus(f"SHOULD NOT RAISE ERROR: {e}", 10000, False) def valueEdited(self): """ Function call when the value displayed in not sure anymore. - The value has been modified either in the GUI (but not sent) or by command line """ + The value has been modified either in the GUI (but not sent) + or by command line """ self.setValueKnownState(False) + def readButtonCheckEdited(self): + state = bool(self.readButtonCheck.isChecked()) + self.readButton.setEnabled(state) + def menu(self, position): """ This function provides the menu when the user right click on an item """ if not self.isDisabled(): @@ -362,12 +651,12 @@ def menu(self, position): def saveValue(self): filename = QtWidgets.QFileDialog.getSaveFileName( self.gui, f"Save {self.variable.name} value", os.path.join( - paths.USER_LAST_CUSTOM_FOLDER,f'{self.variable.address()}.txt'), + PATHS['last_folder'],f'{self.variable.address()}.txt'), filter=SUPPORTED_EXTENSION)[0] path = os.path.dirname(filename) if path != '': - paths.USER_LAST_CUSTOM_FOLDER = path + PATHS['last_folder'] = path try: self.gui.setStatus( f"Saving value of {self.variable.name}...", 5000) @@ -376,7 +665,7 @@ def saveValue(self): f"Value of {self.variable.name} successfully read and save at {filename}", 5000) except Exception as e: - self.gui.setStatus(f"An error occured: {str(e)}", 10000, False) + self.gui.setStatus(f"An error occured: {e}", 10000, False) # Signals can be emitted only from QObjects @@ -387,6 +676,6 @@ def emit_read(self, value): self.read.emit(value) class WriteSignal(QtCore.QObject): - writed = QtCore.Signal() - def emit_write(self): - self.writed.emit() + writed = QtCore.Signal(object) + def emit_write(self, value): + self.writed.emit(value) diff --git a/autolab/core/gui/scanning/config.py b/autolab/core/gui/scanning/config.py index bd2cf47f..3a555742 100644 --- a/autolab/core/gui/scanning/config.py +++ b/autolab/core/gui/scanning/config.py @@ -9,17 +9,23 @@ import datetime import os import math as m -from typing import Any, Tuple, List, Dict +from typing import Any, Tuple, List, Dict, Union from collections import OrderedDict import numpy as np import pandas as pd from qtpy import QtWidgets, QtCore -from .. import variables -from ...utilities import (boolean, str_to_array, array_to_str, - str_to_dataframe, dataframe_to_str, create_array) -from ... import paths, devices, config +from ...config import get_scanner_config +from ...elements import Variable as Variable_og +from ...elements import Action +from ...devices import DEVICES, list_loaded_devices, get_element_by_address +from ...utilities import (boolean, str_to_array, array_to_str, create_array, + str_to_dataframe, dataframe_to_str, str_to_data, + str_to_tuple) +from ...variables import (get_variable, has_eval, is_Variable, eval_variable, + remove_from_config, update_from_config, VARIABLES) +from ...paths import PATHS from .... import __version__ @@ -71,7 +77,7 @@ def __init__(self, gui: QtWidgets.QMainWindow): self.gui = gui # Import Autolab config - scanner_config = config.get_scanner_config() + scanner_config = get_scanner_config() self.precision = scanner_config['precision'] # Initializing configuration values @@ -82,6 +88,34 @@ def __init__(self, gui: QtWidgets.QMainWindow): self._old_variables = [] # To update variable menu + def ask_get_element_by_address(self, device_name: str, address: str): + """ Wrap of :meth:`get_element_by_address` to ask user if want to + instantiate device if not already instantiated. + Returns the element at the given address. """ + if device_name not in DEVICES: + msg_box = QtWidgets.QMessageBox(self.gui) + msg_box.setWindowTitle(f"Device {device_name}") + msg_box.setText(f"Instantiate device {device_name}?") + msg_box.setStandardButtons(QtWidgets.QMessageBox.Ok + | QtWidgets.QMessageBox.Cancel) + msg_box.show() + if msg_box.exec_() == QtWidgets.QMessageBox.Cancel: + raise ValueError(f'Refused {device_name} instantiation') + + element = get_element_by_address(address) + + return element + + def update_loaded_devices(self, already_loaded_devices: list): + """ Refresh GUI with the new loaded devices """ + for device in (set(list_loaded_devices()) - set(already_loaded_devices)): + item_list = self.gui.mainGui.tree.findItems( + device, QtCore.Qt.MatchExactly, 0) + + if len(item_list) == 1: + item = item_list[0] + self.gui.mainGui.itemClicked(item) + # NAMES ########################################################################### @@ -139,8 +173,8 @@ def updateVariableConfig(self, new_variables: List[Tuple[str, Any]] = None): if new_variables is None: new_variables = self.getConfigVariables() remove_variables = list(set(self._old_variables) - set(new_variables)) - variables.remove_from_config(remove_variables) - variables.update_from_config(new_variables) + remove_from_config(remove_variables) + update_from_config(new_variables) self._old_variables = new_variables def addNewConfig(self): @@ -194,7 +228,9 @@ def renameRecipe(self, existing_recipe_name: str, new_recipe_name: str): if new_recipe_name == existing_recipe_name: return if existing_recipe_name not in self.recipeNameList(): - raise ValueError(f'should not be possible to select a non existing recipe_name: {existing_recipe_name} not in {self.recipeNameList()}') + raise ValueError( + 'should not be possible to select a non existing recipe_name: ' \ + f'{existing_recipe_name} not in {self.recipeNameList()}') new_recipe_name = self.getUniqueNameRecipe(new_recipe_name) old_config = self.config @@ -216,7 +252,7 @@ def renameRecipe(self, existing_recipe_name: str, new_recipe_name: str): self.gui.selectParameter_comboBox.setCurrentIndex(prev_index_param) def checkConfig(self): - """ Checks validity of a config. Used before a scan start. """ + """ Checks validity of a config. Used before a scan start or after a scan pause. """ assert len(self.recipeNameList()) != 0, 'Need a recipe to start a scan!' one_recipe_active = False @@ -238,26 +274,30 @@ def checkConfig(self): for step in recipe_i['recipe']: if step['stepType'] == 'recipe': has_sub_recipe = True - assert step['element'] in self.config, f"Recipe {step['element']} doesn't exist in {recipe_name}!" + assert step['element'] in self.config, ( + f"Recipe {step['element']} doesn't exist in {recipe_name}!") other_recipe = self.config[step['element']] - assert len(other_recipe['recipe']) > 0, f"Recipe {step['element']} is empty!" + assert len(other_recipe['recipe']) > 0, ( + f"Recipe {step['element']} is empty!") list_recipe_new.append(other_recipe) list_recipe_new.remove(recipe_i) assert one_recipe_active, "Need at least one active recipe!" - # Replace closed devices by reopenned one - for recipe_name in self.recipeNameList(): - for i, step in enumerate(self.config[recipe_name]['recipe']): - if (step['element']._parent.name in devices.DEVICES - and not step['element']._parent in devices.DEVICES.values()): - module_name = step['element']._parent.name - module = self.gui.mainGui.tree.findItems( - module_name, QtCore.Qt.MatchExactly)[0].module - var = module.get_variable( - self.config[recipe_name]['recipe'][i]['element'].name) - self.config[recipe_name]['recipe'][i]['element'] = var + already_loaded_devices = list_loaded_devices() + try: + # Replace closed devices by reopened one + for recipe_name in self.recipeNameList(): + for step in (self.stepList(recipe_name) + + self.parameterList(recipe_name)): + if step['element']: + device_name = step['element'].address().split('.')[0] + element = self.ask_get_element_by_address( + device_name, step['element'].address()) + step['element'] = element + finally: + self.update_loaded_devices(already_loaded_devices) def lastRecipeName(self) -> str: """ Returns last recipe name """ @@ -305,7 +345,7 @@ def removeParameter(self, recipe_name: str, param_name: str): self.addNewConfig() def setParameter(self, recipe_name: str, param_name: str, - element: devices.Device, newName: str = None): + element: Variable_og, newName: str = None): """ Sets the element provided as the new parameter of the scan. Add a parameter is no existing parameter """ if not self.gui.scanManager.isStarted(): @@ -420,7 +460,7 @@ def setValues(self, recipe_name: str, param_name: str, values: List[float]): if not self.gui.scanManager.isStarted(): param = self.getParameter(recipe_name, param_name) - if variables.has_eval(values) or np.ndim(values) == 1: + if has_eval(values) or np.ndim(values) == 1: param['values'] = values self.addNewConfig() @@ -435,7 +475,8 @@ def addRecipeStep(self, recipe_name: str, stepType: str, element, recipe_name = self.lastRecipeName() if not self.gui.scanManager.isStarted(): - assert recipe_name in self.recipeNameList(), f'{recipe_name} not in {self.recipeNameList()}' + assert recipe_name in self.recipeNameList(), ( + f'{recipe_name} not in {self.recipeNameList()}') if name is None: name = self.getUniqueName(recipe_name, element.name) @@ -450,16 +491,19 @@ def addRecipeStep(self, recipe_name: str, stepType: str, element, assert element != recipe_name, "Can't have a recipe in itself: {element}" # safeguard but should be stopped before arriving here if stepType == 'set': setValue = True elif stepType == 'action' and element.type in [ - int, float, str, np.ndarray, pd.DataFrame]: setValue = True + int, float, bool, str, bytes, tuple, np.ndarray, pd.DataFrame]: + setValue = True else: setValue = False if setValue: if value is None: if element.type in [int, float]: value = 0 elif element.type in [str]: value = '' + elif element.type in [bytes]: value = b'' elif element.type in [pd.DataFrame]: value = pd.DataFrame() elif element.type in [np.ndarray]: value = np.array([]) elif element.type in [bool]: value = False + elif element.type in [tuple]: value = ([], -1) step['value'] = value self.stepList(recipe_name).append(step) @@ -478,18 +522,18 @@ def renameRecipeStep(self, recipe_name: str, name: str, newName: str): """ Renames a step in the scan recipe """ if not self.gui.scanManager.isStarted(): if newName != name: - pos = self.getRecipeStepPosition(recipe_name, name) + step_info = self.getRecipeStep(recipe_name, name) newName = self.getUniqueName(recipe_name, newName) - self.stepList(recipe_name)[pos]['name'] = newName + step_info['name'] = newName self.gui._refreshRecipe(recipe_name) self.addNewConfig() def setRecipeStepValue(self, recipe_name: str, name: str, value: Any): """ Sets the value of a step in the scan recipe """ if not self.gui.scanManager.isStarted(): - pos = self.getRecipeStepPosition(recipe_name, name) - if value is not self.stepList(recipe_name)[pos]['value']: - self.stepList(recipe_name)[pos]['value'] = value + step_info = self.getRecipeStep(recipe_name, name) + if value is not step_info['value']: + step_info['value'] = value self.gui._refreshRecipe(recipe_name) self.addNewConfig() @@ -598,7 +642,8 @@ def getParameter(self, recipe_name: str, param_name: str) -> dict: def getParameterPosition(self, recipe_name: str, param_name: str) -> int: """ Returns the position of a parameter """ - return [i for i, param in enumerate(self.parameterList(recipe_name)) if param['name'] == param_name][0] + return [i for i, param in enumerate(self.parameterList(recipe_name)) + if param['name'] == param_name][0] def parameterList(self, recipe_name: str) -> List[dict]: """ Returns the list of parameters in the recipe """ @@ -606,12 +651,11 @@ def parameterList(self, recipe_name: str) -> List[dict]: def parameterNameList(self, recipe_name: str) -> List[str]: """ Returns the list of parameter names in the recipe """ - if recipe_name in self.config: - return [param['name'] for param in self.parameterList(recipe_name)] - else: - return [] + return [param['name'] for param in self.parameterList(recipe_name)] if ( + recipe_name in self.config) else [] - def getParameterElement(self, recipe_name: str, param_name: str) -> devices.Device: + def getParameterElement( + self, recipe_name: str, param_name: str) -> Union[None, Variable_og]: """ Returns the element of a parameter """ param = self.getParameter(recipe_name, param_name) return param['element'] @@ -648,7 +692,8 @@ def getValues(self, recipe_name: str, param_name: str) -> List[float]: # Creates the array of values for the parameter if logScale: - paramValues = np.logspace(m.log10(startValue), m.log10(endValue), nbpts, endpoint=True) + paramValues = np.logspace( + m.log10(startValue), m.log10(endValue), nbpts, endpoint=True) else: paramValues = np.linspace(startValue, endValue, nbpts, endpoint=True) @@ -664,20 +709,26 @@ def stepList(self, recipe_name: str) -> List[dict]: """ Returns the list of steps in the recipe """ return self.config[recipe_name]['recipe'] - def getRecipeStepElement(self, recipe_name: str, name: str) -> devices.Device: - """ Returns the element of a recipe step """ + def getRecipeStep(self, recipe_name: str, name: str) -> dict: + """ Returns a dictionnary with recipe step information """ pos = self.getRecipeStepPosition(recipe_name, name) - return self.stepList(recipe_name)[pos]['element'] + return self.stepList(recipe_name)[pos] + + def getRecipeStepElement( + self, recipe_name: str, name: str) -> Union[Variable_og, Action]: + """ Returns the element of a recipe step """ + step_info = self.getRecipeStep(recipe_name, name) + return step_info['element'] def getRecipeStepType(self, recipe_name: str, name: str) -> str: """ Returns the type a recipe step """ - pos = self.getRecipeStepPosition(recipe_name, name) - return self.stepList(recipe_name)[pos]['stepType'] + step_info = self.getRecipeStep(recipe_name, name) + return step_info['stepType'] def getRecipeStepValue(self, recipe_name: str, name: str) -> Any: """ Returns the value of a recipe step """ - pos = self.getRecipeStepPosition(recipe_name, name) - return self.stepList(recipe_name)[pos]['value'] + step_info = self.getRecipeStep(recipe_name, name) + return step_info['value'] def getRecipeStepPosition(self, recipe_name: str, name: str) -> int: """ Returns the position of a recipe step in the recipe """ @@ -687,7 +738,7 @@ def getParamDataFrame(self, recipe_name: str, param_name: str) -> pd.DataFrame: """ Returns a pd.DataFrame with 'id' and 'param_name' columns containing the parameter array """ paramValues = self.getValues(recipe_name, param_name) - paramValues = variables.eval_variable(paramValues) + paramValues = eval_variable(paramValues) paramValues = create_array(paramValues) assert isinstance(paramValues, np.ndarray) data = pd.DataFrame() @@ -704,7 +755,7 @@ def getConfigVariables(self) -> List[Tuple[str, Any]]: for recipe_name in reversed(self.recipeNameList()): for param_name in self.parameterNameList(recipe_name): values = self.getValues(recipe_name, param_name) - value = values if variables.has_eval(values) else float(values[0]) + value = values if has_eval(values) else float(values[0]) listVariable.append((param_name, value)) for step in self.stepList(recipe_name): if step['stepType'] == 'measure': @@ -749,7 +800,7 @@ def create_configPars(self) -> dict: param_pars['address'] = "None" if 'values' in param: - if variables.has_eval(param['values']): + if has_eval(param['values']): param_pars['values'] = param['values'] else: param_pars['values'] = array_to_str( @@ -777,11 +828,11 @@ def create_configPars(self) -> dict: if stepType == 'set' or (stepType == 'action' and config_step['element'].type in [ - int, float, str, + int, float, bool, str, bytes, tuple, np.ndarray, pd.DataFrame]): value = config_step['value'] - if variables.has_eval(value): + if has_eval(value): valueStr = value else: if config_step['element'].type in [np.ndarray]: @@ -794,6 +845,10 @@ def create_configPars(self) -> dict: valueStr = f'{value:.{self.precision}g}' except: valueStr = f'{value}' + elif config_step['element'].type in [bytes]: + valueStr = f'{value.decode()}' + else: # for tuple and safety + valueStr = f'{value}' pars_recipe_i['recipe'][f'{i+1}_value'] = valueStr @@ -801,20 +856,21 @@ def create_configPars(self) -> dict: # Add variables to config name_var_config = [var[0] for var in self.getConfigVariables()] - names_var_user = list(variables.VARIABLES) + names_var_user = list(VARIABLES) names_var_to_save = list(set(names_var_user) - set(name_var_config)) var_to_save = {} for var_name in names_var_to_save: - var = variables.get_variable(var_name) + var = get_variable(var_name) if var is not None: - assert variables.is_Variable(var) + assert is_Variable(var) value_raw = var.raw - if isinstance(value_raw, np.ndarray): valueStr = array_to_str( + if isinstance(value_raw, np.ndarray): + valueStr = array_to_str( value_raw, threshold=1000000, max_line_width=9000000) - elif isinstance(value_raw, pd.DataFrame): valueStr = dataframe_to_str( - value_raw, threshold=1000000) + elif isinstance(value_raw, pd.DataFrame): + valueStr = dataframe_to_str(value_raw, threshold=1000000) elif isinstance(value_raw, (int, float, str)): try: valueStr = f'{value_raw:.{self.precision}g}' except: valueStr = f'{value_raw}' @@ -836,28 +892,33 @@ def import_configPars(self, filename: str, append: bool = False): try: with open(filename, "r") as read_file: configPars = json.load(read_file) - except Exception as error: - self.gui.setStatus(f"Impossible to load configuration file: {error}", 10000, False) + except Exception as e: + self.gui.setStatus( + f"Impossible to load configuration file: {e}", + 10000, False) return None else: - print("ConfigParser depreciated, now use json. Will convert this config to json if save it.") - configPars = {s: dict(legacy_configPars.items(s)) for s in legacy_configPars.sections()} + print("ConfigParser depreciated, now use json. " \ + "Will convert this config to json if save it.") + configPars = {s: dict(legacy_configPars.items(s)) + for s in legacy_configPars.sections()} path = os.path.dirname(filename) - paths.USER_LAST_CUSTOM_FOLDER = path + PATHS['last_folder'] = path self.load_configPars(configPars, append=append) if not self._got_error: self.addNewConfig() else: - self.gui.setStatus(f"Configuration file {filename} doesn't exists", 5000) + self.gui.setStatus( + f"Configuration file {filename} doesn't exists", 5000) def load_configPars(self, configPars: dict, append: bool = False): """ Creates a config representing a scan form a configPars """ self._got_error = False self.configHistory.active = False previous_config = self.config.copy() # used to recover old config if error in loading new one - already_loaded_devices = devices.list_loaded_devices() + already_loaded_devices = list_loaded_devices() try: # LEGACY <= 1.2 @@ -899,7 +960,9 @@ def load_configPars(self, configPars: dict, append: bool = False): # Config config = OrderedDict() - recipeNameList = [i for i in list(configPars) if i != 'autolab' and i != 'variables'] # to remove 'autolab' from recipe list + # to remove 'autolab' and 'variables' from recipe list + recipeNameList = [i for i in list(configPars) + if i not in ('autolab', 'variables')] for recipe_num_name in recipeNameList: @@ -920,38 +983,45 @@ def load_configPars(self, configPars: dict, append: bool = False): else: recipe_i['active'] = True # LEGACY <= 1.2.1 - assert 'parameter' in pars_recipe_i, f'Missing parameter in {recipe_name}' + assert 'parameter' in pars_recipe_i, ( + f'Missing parameter in {recipe_name}') param_list = recipe_i['parameter'] = [] # LEGACY <= 1.2.1 if len(pars_recipe_i['parameter']) != 0: - if type(list(pars_recipe_i['parameter'].values())[0]) is not dict: - pars_recipe_i['parameter'] = {'parameter_1': pars_recipe_i['parameter']} + if not isinstance( + list(pars_recipe_i['parameter'].values())[0], + dict): + pars_recipe_i['parameter'] = { + 'parameter_1': pars_recipe_i['parameter']} for param_pars_name in pars_recipe_i['parameter']: param_pars = pars_recipe_i['parameter'][param_pars_name] param = {} - assert 'name' in param_pars, f"Missing name to {param_pars}" + assert 'name' in param_pars, ( + f"Missing name to {param_pars}") param['name'] = param_pars['name'] - assert 'address' in param_pars, f"Missing address to {param_pars}" + assert 'address' in param_pars, ( + f"Missing address to {param_pars}") if param_pars['address'] == "None": element = None else: - element = devices.get_element_by_address(param_pars['address']) - assert element is not None, f"Parameter {param_pars['address']} not found." + device_name = param_pars['address'].split('.')[0] + element = self.ask_get_element_by_address(device_name, param_pars['address']) param['element'] = element if 'values' in param_pars: - if variables.has_eval(param_pars['values']): + if has_eval(param_pars['values']): values = param_pars['values'] else: values = str_to_array(param_pars['values']) - if not variables.has_eval(values): - assert np.ndim(values) == 1, f"Values must be one dimension array in parameter: {param['name']}" + if not has_eval(values): + assert np.ndim(values) == 1, ( + f"Values must be one dimension array in parameter: {param['name']}") param['values'] = values else: for key in ['nbpts', 'start_value', 'end_value', 'log']: @@ -982,30 +1052,36 @@ def load_configPars(self, configPars: dict, append: bool = False): step['name'] = pars_recipe[f'{i}_name'] name = step['name'] - assert f'{i}_steptype' in pars_recipe, f"Missing stepType in step {i} ({name})." + assert f'{i}_steptype' in pars_recipe, ( + f"Missing stepType in step {i} ({name}).") step['stepType'] = pars_recipe[f'{i}_steptype'] - assert f'{i}_address' in pars_recipe, f"Missing address in step {i} ({name})." + assert f'{i}_address' in pars_recipe, ( + f"Missing address in step {i} ({name}).") address = pars_recipe[f'{i}_address'] if step['stepType'] == 'recipe': - assert step['stepType'] != 'recipe', "Removed the recipe in recipe feature!" + assert step['stepType'] != 'recipe', ( + "Removed the recipe in recipe feature!") element = address else: - element = devices.get_element_by_address(address) + device_name = address.split('.')[0] + element = self.ask_get_element_by_address(device_name, address) - assert element is not None, f"Address {address} not found for step {i} ({name})." step['element'] = element if (step['stepType'] == 'set') or ( step['stepType'] == 'action' and element.type in [ - int, float, str, np.ndarray, pd.DataFrame]): - assert f'{i}_value' in pars_recipe, f"Missing value in step {i} ({name})." + int, float, bool, str, bytes, tuple, + np.ndarray, pd.DataFrame]): + assert f'{i}_value' in pars_recipe, ( + f"Missing value in step {i} ({name}).") value = pars_recipe[f'{i}_value'] try: try: - assert variables.has_eval(value), "Need $eval: to evaluate the given string" + assert has_eval(value), ( + "Need $eval: to evaluate the given string") except: # Type conversions if element.type in [int]: @@ -1014,14 +1090,19 @@ def load_configPars(self, configPars: dict, append: bool = False): value = float(value) elif element.type in [str]: value = str(value) + elif element.type in [bytes]: + value = value.encode() elif element.type in [bool]: value = boolean(value) + elif element.type in [tuple]: + value = str_to_tuple(value) elif element.type in [np.ndarray]: value = str_to_array(value) elif element.type in [pd.DataFrame]: value = str_to_dataframe(value) else: - assert variables.has_eval(value), "Need $eval: to evaluate the given string" + assert has_eval(value), ( + "Need $eval: to evaluate the given string") except: raise ValueError(f"Error with {i}_value = {value}. Expect either {element.type} or device address. Check address or open device first.") @@ -1032,7 +1113,6 @@ def load_configPars(self, configPars: dict, append: bool = False): recipe.append(step) else: break - if append: for conf in config.values(): recipe_name = conf['name'] @@ -1050,27 +1130,23 @@ def load_configPars(self, configPars: dict, append: bool = False): add_vars = [] for var_name, raw_value in var_dict.items(): - raw_value = variables.convert_str_to_data(raw_value) + if not has_eval(raw_value): + raw_value = str_to_data(raw_value) add_vars.append((var_name, raw_value)) - variables.update_from_config(add_vars) + update_from_config(add_vars) except Exception as error: self._got_error = True - self.gui.setStatus(f"Impossible to load configuration file: {error}", 10000, False) + self.gui.setStatus( + f"Impossible to load configuration file: {error}", 10000, False) self.config = previous_config else: self.gui._resetRecipe() self.gui.setStatus("Configuration file loaded successfully", 5000) - - for device in (set(devices.list_loaded_devices()) - set(already_loaded_devices)): - item_list = self.gui.mainGui.tree.findItems(device, QtCore.Qt.MatchExactly, 0) - - if len(item_list) == 1: - item = item_list[0] - self.gui.mainGui.itemClicked(item) - - self.configHistory.active = True + finally: + self.configHistory.active = True + self.update_loaded_devices(already_loaded_devices) # UNDO REDO ACTIONS ########################################################################### @@ -1094,6 +1170,7 @@ def changeConfig(self): self.updateUndoRedoButtons() self.updateVariableConfig() + self.gui.setStatus('') def updateUndoRedoButtons(self): """ enables/disables undo/redo button depending on history """ diff --git a/autolab/core/gui/scanning/customWidgets.py b/autolab/core/gui/scanning/customWidgets.py index a5881320..365c9e07 100644 --- a/autolab/core/gui/scanning/customWidgets.py +++ b/autolab/core/gui/scanning/customWidgets.py @@ -5,13 +5,18 @@ @author: Jonathan """ -from typing import List +from typing import List, Union +import numpy as np +import pandas as pd from qtpy import QtCore, QtWidgets, QtGui from ..icons import icons -from ...devices import Device -from ...utilities import clean_string +from ...utilities import clean_string, array_to_str, dataframe_to_str +from ...elements import Variable as Variable_og +from ...elements import Action +from ...variables import has_eval +from ...config import get_scanner_config class MyQTreeWidget(QtWidgets.QTreeWidget): @@ -23,7 +28,7 @@ def __init__(self, parent: QtWidgets.QFrame, gui: QtWidgets.QMainWindow, recipe_name: str): self.recipe_name = recipe_name - self.scanner = gui + self.scanner = gui # gui is scanner super().__init__(parent) self.setAcceptDrops(True) @@ -143,7 +148,9 @@ def dropEvent(self, event): elif variable.readable: gui.addStepToScanRecipe(self.recipe_name, 'measure', variable) elif variable.writable: - gui.addStepToScanRecipe(self.recipe_name, 'set', variable) + value = variable.value if variable.type in [tuple] else None + gui.addStepToScanRecipe( + self.recipe_name, 'set', variable, value=value) elif variable._element_type == "action": gui.addStepToScanRecipe(self.recipe_name, 'action', variable) @@ -187,13 +194,13 @@ def dragLeaveEvent(self, event): self.setGraphicsEffect(None) def menu(self, gui: QtWidgets.QMainWindow, - variable: Device, position: QtCore.QPoint): + variable: Union[Variable_og, Action], position: QtCore.QPoint): """ Provides the menu when the user right click on an item """ menu = QtWidgets.QMenu() scanMeasureStepAction = menu.addAction("Measure in scan recipe") - scanMeasureStepAction.setIcon(QtGui.QIcon(icons['measure'])) + scanMeasureStepAction.setIcon(icons['measure']) scanSetStepAction = menu.addAction("Set value in scan recipe") - scanSetStepAction.setIcon(QtGui.QIcon(icons['write'])) + scanSetStepAction.setIcon(icons['write']) scanMeasureStepAction.setEnabled(variable.readable) scanSetStepAction.setEnabled(variable.writable) choice = menu.exec_(self.viewport().mapToGlobal(position)) @@ -201,7 +208,142 @@ def menu(self, gui: QtWidgets.QMainWindow, gui.addStepToScanRecipe(self.recipe_name, 'measure', variable) elif choice == scanSetStepAction: - gui.addStepToScanRecipe(self.recipe_name, 'set', variable) + value = variable.value if variable.type in [tuple] else None + gui.addStepToScanRecipe( + self.recipe_name, 'set', variable, value=value) + + def keyPressEvent(self, event): + ctrl = QtCore.Qt.ControlModifier + shift = QtCore.Qt.ShiftModifier + mod = event.modifiers() + if event.key() == QtCore.Qt.Key_R and mod == ctrl: + self.rename_step(event) + elif event.key() == QtCore.Qt.Key_C and mod == ctrl: + self.copy_step(event) + elif event.key() == QtCore.Qt.Key_V and mod == ctrl: + self.paste_step(event) + elif event.key() == QtCore.Qt.Key_Z and mod == ctrl: + # Note: needed to add setFocus to tree on creation to allow multiple ctrl+z + self.scanner.configManager.undoClicked() + elif ( + event.key() == QtCore.Qt.Key_Z and mod == (ctrl | shift) + ) or ( + event.key() == QtCore.Qt.Key_Y and mod == ctrl + ): + self.scanner.configManager.redoClicked() + elif (event.key() == QtCore.Qt.Key_Delete): + self.remove_step(event) + else: + super().keyPressEvent(event) + + def rename_step(self, event): + if len(self.selectedItems()) == 0: + super().keyPressEvent(event) + return None + item = self.selectedItems()[0] # assume can select only one item + self.scanner.recipeDict[self.recipe_name]['recipeManager'].renameStep(item.text(0)) + + def copy_step(self, event): + if len(self.selectedItems()) == 0: + super().keyPressEvent(event) + return None + item = self.selectedItems()[0] # assume can select only one item + self.scanner.recipeDict[self.recipe_name]['recipeManager'].copyStep(item.text(0)) + + def paste_step(self, event): + self.scanner.recipeDict[self.recipe_name]['recipeManager'].pasteStep() + + def remove_step(self, event): + if len(self.selectedItems()) == 0: + super().keyPressEvent(event) + return None + item = self.selectedItems()[0] # assume can select only one item + self.scanner.recipeDict[self.recipe_name]['recipeManager'].removeStep(item.text(0)) + + +class MyQTreeWidgetItem(QtWidgets.QTreeWidgetItem): + + def __init__(self, itemParent: QtWidgets.QTreeWidget, step: dict, + gui: QtWidgets.QMainWindow): + + self.gui = gui + + # Import Autolab config + scanner_config = get_scanner_config() + self.precision = scanner_config['precision'] + + super().__init__(itemParent) + + self.setFlags(self.flags() ^ QtCore.Qt.ItemIsDropEnabled) + self.setToolTip(0, step['element']._help) + + # Column 1 : Step name + self.setText(0, step['name']) + + # OPTIMIZE: stepType is a bad name. Possible confusion with element type. stepType should be stepAction or just action + # Column 2 : Step type + if step['stepType'] == 'measure': + self.setText(1, 'Measure') + self.setIcon(0, icons['measure']) + elif step['stepType'] == 'set': + self.setText(1, 'Set') + self.setIcon(0, icons['write']) + elif step['stepType'] == 'action': + self.setText(1, 'Do') + self.setIcon(0, icons['action']) + elif step['stepType'] == 'recipe': + self.setText(1, 'Recipe') + self.setIcon(0, icons['recipe']) + + # Column 3 : Element address + if step['stepType'] == 'recipe': + self.setText(2, step['element']) + else: + self.setText(2, step['element'].address()) + + # Column 4 : Icon of element type + etype = step['element'].type + if etype is int: self.setIcon(3, icons['int']) + elif etype is float: self.setIcon(3, icons['float']) + elif etype is bool: self.setIcon(3, icons['bool']) + elif etype is str: self.setIcon(3, icons['str']) + elif etype is bytes: self.setIcon(3, icons['bytes']) + elif etype is tuple: self.setIcon(3, icons['tuple']) + elif etype is np.ndarray: self.setIcon(3, icons['ndarray']) + elif etype is pd.DataFrame: self.setIcon(3, icons['DataFrame']) + + # Column 5 : Value if stepType is 'set' + value = step['value'] + if value is not None: + if has_eval(value): + self.setText(4, f'{value}') + else: + try: + if step['element'].type in [bool, str, tuple]: + self.setText(4, f'{value}') + elif step['element'].type in [bytes]: + self.setText(4, f"{value.decode()}") + elif step['element'].type in [np.ndarray]: + value = array_to_str( + value, threshold=1000000, max_line_width=100) + self.setText(4, f'{value}') + elif step['element'].type in [pd.DataFrame]: + value = dataframe_to_str(value, threshold=1000000) + self.setText(4, f'{value}') + else: + self.setText(4, f'{value:.{self.precision}g}') + except ValueError: + self.setText(4, f'{value}') + + # Column 6 : Unit of element + unit = step['element'].unit + if unit is not None: + self.setText(5, str(unit)) + + # set AlignTop to all columns + for i in range(self.columnCount()): + self.setTextAlignment(i, QtCore.Qt.AlignTop) + # OPTIMIZE: icon are not aligned with text: https://www.xingyulei.com/post/qt-button-alignment/index.html class MyQTabWidget(QtWidgets.QTabWidget): @@ -233,36 +375,36 @@ def menu(self, position: QtCore.QPoint): if IS_ACTIVE: activateRecipeAction = menu.addAction("Disable recipe") - activateRecipeAction.setIcon(QtGui.QIcon(icons['is-enable'])) + activateRecipeAction.setIcon(icons['is-enable']) else: activateRecipeAction = menu.addAction("Enable recipe") - activateRecipeAction.setIcon(QtGui.QIcon(icons['is-disable'])) + activateRecipeAction.setIcon(icons['is-disable']) menu.addSeparator() renameRecipeAction = menu.addAction("Rename recipe") - renameRecipeAction.setIcon(QtGui.QIcon(icons['rename'])) + renameRecipeAction.setIcon(icons['rename']) removeRecipeAction = menu.addAction("Remove recipe") - removeRecipeAction.setIcon(QtGui.QIcon(icons['remove'])) + removeRecipeAction.setIcon(icons['remove']) menu.addSeparator() + # OBSOLETE recipeLink = self.gui.configManager.getRecipeLink(self.recipe_name) - if len(recipeLink) == 1: # A bit too restrictive but do the work renameRecipeAction.setEnabled(True) else: renameRecipeAction.setEnabled(False) addParameterAction = menu.addAction("Add parameter") - addParameterAction.setIcon(QtGui.QIcon(icons['add'])) + addParameterAction.setIcon(icons['add']) menu.addSeparator() moveUpRecipeAction = menu.addAction("Move recipe up") - moveUpRecipeAction.setIcon(QtGui.QIcon(icons['up'])) + moveUpRecipeAction.setIcon(icons['up']) moveDownRecipeAction = menu.addAction("Move recipe down") - moveDownRecipeAction.setIcon(QtGui.QIcon(icons['down'])) + moveDownRecipeAction.setIcon(icons['down']) config = self.gui.configManager.config keys = list(config) @@ -302,7 +444,7 @@ def renameRecipe(self): self.recipe_name, newName) -class parameterQFrame(QtWidgets.QFrame): +class ParameterQFrame(QtWidgets.QFrame): # customMimeType = "autolab/MyQTreeWidget-selectedItems" def __init__(self, parent: QtWidgets.QMainWindow, recipe_name: str, param_name: str): diff --git a/autolab/core/gui/scanning/data.py b/autolab/core/gui/scanning/data.py index d45f55c5..ff9b8217 100644 --- a/autolab/core/gui/scanning/data.py +++ b/autolab/core/gui/scanning/data.py @@ -18,9 +18,9 @@ import pandas as pd from qtpy import QtCore, QtWidgets -from .. import variables -from ... import config as autolab_config -from ... import utilities +from ...config import get_scanner_config +from ...utilities import boolean, create_array, data_to_dataframe +from ...variables import has_eval, eval_safely class DataManager: @@ -32,15 +32,15 @@ def __init__(self, gui: QtWidgets.QMainWindow): self.datasets = [] self.queue = Queue() - scanner_config = autolab_config.get_scanner_config() - self.save_temp = utilities.boolean(scanner_config["save_temp"]) + scanner_config = get_scanner_config() + self.save_temp = boolean(scanner_config["save_temp"]) # Timer self.timer = QtCore.QTimer(self.gui) self.timer.setInterval(33) #30fps self.timer.timeout.connect(self.sync) - def getData(self, nbDataset: int, var_list: list, + def getData(self, nbDataset: int, var_list: List[str], selectedData: int = 0, data_name: str = "Scan", filter_condition: List[dict] = []) -> List[pd.DataFrame]: """ Returns the required data """ @@ -49,9 +49,10 @@ def getData(self, nbDataset: int, var_list: list, for i in range(selectedData, nbDataset+selectedData): if i < len(self.datasets): - datasets = self.datasets[-(i+1)] - if recipe_name not in datasets: continue - dataset = datasets[recipe_name] + scanset = self.datasets[-(i+1)] + if recipe_name not in scanset: continue + if not scanset.display: continue + dataset = scanset[recipe_name] data = None if data_name == "Scan": @@ -108,7 +109,7 @@ def getLastSelectedDataset(self) -> Union[dict, None]: def newDataset(self, config: dict): """ Creates and returns a new empty dataset """ maximum = 0 - datasets = {} + scanset = ScanSet() if self.save_temp: FOLDER_TEMP = os.environ['TEMP'] # This variable can be changed at autolab start-up @@ -126,20 +127,20 @@ def newDataset(self, config: dict): dataset = Dataset(sub_folder, recipe_name, config, save_temp=self.save_temp) - datasets[recipe_name] = dataset + scanset[recipe_name] = dataset # bellow just to know maximum point nbpts = 1 for parameter in recipe['parameter']: if 'values' in parameter: - if variables.has_eval(parameter['values']): - values = variables.eval_safely(parameter['values']) + if has_eval(parameter['values']): + values = eval_safely(parameter['values']) if isinstance(values, str): nbpts *= 11 # OPTIMIZE: can't know length in this case without doing eval (should not do eval here because can imagine recipe_2 with param set at end of recipe_1) self.gui.progressBar.setStyleSheet( "QProgressBar::chunk {background-color: orange;}") else: - values = utilities.create_array(values) + values = create_array(values) nbpts *= len(values) else: nbpts *= len(parameter['values']) else: nbpts *= parameter['nbpts'] @@ -174,14 +175,14 @@ def newDataset(self, config: dict): list_recipe_nbpts_new.remove(recipe_nbpts) - self.datasets.append(datasets) + self.datasets.append(scanset) self.gui.progressBar.setMaximum(maximum) def sync(self): """ This function sync the last dataset with the data available in the queue """ # Empty the queue count = 0 - datasets = self.getLastDataset() + scanset = self.getLastDataset() lenQueue = self.queue.qsize() # Add scan data to dataset @@ -190,7 +191,7 @@ def sync(self): except: break recipe_name = list(point.values())[0] - dataset = datasets[recipe_name] + dataset = scanset[recipe_name] dataset.addPoint(point) count += 1 @@ -200,11 +201,13 @@ def sync(self): progress = 0 for dataset_name in self.gui.configManager.getRecipeActive(): - progress += len(datasets[dataset_name]) + progress += len(scanset[dataset_name]) self.gui.progressBar.setValue(progress) self.gui.save_pushButton.setEnabled(True) + if len(self.datasets) != 1: + self.gui.save_all_pushButton.setEnabled(True) # Update plot self.gui.figureManager.data_comboBoxClicked() @@ -214,11 +217,11 @@ def updateDisplayableResults(self): the results that can be plotted """ data_name = self.gui.dataframe_comboBox.currentText() recipe_name = self.gui.scan_recipe_comboBox.currentText() - datasets = self.getLastSelectedDataset() + scanset = self.getLastSelectedDataset() - if datasets is None or recipe_name not in datasets: return None + if scanset is None or recipe_name not in scanset: return None - dataset = datasets[recipe_name] + dataset = scanset[recipe_name] data = None if data_name == "Scan": data = dataset.data @@ -237,7 +240,7 @@ def updateDisplayableResults(self): self.gui.variable_y_comboBox.clear() return None - try: data = utilities.formatData(data) + try: data = data_to_dataframe(data) except AssertionError: # if np.ndarray of string for example self.gui.variable_x_comboBox.clear() self.gui.variable_x2_comboBox.clear() @@ -289,8 +292,7 @@ def updateDisplayableResults(self): class Dataset(): - """ Collection of data from a scan """ - + """ Collection of data from a recipe """ def __init__(self, folder_dataset_temp: str, recipe_name: str, config: dict, save_temp: bool = True): self._data_temp = [] @@ -334,7 +336,7 @@ def __init__(self, folder_dataset_temp: str, recipe_name: str, config: dict, ) self.data = pd.DataFrame(columns=self.header) - def getData(self, var_list: list, data_name: str = "Scan", + def getData(self, var_list: List[str], data_name: str = "Scan", dataID: int = 0, filter_condition: List[dict] = []) -> pd.DataFrame: """ This function returns a dataframe with two columns : the parameter value, and the requested result value """ @@ -347,7 +349,7 @@ def getData(self, var_list: list, data_name: str = "Scan", and not isinstance(data, str) and (len(data.T.shape) == 1 or ( len(data.T.shape) != 0 and data.T.shape[0] == 2))): - data = utilities.formatData(data) + data = data_to_dataframe(data) else: # Image return data @@ -406,7 +408,13 @@ def save(self, filename: str): dest_folder = os.path.join(dataset_folder, array_name) if os.path.exists(tmp_folder): - shutil.copytree(tmp_folder, dest_folder, dirs_exist_ok=True) + try: + shutil.copytree(tmp_folder, dest_folder, + dirs_exist_ok=True) # python >=3.8 only + except: + if os.path.exists(dest_folder): + shutil.rmtree(dest_folder, ignore_errors=True) + shutil.copytree(tmp_folder, dest_folder) else: # This is only executed if no temp folder is set if not os.path.exists(dest_folder): os.mkdir(dest_folder) @@ -467,6 +475,12 @@ def addPoint(self, dataPoint: OrderedDict): self.data = pd.DataFrame(self._data_temp, columns=self.header) if self.save_temp: + if not os.path.exists(self.folder_dataset_temp): + print(f'Warning: {self.folder_dataset_temp} has been created ' \ + 'but should have been created earlier. ' \ + 'Check that you have not lost any data', + file=sys.stderr) + os.mkdir(self.folder_dataset_temp) if ID == 1: self.data.tail(1).to_csv( os.path.join(self.folder_dataset_temp, 'data.txt'), @@ -479,3 +493,11 @@ def addPoint(self, dataPoint: OrderedDict): def __len__(self): """ Returns the number of data point of this dataset """ return len(self.data) + + +class ScanSet(dict): + """ Collection of data from a scan """ + # TODO: use this in scan plot + display = True + color = 'default' + saved = False diff --git a/autolab/core/gui/scanning/figure.py b/autolab/core/gui/scanning/figure.py index 86e9a626..31d2031a 100644 --- a/autolab/core/gui/scanning/figure.py +++ b/autolab/core/gui/scanning/figure.py @@ -11,14 +11,16 @@ import numpy as np import pandas as pd import pyqtgraph as pg -from qtpy import QtWidgets, QtGui, QtCore +import pyqtgraph.exporters # Needed for pg.exporters.ImageExporter +from qtpy import QtWidgets, QtCore from .display import DisplayValues +from ..GUI_instances import openPlotter from ..GUI_utilities import (get_font_size, setLineEditBackground, pyqtgraph_fig_ax, pyqtgraph_image) -from ..slider import Slider -from ..variables import Variable +from ..GUI_slider import Slider from ..icons import icons +from ...variables import Variable if hasattr(pd.errors, 'UndefinedVariableError'): @@ -38,7 +40,7 @@ def __init__(self, gui: QtWidgets.QMainWindow): self.curves = [] self.filter_condition = [] - self._font_size = get_font_size() + 1 + self._font_size = get_font_size() # Configure and initialize the figure in the GUI self.fig, self.ax = pyqtgraph_fig_ax() @@ -48,11 +50,11 @@ def __init__(self, gui: QtWidgets.QMainWindow): self.figMap.hide() self.gui.variable_x_comboBox.activated.connect( - self.variableChanged) + self.axisChanged) self.gui.variable_x2_comboBox.activated.connect( - self.variableChanged) + self.axisChanged) self.gui.variable_y_comboBox.activated.connect( - self.variableChanged) + self.axisChanged) pgv = pg.__version__.split('.') if int(pgv[0]) == 0 and int(pgv[1]) < 12: @@ -77,11 +79,17 @@ def __init__(self, gui: QtWidgets.QMainWindow): self.gui.nbTraces_lineEdit, 'synced', self._font_size) # Window to show scan data + self.gui.displayScanData_pushButton.setIcon(icons['DataFrame']) self.gui.displayScanData_pushButton.clicked.connect( self.displayScanDataButtonClicked) self.gui.displayScanData_pushButton.hide() self.displayScan = DisplayValues("Scan", size=(500, 300)) - self.displayScan.setWindowIcon(QtGui.QIcon(icons['DataFrame'])) + self.displayScan.setWindowIcon(icons['DataFrame']) + + self.gui.sendScanData_pushButton.setIcon(icons['plotter']) + self.gui.sendScanData_pushButton.clicked.connect( + self.sendScanDataButtonClicked) + self.gui.sendScanData_pushButton.hide() # comboBox with scan id self.gui.data_comboBox.activated.connect(self.data_comboBoxClicked) @@ -114,8 +122,9 @@ def refresh_filters(self): self.filter_condition.clear() if self.gui.checkBoxFilter.isChecked(): - for i in range(self.gui.layoutFilter.count()-1): # last is buttons - layout = self.gui.layoutFilter.itemAt(i).layout() + # OPTIMIZE: Should never based code on widget/layout position. Can instead create dict or other with all information about filter widgets + for i in range(self.gui.layoutFilter.count()): + layout = self.gui.layoutFilter.itemAt(i).widget().layout() if layout.count() == 5: enable = bool(layout.itemAt(0).widget().isChecked()) @@ -164,58 +173,13 @@ def refresh_filters(self): self.filter_condition.append(filter_i) - # Change minimum size - min_width = 6 - min_height = 6 - - for i in range(self.gui.layoutFilter.count()): - layout = self.gui.layoutFilter.itemAt(i).layout() - min_width_temp = 6 - min_height_temp = 6 - - for j in range(layout.count()): - item = layout.itemAt(j) - widget = item.widget() - - if widget is not None: - min_size = widget.minimumSizeHint() - - min_width_temp_2 = min_size.width() - min_height_temp_2 = min_size.height() - - if min_width_temp_2 == 0: min_width_temp_2 = 21 - if min_height_temp_2 == 0: min_height_temp_2 = 21 - - min_width_temp += min_width_temp_2 + 6 - min_height_temp_2 += min_height_temp_2 + 6 - - if min_height_temp_2 > min_height_temp: - min_height_temp = min_height_temp_2 - - min_height += min_height_temp - - if min_width_temp > min_width: - min_width = min_width_temp - - min_width += 12 - - if min_width > 500: min_width = 500 - if min_height < 85: min_height = 85 - if min_height > 210: min_height = 210 - - self.gui.frameAxis.setMinimumHeight(min_height) - self.gui.scrollArea_filter.setMinimumWidth(min_width) - else: - self.gui.frameAxis.setMinimumHeight(65) - self.gui.scrollArea_filter.setMinimumWidth(0) - self.reloadData() def refresh_filter_combobox(self, comboBox): items = [] - for dataset in self.gui.dataManager.datasets: - for recipe in dataset.values(): - for key in recipe.data.columns: + for scanset in self.gui.dataManager.datasets: + for dataset in scanset.values(): + for key in dataset.data.columns: if key not in items: items.append(key) @@ -226,11 +190,11 @@ def refresh_filter_combobox(self, comboBox): def addFilterClicked(self, filter_type): """ Add filter condition """ - conditionLayout = QtWidgets.QHBoxLayout() + conditionWidget = QtWidgets.QFrame() + conditionWidget.setFrameShape(QtWidgets.QFrame.StyledPanel) + conditionLayout = QtWidgets.QHBoxLayout(conditionWidget) filterCheckBox = QtWidgets.QCheckBox() - filterCheckBox.setMinimumSize(0, 21) - filterCheckBox.setMaximumSize(16777215, 21) filterCheckBox.setToolTip('Toggle filter') filterCheckBox.setCheckState(QtCore.Qt.Checked) filterCheckBox.stateChanged.connect(self.refresh_filters) @@ -238,8 +202,6 @@ def addFilterClicked(self, filter_type): if filter_type in ('standard', 'slider'): variableComboBox = QtWidgets.QComboBox() - variableComboBox.setMinimumSize(0, 21) - variableComboBox.setMaximumSize(16777215, 21) self.refresh_filter_combobox(variableComboBox) variableComboBox.activated.connect(self.refresh_filters) @@ -248,8 +210,6 @@ def addFilterClicked(self, filter_type): conditionLayout.addWidget(variableComboBox) filterComboBox = QtWidgets.QComboBox() - filterComboBox.setMinimumSize(0, 21) - filterComboBox.setMaximumSize(16777215, 21) items = ['==', '!=', '<', '<=', '>=', '>'] filterComboBox.addItems(items) filterComboBox.activated.connect(self.refresh_filters) @@ -257,8 +217,6 @@ def addFilterClicked(self, filter_type): if filter_type == 'standard': valueWidget = QtWidgets.QLineEdit() - valueWidget.setMinimumSize(0, 21) - valueWidget.setMaximumSize(16777215, 21) valueWidget.setText('1') valueWidget.returnPressed.connect(self.refresh_filters) valueWidget.textEdited.connect(lambda: setLineEditBackground( @@ -268,18 +226,15 @@ def addFilterClicked(self, filter_type): elif filter_type == 'slider': var = Variable('temp', 1.) valueWidget = Slider(var) - min_size = valueWidget.minimumSizeHint() - valueWidget.setMinimumSize(min_size) - valueWidget.setMaximumSize(min_size) valueWidget.minWidget.setText('1.0') valueWidget.minWidgetValueChanged() valueWidget.changed.connect(self.refresh_filters) + valueWidget.setSizePolicy(QtWidgets.QSizePolicy.Preferred, + QtWidgets.QSizePolicy.Fixed) conditionLayout.addWidget(valueWidget) elif filter_type == 'custom': customConditionWidget = QtWidgets.QLineEdit() - customConditionWidget.setMinimumSize(0, 21) - customConditionWidget.setMaximumSize(16777215, 21) customConditionWidget.setToolTip( "Filter condition can be 'id == 1' '1 <= amplitude <= 2' 'id in (1, 2)'") customConditionWidget.setText('id == 1') @@ -291,22 +246,19 @@ def addFilterClicked(self, filter_type): conditionLayout.addWidget(customConditionWidget) removePushButton = QtWidgets.QPushButton() - removePushButton.setMinimumSize(0, 21) - removePushButton.setMaximumSize(16777215, 21) - removePushButton.setIcon(QtGui.QIcon(icons['remove'])) + removePushButton.setIcon(icons['remove']) removePushButton.clicked.connect( - lambda: self.remove_filter(conditionLayout)) + lambda: self.remove_filter(conditionWidget)) conditionLayout.addWidget(removePushButton) - self.gui.layoutFilter.insertLayout( - self.gui.layoutFilter.count()-1, conditionLayout) + self.gui.layoutFilter.addWidget(conditionWidget) self.refresh_filters() - def remove_filter(self, layout): + def remove_filter(self, widget): """ Remove filter condition """ - for j in reversed(range(layout.count())): - layout.itemAt(j).widget().setParent(None) - layout.setParent(None) + for j in reversed(range(widget.layout().count())): + widget.layout().itemAt(j).widget().setParent(None) + widget.setParent(None) self.refresh_filters() def checkBoxFilterChanged(self): @@ -324,10 +276,10 @@ def data_comboBoxClicked(self): """ This function select a dataset """ if len(self.gui.dataManager.datasets) != 0: self.gui.data_comboBox.show() - dataset = self.gui.dataManager.getLastSelectedDataset() + scanset = self.gui.dataManager.getLastSelectedDataset() index = self.gui.scan_recipe_comboBox.currentIndex() - result_names = list(dataset) + result_names = list(scanset) items = [self.gui.scan_recipe_comboBox.itemText(i) for i in range( self.gui.scan_recipe_comboBox.count())] @@ -371,14 +323,14 @@ def updateDataframe_comboBox(self): # Executed each time the queue is read index = self.gui.dataframe_comboBox.currentIndex() recipe_name = self.gui.scan_recipe_comboBox.currentText() - dataset = self.gui.dataManager.getLastSelectedDataset() + scanset = self.gui.dataManager.getLastSelectedDataset() - if dataset is None or recipe_name not in dataset: return None + if scanset is None or recipe_name not in scanset: return None - sub_dataset = dataset[recipe_name] + dataset = scanset[recipe_name] result_names = ["Scan"] + [ - i for i, val in sub_dataset.data_arrays.items() if not isinstance( + i for i, val in dataset.data_arrays.items() if not isinstance( val[0], (str, tuple))] # Remove this condition if want to plot string or tuple: Tuple[List[str], int] items = [self.gui.dataframe_comboBox.itemText(i) for i in range( @@ -402,7 +354,8 @@ def setLabel(self, axe: str, value: str): """ This function changes the label of the given axis """ axes = {'x':'bottom', 'y':'left'} if value == '': value = ' ' - self.ax.setLabel(axes[axe], value, **{'color':0.4, 'font-size': '12pt'}) + self.ax.setLabel(axes[axe], value, **{'color': pg.getConfigOption("foreground"), + 'font-size': '12pt'}) # PLOT DATA ########################################################################### @@ -461,10 +414,9 @@ def reloadData(self): else: var_to_display = [variable_x, variable_y] - can_filter = var_to_display != ['', ''] # Allows to differentiate images from scan or arrays. Works only because on dataframe_comboBoxCurrentChanged, updateDisplayableResults is called + can_filter = var_to_display not in (['', ''], ['', '', '']) # Allows to differentiate images from scan or arrays. Works only because on dataframe_comboBoxCurrentChanged, updateDisplayableResults is called filter_condition = self.filter_condition if ( - self.gui.checkBoxFilter.isChecked() and can_filter) else {} - + self.gui.checkBoxFilter.isChecked() and can_filter) else [] data: List[pd.DataFrame] = self.gui.dataManager.getData( nbtraces_temp, var_to_display, selectedData=selectedData, data_name=data_name, @@ -479,6 +431,7 @@ def reloadData(self): self.refreshDisplayScanData() if not self.gui.displayScanData_pushButton.isVisible(): self.gui.displayScanData_pushButton.show() + self.gui.sendScanData_pushButton.show() for temp_data in data: if temp_data is not None: break @@ -487,6 +440,9 @@ def reloadData(self): # If plot scan as image if data_name == "Scan" and self.displayed_as_image: + if not self.gui.frameAxis.isVisible(): + self.gui.frameAxis.show() + if not self.gui.variable_x2_comboBox.isVisible(): self.gui.variable_x2_comboBox.show() self.gui.label_scan_2D.show() @@ -628,18 +584,23 @@ def reloadData(self): color = 'r' alpha = 1 else: - color = 'k' + color = pg.getConfigOption("foreground") alpha = (true_nbtraces - (len(data) - 1 - i)) / true_nbtraces + # TODO: no information about dataset nor scanset in this method! See what could be done + # if scanset.color != 'default': + # color = scanset.color + # Plot # careful, now that can filter data, need .values to avoid pyqtgraph bug + # pyqtgraph 0.11.1 raise hover error if plot deleted curve = self.ax.plot(x.values, y.values, symbol='x', symbolPen=color, symbolSize=10, pen=color, symbolBrush=color) curve.setAlpha(alpha, False) self.curves.append(curve) - def variableChanged(self, index): + def axisChanged(self, index): """ This function is called when the displayed result has been changed in the combo box. It proceeds to the change. """ if (self.gui.variable_x_comboBox.currentIndex() != -1 @@ -674,19 +635,29 @@ def nbTracesChanged(self): ########################################################################### def refreshDisplayScanData(self): recipe_name = self.gui.scan_recipe_comboBox.currentText() - datasets = self.gui.dataManager.getLastSelectedDataset() - if datasets is not None and recipe_name in datasets: + scanset = self.gui.dataManager.getLastSelectedDataset() + if scanset is not None and recipe_name in scanset: name = f"Scan{self.gui.data_comboBox.currentIndex()+1}" if self.gui.scan_recipe_comboBox.count() > 1: name += f", {recipe_name}" self.displayScan.setWindowTitle(name) - self.displayScan.refresh(datasets[recipe_name].data) + self.displayScan.refresh(scanset[recipe_name].data) def displayScanDataButtonClicked(self): - """ This function opens a window showing the scan data for the displayed scan id """ + """ Opens a window showing the scan data for the displayed scan id """ self.refreshDisplayScanData() self.displayScan.show() + def sendScanDataButtonClicked(self): + """ Sends the displayer scan data to plotter """ + recipe_name = self.gui.scan_recipe_comboBox.currentText() + scanset = self.gui.dataManager.getLastSelectedDataset() + if scanset is not None and recipe_name in scanset: + data = scanset[recipe_name].data + scan_name = self.gui.data_comboBox.currentText() + data.name = f"{scan_name}_{recipe_name}" + openPlotter(variable=scanset[recipe_name].data, has_parent=True) + # SAVE FIGURE ########################################################################### diff --git a/autolab/core/gui/scanning/interface.ui b/autolab/core/gui/scanning/interface.ui index e558fbab..22f78151 100644 --- a/autolab/core/gui/scanning/interface.ui +++ b/autolab/core/gui/scanning/interface.ui @@ -6,22 +6,24 @@ 0 0 - 1154 + 1214 740 - - - 9 - - - - - 9 - - - + + + 0 + + + 0 + + + 0 + + + 0 + @@ -32,6 +34,12 @@ 0 + + 0 + + + 0 + 0 @@ -63,11 +71,11 @@ 0 0 - 362 - 543 + 379 + 589 - + 0 @@ -80,9 +88,6 @@ 0 - - - @@ -98,97 +103,84 @@ QFrame::Raised - + - 0 + 3 - 0 + 3 - 0 + 3 - 0 + 3 - - - - - Add recipe - - - - - - - Qt::Horizontal - - - - 0 - 20 - - - - - - - - Edit: - - - - - - - Name of recipe that receive variables from Control Panel - - - QComboBox::AdjustToContents - - - - - - - Name of parameter that receive variables from Control Panel - - - QComboBox::AdjustToContents - - - - + + + Add recipe + + + + + + + Qt::Horizontal + + + + 82 + 20 + + + + + + + + Edit: + + + + + + + Name of recipe that receive variables from Control Panel + + + QComboBox::AdjustToContents + + + + + + + Name of parameter that receive variables from Control Panel + + + QComboBox::AdjustToContents + + - - - 0 - 100 - - - - - 16777215 - 100 - - QFrame::StyledPanel + + 9 + - Start (or stop) the scan + Start the scan Start @@ -197,14 +189,6 @@ - - true - - - - 9 - - Pause (or resume) the scan @@ -214,22 +198,12 @@ - - - Clear any recorded data - - - Clear data - - - - - + - Save data of the selected scan + Stop the scan - Save + Stop @@ -280,591 +254,573 @@ ArrowCursor - + + QFrame::StyledPanel + + + QFrame::Sunken + + + + 0 + 0 + + 0 + + + 0 + 0 - + Qt::Vertical - + + + + + + + 0 + 0 + + - QFrame::StyledPanel + QFrame::NoFrame - + + + 25 + + + 12 + - - - - - Select the scan to display - - - - - - - QComboBox::AdjustToContents - - - - - - - QComboBox::AdjustToContents - - - + + + Qt::Horizontal + + + QSizePolicy::Maximum + + + + 0 + 20 + + + + + + + + 9 + - - - Display scan data in tabular format - - - Scan data + + + 0 - + + + + + 75 + true + + + + X axis + + + Qt::AlignBottom|Qt::AlignHCenter + + + 5 + + + + + + + QComboBox::AdjustToContents + + + + - - - Qt::Horizontal + + + 0 - - - 0 - 20 - - - + + + + + 75 + true + + + + Y axis + + + Qt::AlignBottom|Qt::AlignHCenter + + + 5 + + + + + + + QComboBox::AdjustToContents + + + + - - - Qt::Vertical + + + 9 - - - - - - - 0 - 65 - - - - QFrame::StyledPanel - - + + + + 0 + - - - 0 + + + QFrame::StyledPanel + + + QAbstractScrollArea::AdjustToContents - - - + + true + + + + + 0 + 0 + 324 + 586 + + + + 0 - - - - - 75 - true - - - - X axis - - - Qt::AlignBottom|Qt::AlignHCenter - - - 5 - - - - - - - QComboBox::AdjustToContents - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Maximum + + 0 - - - 10 - 20 - + + 0 - - - - - + 0 - - - - 75 - true - - - - Y axis - - - Qt::AlignBottom|Qt::AlignHCenter - - - 5 - - - - - - - QComboBox::AdjustToContents + + + 8 - - - - - - - - Qt::Horizontal - - - - 0 - 20 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Minimum - - - - 5 - 20 - - - - - - - - - - Qt::Vertical + + 8 - - - 20 - 5 - + + 8 - - - - - - Filter data - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 8 - - - - - - - - - 2 - 2 - - - - QFrame::StyledPanel - - - true - - - + + - 0 + 6 - 0 + 6 - 0 + 6 - 0 + 6 + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + - - - - - - - - QFrame::NoFrame - - - QFrame::Plain - - - - 6 - - - 6 - - - 6 - - - 6 - - - - - - - - 0 - 21 - - - - - 100 - 21 - - - - Add basic - - - - - - - - 0 - 21 - - - - - 100 - 21 - - - - Add slider - - - - - - - - 0 - 21 - - - - - 100 - 21 - - - - Add custom - - - - - - - Qt::Horizontal - - - - 0 - 20 - - - - - - - - - - + + + Add basic + + + + + + + Add slider + + + + + + + Add custom + + - - + + - - - - - Qt::Horizontal - - - QSizePolicy::Minimum - - - - 5 - 20 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Maximum - - - - 15 - 20 - - - - - - - - - - Qt::Vertical - - - - 20 - 0 - - - - - - - - Show scan data as 2D colormesh - - - 2D plot - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 8 - - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Maximum - - - - 20 - 20 - - - - - - - - 0 - - - - - Nb traces - - - Qt::AlignBottom|Qt::AlignHCenter - - - 5 - - - - - - - - 0 - 0 - - - - - 70 - 16777215 - - - - Number of visible traces - - - 1 - - - Qt::AlignCenter - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Minimum - - - - 5 - 20 - - - - - - - - Qt::Horizontal - - - - 0 - 20 - - - - - - - - 0 - - - - - - 75 - true - - - - Y axis - - - Qt::AlignBottom|Qt::AlignHCenter - - - 5 - - - - - - - QComboBox::AdjustToContents - - - - - - + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 8 + + + + + + + + Filter data + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 8 + + + - - + + + + + + + Qt::Vertical + + + + 20 + 8 + + + + + + + + Show scan data as 2D colormesh + + + 2D plot + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 8 + + + + + + + + + + 0 + + + + + Nb traces + + + Qt::AlignBottom|Qt::AlignHCenter + + + 5 + + + + + + + + 0 + 0 + + + + + 100 + 16777215 + + + + Number of visible traces + + + 1 + + + Qt::AlignCenter + + + + + + + + + + + 0 + + + + + + 75 + true + + + + Y axis + + + Qt::AlignBottom|Qt::AlignHCenter + + + 5 + + + + + + + QComboBox::AdjustToContents + + + + + + + + Qt::Horizontal + + + + + + + QFrame::NoFrame + + + + 6 + + + 6 + + + 6 + + + 6 + + + 6 + + + + + Clear any recorded data + + + Clear all + + + + + + + Save data of all scans + + + Save all + + + + + + + Save data of the selected scan + + + Save scan1 + + + + + + + + 10 + 0 + + + + Qt::Vertical + + + + + + + Select the scan to display + + + QComboBox::AdjustToContents + + + + + + + QComboBox::AdjustToContents + + + + + + + QComboBox::AdjustToContents + + + + + + + Display scan data in tabular format + + + Scan data + + + + + + + Send scan recipe dataframe to Plotter + + + Send to Plotter + + + + + + + Qt::Horizontal + + + + 0 + 20 + + + + + + + @@ -877,18 +833,33 @@ 0 0 - 1154 + 1214 21 + scrollArea + addRecipe_pushButton + selectRecipe_comboBox + selectParameter_comboBox start_pushButton - pause_pushButton - clear_pushButton - save_pushButton continuous_checkBox + data_comboBox + scan_recipe_comboBox + dataframe_comboBox + displayScanData_pushButton + variable_x_comboBox + variable_x2_comboBox + checkBoxFilter + scrollArea_filter + addFilterPushButton + addSliderFilterPushButton + addCustomFilterPushButton + variable_x2_checkBox + nbTraces_lineEdit + variable_y_comboBox diff --git a/autolab/core/gui/scanning/main.py b/autolab/core/gui/scanning/main.py index 5795d860..0668872c 100644 --- a/autolab/core/gui/scanning/main.py +++ b/autolab/core/gui/scanning/main.py @@ -4,6 +4,7 @@ @author: qchat """ +from typing import List import os import sys import shutil @@ -18,29 +19,30 @@ from .recipe import RecipeManager from .scan import ScanManager from .data import DataManager -from .. import variables from ..icons import icons -from ... import paths, utilities -from ... import config as autolab_config +from ..GUI_instances import openVariablesMenu, openPlotter +from ...paths import PATHS +from ...utilities import boolean, SUPPORTED_EXTENSION +from ...config import get_scanner_config, load_config, change_autolab_config class Scanner(QtWidgets.QMainWindow): - def __init__(self, mainGui: QtWidgets.QMainWindow): + def __init__(self, parent: QtWidgets.QMainWindow): - self.mainGui = mainGui + self.mainGui = parent # Configuration of the window super().__init__() ui_path = os.path.join(os.path.dirname(__file__), 'interface.ui') uic.loadUi(ui_path, self) self.setWindowTitle("AUTOLAB - Scanner") - self.setWindowIcon(QtGui.QIcon(icons['scanner'])) + self.setWindowIcon(icons['scanner']) self.splitter.setSizes([500, 700]) # Set the width of the two main widgets self.setAcceptDrops(True) self.recipeDict = {} - self.variablesMenu = None self._append = False # option for import config + self._copy_step_info = None # used by tree (in recipe) for copy paste step # Loading of the different centers self.figureManager = FigureManager(self) @@ -52,8 +54,9 @@ def __init__(self, mainGui: QtWidgets.QMainWindow): configMenu = self.menuBar.addMenu('Configuration') self.importAction = configMenu.addAction('Import configuration') - self.importAction.setIcon(QtGui.QIcon(icons['import'])) + self.importAction.setIcon(icons['import']) self.importAction.triggered.connect(self.importActionClicked) + self.importAction.setStatusTip("Import configuration file") self.openRecentMenu = configMenu.addMenu('Import recent configuration') self.populateOpenRecent() @@ -61,49 +64,82 @@ def __init__(self, mainGui: QtWidgets.QMainWindow): configMenu.addSeparator() exportAction = configMenu.addAction('Export current configuration') - exportAction.setIcon(QtGui.QIcon(icons['export'])) + exportAction.setIcon(icons['export']) exportAction.triggered.connect(self.exportActionClicked) + exportAction.setStatusTip("Export current configuration file") # Edition menu editMenu = self.menuBar.addMenu('Edit') self.undo = editMenu.addAction('Undo') - self.undo.setIcon(QtGui.QIcon(icons['undo'])) + self.undo.setIcon(icons['undo']) + self.undo.setShortcut(QtGui.QKeySequence("Ctrl+Z")) self.undo.triggered.connect(self.configManager.undoClicked) self.undo.setEnabled(False) + self.undo.setStatusTip("Revert recipe changes") self.redo = editMenu.addAction('Redo') - self.redo.setIcon(QtGui.QIcon(icons['redo'])) + self.redo.setIcon(icons['redo']) + self.redo.setShortcut(QtGui.QKeySequence("Ctrl+Y")) self.redo.triggered.connect(self.configManager.redoClicked) self.redo.setEnabled(False) + self.redo.setStatusTip("Reapply recipe changes") - variablesMenuAction = self.menuBar.addAction('Variable') - variablesMenuAction.triggered.connect(self.openVariablesMenu) + guiMenu = self.menuBar.addMenu('Panels') + + plotAction = guiMenu.addAction('Plotter') + plotAction.setIcon(icons['plotter']) + plotAction.triggered.connect(lambda: openPlotter(has_parent=True)) + plotAction.setStatusTip('Open the plotter in another window') + + variablesMenuAction = guiMenu.addAction('Variables') + variablesMenuAction.setIcon(icons['variables']) + variablesMenuAction.triggered.connect(lambda: openVariablesMenu(True)) + variablesMenuAction.setStatusTip("Open the variable menu in another window") self.configManager.addRecipe("recipe") # add one recipe by default self.configManager.undoClicked() # avoid false history self.setStatus("") - self.addRecipe_pushButton.clicked.connect(lambda: self.configManager.addRecipe("recipe")) + self.addRecipe_pushButton.clicked.connect( + lambda: self.configManager.addRecipe("recipe")) self.selectRecipe_comboBox.activated.connect(self._updateSelectParameter) # Save button configuration self.save_pushButton.clicked.connect(self.saveButtonClicked) self.save_pushButton.setEnabled(False) + self.save_all_pushButton.clicked.connect(self.saveAllButtonClicked) + self.save_all_pushButton.setEnabled(False) # Clear button configuration self.clear_pushButton.clicked.connect(self.clear) + self.clear_pushButton.setEnabled(False) self.variable_x2_comboBox.hide() self.label_scan_2D.hide() + for splitter in (self.splitter, self.splitterGraph): + for i in range(splitter.count()): + handle = splitter.handle(i) + handle.setStyleSheet("background-color: #DDDDDD;") + handle.installEventFilter(self) + + def eventFilter(self, obj, event): + if event.type() == QtCore.QEvent.Enter: + obj.setStyleSheet("background-color: #AAAAAA;") # Hover color + elif event.type() == QtCore.QEvent.Leave: + obj.setStyleSheet("background-color: #DDDDDD;") # Normal color + return super().eventFilter(obj, event) + def populateOpenRecent(self): """ https://realpython.com/python-menus-toolbars/#populating-python-menus-dynamically """ self.openRecentMenu.clear() - if os.path.exists(paths.HISTORY_CONFIG): - with open(paths.HISTORY_CONFIG, 'r') as f: filenames = f.readlines() + if os.path.exists(PATHS['history_config']): + with open(PATHS['history_config'], 'r') as f: + filenames = f.readlines() for filename in reversed(filenames): filename = filename.rstrip('\n') action = QtWidgets.QAction(filename, self) + action.setIcon(icons['import']) action.setEnabled(os.path.exists(filename)) action.triggered.connect( partial(self.configManager.import_configPars, filename)) @@ -111,26 +147,30 @@ def populateOpenRecent(self): self.openRecentMenu.addSeparator() action = QtWidgets.QAction('Clear list', self) + action.setIcon(icons['remove']) action.triggered.connect(self.clearOpenRecent) self.openRecentMenu.addAction(action) def addOpenRecent(self, filename: str): - if not os.path.exists(paths.HISTORY_CONFIG): - with open(paths.HISTORY_CONFIG, 'w') as f: f.write(filename + '\n') + if not os.path.exists(PATHS['history_config']): + with open(PATHS['history_config'], 'w') as f: + f.write(filename + '\n') else: - with open(paths.HISTORY_CONFIG, 'r') as f: lines = f.readlines() + with open(PATHS['history_config'], 'r') as f: + lines = f.readlines() lines.append(filename) lines = [line.rstrip('\n')+'\n' for line in lines] lines = list(reversed(list(dict.fromkeys(reversed(lines))))) # unique names lines = lines[-10:] - with open(paths.HISTORY_CONFIG, 'w') as f: f.writelines(lines) + with open(PATHS['history_config'], 'w') as f: + f.writelines(lines) self.populateOpenRecent() def clearOpenRecent(self): - if os.path.exists(paths.HISTORY_CONFIG): - try: os.remove(paths.HISTORY_CONFIG) + if os.path.exists(PATHS['history_config']): + try: os.remove(PATHS['history_config']) except: pass self.populateOpenRecent() @@ -151,31 +191,32 @@ def clear(self): self.data_comboBox.clear() self.data_comboBox.hide() self.save_pushButton.setEnabled(False) - self.save_pushButton.setText('Save') + self.save_all_pushButton.setEnabled(False) + self.clear_pushButton.setEnabled(False) + self.save_pushButton.setText('Save scan1') self.progressBar.setValue(0) self.progressBar.setStyleSheet("") self.displayScanData_pushButton.hide() + self.sendScanData_pushButton.hide() self.dataframe_comboBox.clear() self.dataframe_comboBox.addItems(["Scan"]) self.dataframe_comboBox.hide() self.scan_recipe_comboBox.setCurrentIndex(0) self.scan_recipe_comboBox.hide() + self.refresh_widget(self.clear_pushButton) - def openVariablesMenu(self): - if self.variablesMenu is None: - self.variablesMenu = variables.VariablesMenu(self) - self.variablesMenu.show() - else: - self.variablesMenu.refresh() - - def clearVariablesMenu(self): - """ This clear the variables menu instance reference when quitted """ - self.variablesMenu = None + @staticmethod + def refresh_widget(widget: QtWidgets.QWidget): + """ Avoid intermediate disabled state when badly refreshed """ + widget.style().unpolish(widget) + widget.style().polish(widget) + widget.update() def _addRecipe(self, recipe_name: str): """ Adds recipe to managers. Called by configManager """ self._update_recipe_combobox() # recreate all and display first index - self.selectRecipe_comboBox.setCurrentIndex(self.selectRecipe_comboBox.count()-1) # display last index + self.selectRecipe_comboBox.setCurrentIndex( + self.selectRecipe_comboBox.count()-1) # display last index self.recipeDict[recipe_name] = {} # order of creation matter self.recipeDict[recipe_name]['recipeManager'] = RecipeManager(self, recipe_name) @@ -226,10 +267,12 @@ def _addParameter(self, recipe_name: str, param_name: str): self.recipeDict[recipe_name]['parameterManager'][param_name] = new_ParameterManager layoutAll = self.recipeDict[recipe_name]['recipeManager']._layoutAll - layoutAll.insertWidget(layoutAll.count()-1, new_ParameterManager.mainFrame) + layoutAll.insertWidget( + layoutAll.count()-1, new_ParameterManager.mainFrame, stretch=0) self._updateSelectParameter() - self.selectParameter_comboBox.setCurrentIndex(self.selectParameter_comboBox.count()-1) + self.selectParameter_comboBox.setCurrentIndex( + self.selectParameter_comboBox.count()-1) def _removeParameter(self, recipe_name: str, param_name: str): """ Removes parameter from managers. Called by configManager """ @@ -247,14 +290,17 @@ def _updateSelectParameter(self): self.selectParameter_comboBox.clear() if recipe_name != "": - self.selectParameter_comboBox.addItems(self.configManager.parameterNameList(recipe_name)) + self.selectParameter_comboBox.addItems( + self.configManager.parameterNameList(recipe_name)) self.selectParameter_comboBox.setCurrentIndex(prev_index) if self.selectParameter_comboBox.currentText() == "": - self.selectParameter_comboBox.setCurrentIndex(self.selectParameter_comboBox.count()-1) + self.selectParameter_comboBox.setCurrentIndex( + self.selectParameter_comboBox.count()-1) #Shows parameter combobox if multi parameters else hide - if recipe_name != "" and len(self.configManager.parameterList(recipe_name)) > 1: + if (recipe_name != "" + and len(self.configManager.parameterList(recipe_name)) > 1): self.selectParameter_comboBox.show() self.label_selectRecipeParameter.show() else: @@ -275,7 +321,8 @@ def _refreshParameterRange(self, recipe_name: str, param_name: str, recipeDictParam[newName].changeName(newName) recipeDictParam[newName].refresh() else: - print(f"Error: Can't refresh parameter '{param_name}', not found in recipeDictParam '{recipeDictParam}'") + print(f"Error: Can't refresh parameter '{param_name}', not found in recipeDictParam '{recipeDictParam}'", + file=sys.stderr) self._updateSelectParameter() @@ -306,13 +353,23 @@ def __init__(self, parent: QtWidgets.QMainWindow, append: bool): self.append = append + urls = [ + QtCore.QUrl.fromLocalFile(QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.HomeLocation)), + QtCore.QUrl.fromLocalFile(QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.DesktopLocation)), + QtCore.QUrl.fromLocalFile(QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.DocumentsLocation)), + QtCore.QUrl.fromLocalFile(QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.DownloadLocation)), + QtCore.QUrl.fromLocalFile(os.environ['TEMP']), + ] + layout = QtWidgets.QVBoxLayout(self) + self.setLayout(layout) file_dialog = QtWidgets.QFileDialog(self, QtCore.Qt.Widget) file_dialog.setOption(QtWidgets.QFileDialog.DontUseNativeDialog) + file_dialog.setSidebarUrls(urls) file_dialog.setWindowFlags(file_dialog.windowFlags() & ~QtCore.Qt.Dialog) - file_dialog.setDirectory(paths.USER_LAST_CUSTOM_FOLDER) - file_dialog.setNameFilters(["AUTOLAB configuration file (*.conf)", "All Files (*)"]) + file_dialog.setDirectory(PATHS['last_folder']) + file_dialog.setNameFilters(["AUTOLAB configuration file (*.conf)", "Any Files (*)"]) layout.addWidget(file_dialog) appendCheck = QtWidgets.QCheckBox('Append', self) @@ -344,7 +401,9 @@ def closeEvent(self, event): once_or_append = self._append and len(filenames) != 0 for filename in filenames: - if filename != '': self.configManager.import_configPars(filename, append=self._append) + if filename != '': + self.configManager.import_configPars( + filename, append=self._append) else: once_or_append = False @@ -355,68 +414,91 @@ def exportActionClicked(self): and export the current scan configuration in it """ filename = QtWidgets.QFileDialog.getSaveFileName( self, "Export AUTOLAB configuration file", - os.path.join(paths.USER_LAST_CUSTOM_FOLDER, 'config.conf'), + os.path.join(PATHS['last_folder'], 'config.conf'), "AUTOLAB configuration file (*.conf);;All Files (*)")[0] if filename != '': path = os.path.dirname(filename) - paths.USER_LAST_CUSTOM_FOLDER = path + PATHS['last_folder'] = path try: self.configManager.export(filename) - self.setStatus(f"Current configuration successfully saved at {filename}", 5000) + self.setStatus( + f"Current configuration successfully saved at {filename}", + 5000) except Exception as e: self.setStatus(f"An error occured: {str(e)}", 10000, False) else: self.addOpenRecent(filename) + def saveAllButtonClicked(self): + self._saveData(self.dataManager.datasets) + def saveButtonClicked(self): + self._saveData([self.dataManager.getLastSelectedDataset()]) + + def _saveData(self, all_data: List[dict]): """ This function is called when the save button is clicked. It asks a path and starts the procedure to save the data """ filename = QtWidgets.QFileDialog.getSaveFileName( self, caption="Save data", - directory=paths.USER_LAST_CUSTOM_FOLDER, - filter=utilities.SUPPORTED_EXTENSION)[0] + directory=PATHS['last_folder'], + filter=SUPPORTED_EXTENSION)[0] path = os.path.dirname(filename) + save_folder, extension = os.path.splitext(filename) + if path != '': - paths.USER_LAST_CUSTOM_FOLDER = path + PATHS['last_folder'] = path self.setStatus('Saving data...', 5000) - datasets = self.dataManager.getLastSelectedDataset() - - for dataset_name in datasets: - dataset = datasets[dataset_name] - - if len(datasets) == 1: - filename_recipe = filename - else: - dataset_folder, extension = os.path.splitext(filename) - filename_recipe = f'{dataset_folder}_{dataset_name}{extension}' - dataset.save(filename_recipe) - - scanner_config = autolab_config.get_scanner_config() - save_config = utilities.boolean(scanner_config["save_config"]) - if save_config: - dataset_folder, extension = os.path.splitext(filename) - new_configname = dataset_folder + ".conf" - config_name = os.path.join( - os.path.dirname(dataset.folder_dataset_temp), 'config.conf') + for scanset in all_data: + i = self.dataManager.datasets.index(scanset) + scan_name = f'scan{i+1}' - if os.path.exists(config_name): - shutil.copy(config_name, new_configname) + if len(all_data) == 1: + scan_filename = save_folder + new_configname = f'{save_folder}.conf' else: - if datasets is not self.dataManager.getLastDataset(): - print("Warning: Can't find config for this dataset, save latest config instead", file=sys.stderr) - self.configManager.export(new_configname) # BUG: it saves latest config instead of dataset config because no record available of previous config. (I did try to put back self.config to dataset but config changes with new dataset (copy doesn't help and deepcopy not possible) - - self.addOpenRecent(new_configname) - - if utilities.boolean(scanner_config["save_figure"]): - self.figureManager.save(filename) - - self.setStatus( - f'Last dataset successfully saved in {filename}', 5000) + scan_filename = f'{save_folder}_{scan_name}' + new_configname = f'{save_folder}_{scan_name}.conf' + + for recipe_name in scanset: + dataset = scanset[recipe_name] + + if len(scanset) == 1: + filename_recipe = f'{scan_filename}{extension}' + else: + filename_recipe = f'{scan_filename}_{recipe_name}{extension}' + dataset.save(filename_recipe) + + scanset.saved = True + scanner_config = get_scanner_config() + save_config = boolean(scanner_config["save_config"]) + + if save_config: + config_name = os.path.join( + os.path.dirname(dataset.folder_dataset_temp), 'config.conf') + + if os.path.exists(config_name): + shutil.copy(config_name, new_configname) + else: + if scanset is not self.dataManager.getLastDataset(): + print("Warning: Can't find config for this dataset, save latest config instead", + file=sys.stderr) + self.configManager.export(new_configname) # BUG: it saves latest config instead of dataset config because no record available of previous config. (I did try to put back self.config to dataset but config changes with new dataset (copy doesn't help and deepcopy not possible) + + self.addOpenRecent(new_configname) + + if len(all_data) == 1 and boolean(scanner_config["save_figure"]): + self.figureManager.save(filename) + + if len(all_data) == 1: + self.setStatus( + f'{scan_name} successfully saved in {filename}', 5000) + else: + self.setStatus( + f'All scans successfully saved as {save_folder}_[...].txt', 5000) def dropEvent(self, event): """ Imports config file if event has url of a file """ @@ -455,7 +537,29 @@ def closeEvent(self, event): # Stop datamanager timer self.dataManager.timer.stop() - # Delete reference of this window in the control center + scanner_config = get_scanner_config() + ask_close = boolean(scanner_config["ask_close"]) + if ask_close and not all([scanset.saved for scanset in self.dataManager.datasets]): + msg_box = QtWidgets.QMessageBox(self) + msg_box.setWindowTitle("Scanner") + msg_box.setText("Some data hasn't been saved, close scanner anyway?") + msg_box.setStandardButtons(QtWidgets.QMessageBox.Yes + | QtWidgets.QMessageBox.No) + + checkbox = QtWidgets.QCheckBox("Don't ask again") + msg_box.setCheckBox(checkbox) + + msg_box.show() + res = msg_box.exec_() + + autolab_config = load_config('autolab_config') + autolab_config['scanner']['ask_close'] = str(not checkbox.isChecked()) + change_autolab_config(autolab_config) + + if res == QtWidgets.QMessageBox.No: + event.ignore() # Prevent the window from closing + return None + self.mainGui.clearScanner() for recipe in self.recipeDict.values(): @@ -464,15 +568,12 @@ def closeEvent(self, event): self.figureManager.close() - for children in self.findChildren(QtWidgets.QWidget): - children.deleteLater() - # Remove scan variables from VARIABLES try: self.configManager.updateVariableConfig([]) except: pass - if self.variablesMenu is not None: - self.variablesMenu.close() + for children in self.findChildren(QtWidgets.QWidget): + children.deleteLater() super().closeEvent(event) diff --git a/autolab/core/gui/scanning/parameter.py b/autolab/core/gui/scanning/parameter.py index 5769b18f..20d19bd7 100644 --- a/autolab/core/gui/scanning/parameter.py +++ b/autolab/core/gui/scanning/parameter.py @@ -11,11 +11,12 @@ from qtpy import QtCore, QtWidgets, QtGui from .display import DisplayValues -from .customWidgets import parameterQFrame -from .. import variables -from ..GUI_utilities import get_font_size, setLineEditBackground +from .customWidgets import ParameterQFrame +from ..GUI_utilities import (get_font_size, setLineEditBackground, MyLineEdit, + MyQComboBox, qt_object_exists) from ..icons import icons from ...utilities import clean_string, str_to_array, array_to_str, create_array +from ...variables import has_eval, has_variable, eval_safely class ParameterManager: @@ -30,162 +31,147 @@ def __init__(self, gui: QtWidgets.QMainWindow, self.point_or_step = "point" - self._font_size = get_font_size() + 1 + self._font_size = get_font_size() - # Parameter frame - mainFrame = parameterQFrame(self.gui, self.recipe_name, self.param_name) - mainFrame.setFrameShape(QtWidgets.QFrame.StyledPanel) - mainFrame.setMinimumSize(0, 32+60) - mainFrame.setMaximumSize(16777215, 32+60) - self.mainFrame = mainFrame + self.init_ui() + # Do refresh at start + self.refresh() + + def init_ui(self): + # Parameter frame + self.mainFrame = ParameterQFrame( + self.gui, self.recipe_name, self.param_name) + self.mainFrame.setFrameShape(QtWidgets.QFrame.StyledPanel) self.mainFrame.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.mainFrame.customContextMenuRequested.connect(self.rightClick) + self.mainFrame.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - ## 1st row frame: Parameter - frameParameter = QtWidgets.QFrame(mainFrame) - frameParameter.setMinimumSize(0, 32) - frameParameter.setMaximumSize(16777215, 32) - frameParameter.setToolTip(f"Drag and drop a variable or use the right click option of a variable from the control panel to add a recipe to the tree: {self.recipe_name}") + # Parameter layout + parameterLayout = QtWidgets.QVBoxLayout(self.mainFrame) + parameterLayout.setContentsMargins(0,0,0,0) + parameterLayout.setSpacing(0) + frameParameter = QtWidgets.QFrame() + frameParameter.setToolTip( + "Drag and drop a variable or use the right click option of a " \ + "variable from the control panel to add a recipe to the tree: " \ + f"{self.recipe_name}") + + frameScanRange = QtWidgets.QFrame() + + parameterLayout.addWidget(frameParameter) + parameterLayout.addWidget(frameScanRange) + + ## 1st row frame: Parameter ### Name - parameterName_lineEdit = QtWidgets.QLineEdit('', frameParameter) - parameterName_lineEdit.setMinimumSize(0, 20) - parameterName_lineEdit.setMaximumSize(16777215, 20) - parameterName_lineEdit.setSizePolicy(QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)) - parameterName_lineEdit.setAlignment(QtCore.Qt.AlignCenter) - parameterName_lineEdit.setToolTip('Name of the parameter, as it will displayed in the data') - parameterName_lineEdit.textEdited.connect(lambda: setLineEditBackground( - parameterName_lineEdit, 'edited', self._font_size)) - parameterName_lineEdit.returnPressed.connect(self.nameChanged) - parameterName_lineEdit.setEnabled(False) - self.parameterName_lineEdit = parameterName_lineEdit + self.parameterName_lineEdit = QtWidgets.QLineEdit('') + self.parameterName_lineEdit.setSizePolicy( + QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)) + self.parameterName_lineEdit.setAlignment(QtCore.Qt.AlignCenter) + self.parameterName_lineEdit.setToolTip( + 'Name of the parameter, as it will displayed in the data') + self.parameterName_lineEdit.textEdited.connect( + lambda: setLineEditBackground( + self.parameterName_lineEdit, 'edited', self._font_size)) + self.parameterName_lineEdit.returnPressed.connect(self.nameChanged) + self.parameterName_lineEdit.setEnabled(False) ### Address - parameterAddressIndicator_label = QtWidgets.QLabel("Address:", frameParameter) - parameterAddressIndicator_label.setMinimumSize(0, 20) - parameterAddressIndicator_label.setMaximumSize(16777215, 20) - parameterAddressIndicator_label.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) - parameterAddress_label = QtWidgets.QLabel("", frameParameter) - parameterAddress_label.setMinimumSize(0, 20) - parameterAddress_label.setMaximumSize(16777215, 20) - parameterAddress_label.setAlignment(QtCore.Qt.AlignCenter) - parameterAddress_label.setToolTip('Address of the parameter') - self.parameterAddress_label = parameterAddress_label + parameterAddressIndicator_label = QtWidgets.QLabel("Address:") + parameterAddressIndicator_label.setAlignment( + QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) + self.parameterAddress_label = QtWidgets.QLabel("") + self.parameterAddress_label.setAlignment(QtCore.Qt.AlignCenter) + self.parameterAddress_label.setToolTip('Address of the parameter') ### Unit - unit_label = QtWidgets.QLabel("uA", frameParameter) - unit_label.setMinimumSize(0, 20) - unit_label.setMaximumSize(16777215, 20) - unit_label.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter) - self.unit_label = unit_label + self.unit_label = QtWidgets.QLabel("uA") + self.unit_label.setAlignment( + QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter) ### displayParameter button self.displayParameter = DisplayValues("Parameter", size=(250, 400)) - self.displayParameter.setWindowIcon(QtGui.QIcon(icons['ndarray'])) - displayParameter_pushButton = QtWidgets.QPushButton("Parameter", frameParameter) - displayParameter_pushButton.setMinimumSize(0, 23) - displayParameter_pushButton.setMaximumSize(16777215, 23) - self.displayParameter_pushButton = displayParameter_pushButton - self.displayParameter_pushButton.clicked.connect(self.displayParameterButtonClicked) + self.displayParameter.setWindowIcon(icons['ndarray']) + self.displayParameter_pushButton = QtWidgets.QPushButton("Parameter") + self.displayParameter_pushButton.setIcon(icons['ndarray']) + self.displayParameter_pushButton.clicked.connect( + self.displayParameterButtonClicked) ## 1sr row layout: Parameter layoutParameter = QtWidgets.QHBoxLayout(frameParameter) - layoutParameter.addWidget(parameterName_lineEdit) - layoutParameter.addWidget(unit_label) + layoutParameter.addWidget(self.parameterName_lineEdit) + layoutParameter.addWidget(self.unit_label) layoutParameter.addWidget(parameterAddressIndicator_label) - layoutParameter.addWidget(parameterAddress_label) - layoutParameter.addWidget(displayParameter_pushButton) - - frameScanRange = QtWidgets.QFrame(mainFrame) - frameScanRange.setMinimumSize(0, 60) - frameScanRange.setMaximumSize(16777215, 60) - self.frameScanRange = frameScanRange + layoutParameter.addWidget(self.parameterAddress_label) + layoutParameter.addWidget(self.displayParameter_pushButton) ## 2nd row frame: Range - frameScanRange_linLog = QtWidgets.QFrame(frameScanRange) - frameScanRange_linLog.setMinimumSize(0, 60) - frameScanRange_linLog.setMaximumSize(16777215, 60) - self.frameScanRange_linLog = frameScanRange_linLog + self.frameScanRange_linLog = QtWidgets.QFrame() ### first grid widgets: start, stop - labelStart = QtWidgets.QLabel("Start", frameScanRange_linLog) - start_lineEdit = QtWidgets.QLineEdit('0', frameScanRange_linLog) - start_lineEdit.setToolTip('Start value of the scan') - start_lineEdit.setMinimumSize(0, 20) - start_lineEdit.setMaximumSize(16777215, 20) - start_lineEdit.setAlignment(QtCore.Qt.AlignCenter) - self.start_lineEdit = start_lineEdit - - labelEnd = QtWidgets.QLabel("End", frameScanRange_linLog) - end_lineEdit = QtWidgets.QLineEdit('10', frameScanRange_linLog) - end_lineEdit.setMinimumSize(0, 20) - end_lineEdit.setMaximumSize(16777215, 20) - end_lineEdit.setToolTip('End value of the scan') - end_lineEdit.setAlignment(QtCore.Qt.AlignCenter) - self.end_lineEdit = end_lineEdit + labelStart = QtWidgets.QLabel("Start", self.frameScanRange_linLog) + self.start_lineEdit = QtWidgets.QLineEdit('0', self.frameScanRange_linLog) + self.start_lineEdit.setToolTip('Start value of the scan') + self.start_lineEdit.setAlignment(QtCore.Qt.AlignCenter) + + labelEnd = QtWidgets.QLabel("End", self.frameScanRange_linLog) + self.end_lineEdit = QtWidgets.QLineEdit('10', self.frameScanRange_linLog) + self.end_lineEdit.setToolTip('End value of the scan') + self.end_lineEdit.setAlignment(QtCore.Qt.AlignCenter) ### first grid layout: start, stop - startEndGridLayout = QtWidgets.QGridLayout(frameScanRange_linLog) + startEndGridLayout = QtWidgets.QGridLayout(self.frameScanRange_linLog) startEndGridLayout.addWidget(labelStart, 0, 0) - startEndGridLayout.addWidget(start_lineEdit, 0, 1) + startEndGridLayout.addWidget(self.start_lineEdit, 0, 1) startEndGridLayout.addWidget(labelEnd, 1, 0) - startEndGridLayout.addWidget(end_lineEdit, 1, 1) + startEndGridLayout.addWidget(self.end_lineEdit, 1, 1) - startEndGridWidget = QtWidgets.QWidget(frameScanRange_linLog) + startEndGridWidget = QtWidgets.QFrame(self.frameScanRange_linLog) startEndGridWidget.setLayout(startEndGridLayout) ### second grid widgets: mean, width - labelMean = QtWidgets.QLabel("Mean", frameScanRange_linLog) - mean_lineEdit = QtWidgets.QLineEdit('5', frameScanRange_linLog) - mean_lineEdit.setMinimumSize(0, 20) - mean_lineEdit.setMaximumSize(16777215, 20) - mean_lineEdit.setAlignment(QtCore.Qt.AlignCenter) - self.mean_lineEdit = mean_lineEdit - - labelWidth = QtWidgets.QLabel("Width", frameScanRange_linLog) - width_lineEdit = QtWidgets.QLineEdit('10', frameScanRange_linLog) - width_lineEdit.setMinimumSize(0, 20) - width_lineEdit.setMaximumSize(16777215, 20) - width_lineEdit.setAlignment(QtCore.Qt.AlignCenter) - self.width_lineEdit = width_lineEdit + labelMean = QtWidgets.QLabel("Mean", self.frameScanRange_linLog) + self.mean_lineEdit = QtWidgets.QLineEdit('5', self.frameScanRange_linLog) + self.mean_lineEdit.setAlignment(QtCore.Qt.AlignCenter) + + labelWidth = QtWidgets.QLabel("Width", self.frameScanRange_linLog) + self.width_lineEdit = QtWidgets.QLineEdit('10', self.frameScanRange_linLog) + self.width_lineEdit.setAlignment(QtCore.Qt.AlignCenter) ### second grid layout: mean, width - meanWidthGridLayout = QtWidgets.QGridLayout(frameScanRange_linLog) - meanWidthGridWidget = QtWidgets.QWidget(frameScanRange_linLog) - meanWidthGridWidget.setLayout(meanWidthGridLayout) + meanWidthGridLayout = QtWidgets.QGridLayout(self.frameScanRange_linLog) meanWidthGridLayout.addWidget(labelMean, 0, 0) - meanWidthGridLayout.addWidget(mean_lineEdit, 0, 1) + meanWidthGridLayout.addWidget(self.mean_lineEdit, 0, 1) meanWidthGridLayout.addWidget(labelWidth, 1, 0) - meanWidthGridLayout.addWidget(width_lineEdit, 1, 1) + meanWidthGridLayout.addWidget(self.width_lineEdit, 1, 1) + + meanWidthGridWidget = QtWidgets.QFrame(self.frameScanRange_linLog) + meanWidthGridWidget.setLayout(meanWidthGridLayout) ### third grid widgets: npts, step, log - labelNbpts = QtWidgets.QLabel("Nb points", frameScanRange_linLog) - nbpts_lineEdit = QtWidgets.QLineEdit('11', frameScanRange_linLog) - nbpts_lineEdit.setMinimumSize(0, 20) - nbpts_lineEdit.setMaximumSize(16777215, 20) - nbpts_lineEdit.setAlignment(QtCore.Qt.AlignCenter) - self.nbpts_lineEdit = nbpts_lineEdit - - labelStep = QtWidgets.QLabel("Step", frameScanRange_linLog) - self.labelStep = labelStep - step_lineEdit = QtWidgets.QLineEdit('1', frameScanRange_linLog) - step_lineEdit.setMinimumSize(0, 20) - step_lineEdit.setMaximumSize(16777215, 20) - step_lineEdit.setAlignment(QtCore.Qt.AlignCenter) - self.step_lineEdit = step_lineEdit + labelNbpts = QtWidgets.QLabel("Nb points", self.frameScanRange_linLog) + self.nbpts_lineEdit = QtWidgets.QLineEdit('11', self.frameScanRange_linLog) + self.nbpts_lineEdit.setAlignment(QtCore.Qt.AlignCenter) + + self.labelStep = QtWidgets.QLabel("Step", self.frameScanRange_linLog) + self.step_lineEdit = QtWidgets.QLineEdit('1', self.frameScanRange_linLog) + self.step_lineEdit.setAlignment(QtCore.Qt.AlignCenter) ### third grid layout: npts, step, log - nptsStepGridLayout = QtWidgets.QGridLayout(frameScanRange_linLog) - nptsStepGridWidget = QtWidgets.QWidget(frameScanRange_linLog) - nptsStepGridWidget.setLayout(nptsStepGridLayout) + nptsStepGridLayout = QtWidgets.QGridLayout(self.frameScanRange_linLog) nptsStepGridLayout.addWidget(labelNbpts, 0, 0) - nptsStepGridLayout.addWidget(nbpts_lineEdit, 0, 1) - nptsStepGridLayout.addWidget(labelStep, 1, 0) - nptsStepGridLayout.addWidget(step_lineEdit, 1, 1) + nptsStepGridLayout.addWidget(self.nbpts_lineEdit, 0, 1) + nptsStepGridLayout.addWidget(self.labelStep, 1, 0) + nptsStepGridLayout.addWidget(self.step_lineEdit, 1, 1) + + nptsStepGridWidget = QtWidgets.QFrame(self.frameScanRange_linLog) + nptsStepGridWidget.setLayout(nptsStepGridLayout) ## 2nd row layout: Range - layoutScanRange = QtWidgets.QHBoxLayout(frameScanRange_linLog) + layoutScanRange = QtWidgets.QHBoxLayout(self.frameScanRange_linLog) layoutScanRange.setContentsMargins(0,0,0,0) layoutScanRange.setSpacing(0) layoutScanRange.addWidget(startEndGridWidget) @@ -195,70 +181,62 @@ def __init__(self, gui: QtWidgets.QMainWindow, layoutScanRange.addWidget(nptsStepGridWidget) ## 2nd row bis frame: Values (hidden at start) - frameScanRange_values = QtWidgets.QFrame(frameScanRange) - frameScanRange_values.setMinimumSize(0, 60) - frameScanRange_values.setMaximumSize(16777215, 60) - self.frameScanRange_values = frameScanRange_values + self.frameScanRange_values = QtWidgets.QFrame() ### first grid widgets: values (hidden at start) - labelValues = QtWidgets.QLabel("Values", frameScanRange_values) - values_lineEdit = QtWidgets.QLineEdit('[0,1,2,3]', frameScanRange_values) - values_lineEdit.setToolTip('Values of the scan') - values_lineEdit.setMinimumSize(0, 20) - values_lineEdit.setMaximumSize(16777215, 20) - values_lineEdit.setAlignment(QtCore.Qt.AlignCenter) - values_lineEdit.setMaxLength(10000000) - self.values_lineEdit = values_lineEdit + labelValues = QtWidgets.QLabel("Values", self.frameScanRange_values) + self.values_lineEdit = MyLineEdit('[0,1,2,3]', self.frameScanRange_values) + self.values_lineEdit.setToolTip('Values of the scan') + self.values_lineEdit.setAlignment(QtCore.Qt.AlignCenter) + self.values_lineEdit.setMaxLength(10000000) # TODO: keep eval in values, show evaluated in evaluated - labelEvaluatedValues = QtWidgets.QLabel("Evaluated values", frameScanRange_values) - self.labelEvaluatedValues = labelEvaluatedValues - evaluatedValues_lineEdit = QtWidgets.QLineEdit('[0,1,2,3]', frameScanRange_values) - evaluatedValues_lineEdit.setToolTip('Evaluated values of the scan') - evaluatedValues_lineEdit.setMinimumSize(0, 20) - evaluatedValues_lineEdit.setMaximumSize(16777215, 20) - evaluatedValues_lineEdit.setAlignment(QtCore.Qt.AlignCenter) - evaluatedValues_lineEdit.setMaxLength(10000000) - evaluatedValues_lineEdit.setReadOnly(True) - evaluatedValues_lineEdit.setStyleSheet( - "QLineEdit {border: 1px solid #a4a4a4; background-color: #f4f4f4}") - self.evaluatedValues_lineEdit = evaluatedValues_lineEdit + self.labelEvaluatedValues = QtWidgets.QLabel( + "Evaluated values", self.frameScanRange_values) + self.evaluatedValues_lineEdit = QtWidgets.QLineEdit( + '[0,1,2,3]', self.frameScanRange_values) + self.evaluatedValues_lineEdit.setToolTip('Evaluated values of the scan') + self.evaluatedValues_lineEdit.setAlignment(QtCore.Qt.AlignCenter) + self.evaluatedValues_lineEdit.setMaxLength(10000000) + self.evaluatedValues_lineEdit.setReadOnly(True) + palette = self.evaluatedValues_lineEdit.palette() + palette.setColor( + QtGui.QPalette.Base, palette.color(QtGui.QPalette.Base).darker(107)) + self.evaluatedValues_lineEdit.setPalette(palette) ### first grid layout: values (hidden at start) - valuesGridLayout = QtWidgets.QGridLayout(frameScanRange_values) + valuesGridLayout = QtWidgets.QGridLayout(self.frameScanRange_values) valuesGridLayout.addWidget(labelValues, 0, 0) - valuesGridLayout.addWidget(values_lineEdit, 0, 1) - valuesGridLayout.addWidget(labelEvaluatedValues, 1, 0) - valuesGridLayout.addWidget(evaluatedValues_lineEdit, 1, 1) + valuesGridLayout.addWidget(self.values_lineEdit, 0, 1) + valuesGridLayout.addWidget(self.labelEvaluatedValues, 1, 0) + valuesGridLayout.addWidget(self.evaluatedValues_lineEdit, 1, 1) - valuesGridWidget = QtWidgets.QWidget(frameScanRange_values) + valuesGridWidget = QtWidgets.QFrame(self.frameScanRange_values) valuesGridWidget.setLayout(valuesGridLayout) ## 2nd row bis layout: Values (hidden at start) - layoutScanRange_values = QtWidgets.QHBoxLayout(frameScanRange_values) + layoutScanRange_values = QtWidgets.QHBoxLayout(self.frameScanRange_values) layoutScanRange_values.setContentsMargins(0,0,0,0) layoutScanRange_values.setSpacing(0) layoutScanRange_values.addWidget(valuesGridWidget) ## 3rd row frame: choice - frameScanRange_choice = QtWidgets.QFrame(frameScanRange) - frameScanRange_choice.setMinimumSize(0, 60) - frameScanRange_choice.setMaximumSize(16777215, 60) - self.frameScanRange_choice = frameScanRange_choice + self.frameScanRange_choice = QtWidgets.QFrame() ### first grid widgets: choice - comboBoxChoice = QtWidgets.QComboBox(frameScanRange_choice) - comboBoxChoice.addItems(['Linear', 'Log', 'Custom']) - self.comboBoxChoice = comboBoxChoice + self.comboBoxChoice = MyQComboBox(self.frameScanRange_choice) + self.comboBoxChoice.wheel = False + self.comboBoxChoice.key = False + self.comboBoxChoice.addItems(['Linear', 'Log', 'Custom']) ### first grid layout: choice - choiceGridLayout = QtWidgets.QGridLayout(frameScanRange_choice) - choiceGridLayout.addWidget(comboBoxChoice, 0, 0) + choiceGridLayout = QtWidgets.QGridLayout(self.frameScanRange_choice) + choiceGridLayout.addWidget(self.comboBoxChoice, 0, 0) - choiceGridWidget = QtWidgets.QWidget(frameScanRange_choice) + choiceGridWidget = QtWidgets.QFrame(self.frameScanRange_choice) choiceGridWidget.setLayout(choiceGridLayout) ## 3rd row layout: choice - layoutScanRange_choice = QtWidgets.QHBoxLayout(frameScanRange_choice) + layoutScanRange_choice = QtWidgets.QHBoxLayout(self.frameScanRange_choice) layoutScanRange_choice.setContentsMargins(0,0,0,0) layoutScanRange_choice.setSpacing(0) layoutScanRange_choice.addWidget(choiceGridWidget) @@ -266,16 +244,9 @@ def __init__(self, gui: QtWidgets.QMainWindow, scanRangeLayout = QtWidgets.QHBoxLayout(frameScanRange) scanRangeLayout.setContentsMargins(0,0,0,0) scanRangeLayout.setSpacing(0) - scanRangeLayout.addWidget(frameScanRange_linLog) - scanRangeLayout.addWidget(frameScanRange_values) # hidden at start - scanRangeLayout.addWidget(frameScanRange_choice) - - # Parameter layout - parameterLayout = QtWidgets.QVBoxLayout(mainFrame) - parameterLayout.setContentsMargins(0,0,0,0) - parameterLayout.setSpacing(0) - parameterLayout.addWidget(frameParameter) - parameterLayout.addWidget(frameScanRange) + scanRangeLayout.addWidget(self.frameScanRange_linLog) + scanRangeLayout.addWidget(self.frameScanRange_values) # hidden at start + scanRangeLayout.addWidget(self.frameScanRange_choice) # Widget 'return pressed' signal connections self.comboBoxChoice.activated.connect(self.scanRangeComboBoxChanged) @@ -303,9 +274,6 @@ def __init__(self, gui: QtWidgets.QMainWindow, self.values_lineEdit.textEdited.connect(lambda: setLineEditBackground( self.values_lineEdit,'edited', self._font_size)) - # Do refresh at start - self.refresh() - def _removeWidget(self): if hasattr(self, 'mainFrame'): try: @@ -344,18 +312,18 @@ def refresh(self): if self.gui.configManager.hasCustomValues(self.recipe_name, self.param_name): raw_values = self.gui.configManager.getValues(self.recipe_name, self.param_name) - str_raw_values = raw_values if variables.has_eval( + str_raw_values = raw_values if has_eval( raw_values) else array_to_str(raw_values) - if not variables.has_eval(raw_values): + if not has_eval(raw_values): str_values = str_raw_values self.evaluatedValues_lineEdit.hide() self.labelEvaluatedValues.hide() else: - values = variables.eval_safely(raw_values) + values = eval_safely(raw_values) try: values = create_array(values) except: str_values = values - else: str_values = variables.array_to_str(values) + else: str_values = array_to_str(values) self.evaluatedValues_lineEdit.show() self.labelEvaluatedValues.show() @@ -424,7 +392,7 @@ def refresh(self): f"Wrong format for parameter '{self.param_name}'", 10000, False) return None - str_values = variables.array_to_str(paramValues[self.param_name].values) + str_values = array_to_str(paramValues[self.param_name].values) self.evaluatedValues_lineEdit.setText(f'{str_values}') self.displayParameter.refresh(paramValues) @@ -436,7 +404,7 @@ def displayParameterButtonClicked(self): except Exception as e: self.gui.setStatus(f"Wrong format for parameter '{self.param_name}': {e}", 10000) return None - str_values = variables.array_to_str(paramValues[self.param_name].values) + str_values = array_to_str(paramValues[self.param_name].values) self.evaluatedValues_lineEdit.setText(f'{str_values}') self.displayParameter.refresh(paramValues) @@ -610,12 +578,12 @@ def valuesChanged(self): raw_values = self.values_lineEdit.text() try: - if not variables.has_eval(raw_values): + if not has_eval(raw_values): raw_values = str_to_array(raw_values) assert len(raw_values) != 0, "Cannot have empty array" values = raw_values - elif not variables.has_variable(raw_values): - values = variables.eval_safely(raw_values) + elif not has_variable(raw_values): + values = eval_safely(raw_values) if not isinstance(values, str): values = create_array(values) assert len(values) != 0, "Cannot have empty array" @@ -630,10 +598,10 @@ def rightClick(self, position: QtCore.QPoint): addAction = menu.addAction("Add parameter") - addAction.setIcon(QtGui.QIcon(icons['add'])) + addAction.setIcon(icons['add']) removeAction = menu.addAction(f"Remove {self.param_name}") - removeAction.setIcon(QtGui.QIcon(icons['remove'])) + removeAction.setIcon(icons['remove']) choice = menu.exec_(self.mainFrame.mapToGlobal(position)) @@ -647,14 +615,16 @@ def rightClick(self, position: QtCore.QPoint): def setProcessingState(self, state: str): """ Sets the background color of the parameter address during the scan """ + if not qt_object_exists(self.parameterAddress_label): + return None if state == 'idle': self.parameterAddress_label.setStyleSheet( - f"font-size: {self._font_size+1}pt;") + f"font-size: {self._font_size}pt;") else: if state == 'started': color = '#ff8c1a' if state == 'finished': color = '#70db70' self.parameterAddress_label.setStyleSheet( - f"background-color: {color}; font-size: {self._font_size+1}pt;") + f"background-color: {color}; font-size: {self._font_size}pt;") def close(self): """ Called by scanner on closing """ diff --git a/autolab/core/gui/scanning/recipe.py b/autolab/core/gui/scanning/recipe.py index 0df7fad2..7fcf0ca9 100644 --- a/autolab/core/gui/scanning/recipe.py +++ b/autolab/core/gui/scanning/recipe.py @@ -9,12 +9,13 @@ import pandas as pd from qtpy import QtCore, QtWidgets, QtGui -from .customWidgets import MyQTreeWidget, MyQTabWidget -from .. import variables +from .customWidgets import MyQTreeWidget, MyQTabWidget, MyQTreeWidgetItem from ..icons import icons -from ... import config +from ..GUI_utilities import MyInputDialog, qt_object_exists +from ...config import get_scanner_config +from ...variables import has_eval from ...utilities import (clean_string, str_to_array, array_to_str, - str_to_dataframe, dataframe_to_str) + str_to_dataframe, dataframe_to_str, str_to_tuple) class RecipeManager: @@ -22,32 +23,30 @@ class RecipeManager: def __init__(self, gui: QtWidgets.QMainWindow, recipe_name: str): - self.gui = gui + self.gui = gui # gui is scanner self.recipe_name = recipe_name # Import Autolab config - scanner_config = config.get_scanner_config() + scanner_config = get_scanner_config() self.precision = scanner_config['precision'] self.defaultItemBackground = None # Recipe frame frameRecipe = QtWidgets.QFrame() - # frameRecipe.setFrameShape(QtWidgets.QFrame.StyledPanel) # Tree configuration self.tree = MyQTreeWidget(frameRecipe, self.gui, self.recipe_name) - self.tree.setHeaderLabels(['Step name', 'Action', 'Element address', 'Type', 'Value', 'Unit']) + self.tree.setHeaderLabels(['Step name', 'Action', 'Element address', + 'Type', 'Value', 'Unit']) header = self.tree.header() header.setMinimumSectionSize(20) - # header.resizeSections(QtWidgets.QHeaderView.ResizeToContents) header.setStretchLastSection(False) header.resizeSection(0, 95) header.resizeSection(1, 55) header.resizeSection(2, 115) header.resizeSection(3, 35) header.resizeSection(4, 110) - # header.setSectionResizeMode(4, QtWidgets.QHeaderView.Stretch) header.resizeSection(5, 32) header.setMaximumSize(16777215, 16777215) self.tree.itemDoubleClicked.connect(self.itemDoubleClicked) @@ -56,7 +55,8 @@ def __init__(self, gui: QtWidgets.QMainWindow, recipe_name: str): self.tree.setDropIndicatorShown(True) self.tree.setAlternatingRowColors(True) self.tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - self.tree.setSizePolicy(QtWidgets.QSizePolicy.Ignored, QtWidgets.QSizePolicy.Ignored) + self.tree.setSizePolicy( + QtWidgets.QSizePolicy.Ignored, QtWidgets.QSizePolicy.Ignored) self.tree.customContextMenuRequested.connect(self.rightClick) self.tree.setMinimumSize(0, 200) self.tree.setMaximumSize(16777215, 16777215) @@ -67,12 +67,11 @@ def __init__(self, gui: QtWidgets.QMainWindow, recipe_name: str): # Qframe and QTab for close+parameter+scanrange+recipe frameAll = QtWidgets.QFrame() - # frameAll.setFrameShape(QtWidgets.QFrame.StyledPanel) layoutAll = QtWidgets.QVBoxLayout(frameAll) layoutAll.setContentsMargins(0,0,0,0) layoutAll.setSpacing(0) - layoutAll.addWidget(frameRecipe) + layoutAll.addWidget(frameRecipe, stretch=1) frameAll2 = MyQTabWidget(frameAll, self.gui, self.recipe_name) self._frame = frameAll2 @@ -94,7 +93,8 @@ def _activateTree(self, active: bool): self._frame.setTabEnabled(0, bool(active)) def orderChanged(self, event): - newOrder = [self.tree.topLevelItem(i).text(0) for i in range(self.tree.topLevelItemCount())] + newOrder = [self.tree.topLevelItem(i).text(0) + for i in range(self.tree.topLevelItemCount())] self.gui.configManager.setRecipeStepOrder(self.recipe_name, newOrder) def refresh(self): @@ -102,82 +102,11 @@ def refresh(self): self.tree.clear() for step in self.gui.configManager.stepList(self.recipe_name): - # Loading step informations - item = QtWidgets.QTreeWidgetItem() - item.setFlags(item.flags() ^ QtCore.Qt.ItemIsDropEnabled) - item.setToolTip(0, step['element']._help) - - # Column 1 : Step name - item.setText(0, step['name']) - - # OPTIMIZE: stepType is a bad name. Possible confusion with element type. stepType should be stepAction or just action - # Column 2 : Step type - if step['stepType'] == 'measure': - item.setText(1, 'Measure') - item.setIcon(0, QtGui.QIcon(icons['measure'])) - elif step['stepType'] == 'set': - item.setText(1, 'Set') - item.setIcon(0, QtGui.QIcon(icons['write'])) - elif step['stepType'] == 'action': - item.setText(1, 'Do') - item.setIcon(0, QtGui.QIcon(icons['action'])) - elif step['stepType'] == 'recipe': - item.setText(1, 'Recipe') - item.setIcon(0, QtGui.QIcon(icons['recipe'])) - - # Column 3 : Element address - if step['stepType'] == 'recipe': - item.setText(2, step['element']) - else: - item.setText(2, step['element'].address()) - - # Column 4 : Icon of element type - etype = step['element'].type - if etype is int: item.setIcon(3, QtGui.QIcon(icons['int'])) - elif etype is float: item.setIcon(3, QtGui.QIcon(icons['float'])) - elif etype is bool: item.setIcon(3, QtGui.QIcon(icons['bool'])) - elif etype is str: item.setIcon(3, QtGui.QIcon(icons['str'])) - elif etype is bytes: item.setIcon(3, QtGui.QIcon(icons['bytes'])) - elif etype is tuple: item.setIcon(3, QtGui.QIcon(icons['tuple'])) - elif etype is np.ndarray: item.setIcon(3, QtGui.QIcon(icons['ndarray'])) - elif etype is pd.DataFrame: item.setIcon(3, QtGui.QIcon(icons['DataFrame'])) - - # Column 5 : Value if stepType is 'set' - value = step['value'] - if value is not None: - if variables.has_eval(value): - item.setText(4, f'{value}') - else: - try: - if step['element'].type in [bool, str, tuple]: - item.setText(4, f'{value}') - elif step['element'].type in [np.ndarray]: - value = array_to_str( - value, threshold=1000000, max_line_width=100) - item.setText(4, f'{value}') - elif step['element'].type in [pd.DataFrame]: - value = dataframe_to_str(value, threshold=1000000) - item.setText(4, f'{value}') - else: - item.setText(4, f'{value:.{self.precision}g}') - except ValueError: - item.setText(4, f'{value}') - - # Column 6 : Unit of element - unit = step['element'].unit - if unit is not None: - item.setText(5, str(unit)) - - # set AlignTop to all columns - for i in range(item.columnCount()): - item.setTextAlignment(i, QtCore.Qt.AlignTop) - # OPTIMIZE: icon are not aligned with text: https://www.xingyulei.com/post/qt-button-alignment/index.html - - # Add item to the tree - self.tree.addTopLevelItem(item) + item = MyQTreeWidgetItem(self.tree, step, self) self.defaultItemBackground = item.background(0) + self.tree.setFocus() # needed for ctrl+z ctrl+y on recipe # toggle recipe active = bool(self.gui.configManager.getActive(self.recipe_name)) self.gui._activateRecipe(self.recipe_name, active) @@ -196,26 +125,47 @@ def rightClick(self, position: QtCore.QPoint): menuActions = {} menu = QtWidgets.QMenu() - menuActions['rename'] = menu.addAction("Rename") - menuActions['rename'].setIcon(QtGui.QIcon(icons['rename'])) + menuActions['copy'] = menu.addAction("Copy") + menuActions['copy'].setIcon(icons['copy']) + menuActions['copy'].setShortcut(QtGui.QKeySequence("Ctrl+C")) + menu.addSeparator() if stepType == 'set' or (stepType == 'action' and element.type in [ - int, float, str, np.ndarray, pd.DataFrame]): + int, float, bool, str, tuple, np.ndarray, pd.DataFrame]): menuActions['setvalue'] = menu.addAction("Set value") - menuActions['setvalue'].setIcon(QtGui.QIcon(icons['write'])) + menuActions['setvalue'].setIcon(icons['write']) menuActions['remove'] = menu.addAction("Remove") - menuActions['remove'].setIcon(QtGui.QIcon(icons['remove'])) + menuActions['remove'].setIcon(icons['remove']) + menuActions['remove'].setShortcut(QtGui.QKeySequence(QtCore.Qt.Key_Delete)) + + menuActions['rename'] = menu.addAction("Rename") + menuActions['rename'].setIcon(icons['rename']) + menuActions['rename'].setShortcut(QtGui.QKeySequence("Ctrl+R")) choice = menu.exec_(self.tree.viewport().mapToGlobal(position)) - if 'rename' in menuActions and choice == menuActions['rename']: + if choice == menuActions['copy']: + self.copyStep(name) + elif choice == menuActions['rename']: self.renameStep(name) - elif 'remove' in menuActions and choice == menuActions['remove']: - self.gui.configManager.delRecipeStep(self.recipe_name, name) + elif choice == menuActions['remove']: + self.removeStep(name) elif 'setvalue' in menuActions and choice == menuActions['setvalue']: self.setStepValue(name) - # else: # TODO: disabled this feature has it is not good in its current state + else: + menuActions = {} + menu = QtWidgets.QMenu() + menuActions['paste'] = menu.addAction("Paste") + menuActions['paste'].setIcon(icons['paste']) + menuActions['paste'].setShortcut(QtGui.QKeySequence("Ctrl+V")) + menuActions['paste'].setEnabled(self.gui._copy_step_info is not None) + choice = menu.exec_(self.tree.viewport().mapToGlobal(position)) + + if choice == menuActions['paste']: + self.pasteStep() + + # TODO: disabled this feature has it is not good in its current state # config = self.gui.configManager.config # if len(config) > 1: @@ -225,7 +175,7 @@ def rightClick(self, position: QtCore.QPoint): # menu = QtWidgets.QMenu() # for recipe_name in recipe_name_list: # menuActions[recipe_name] = menu.addAction(f'Add {recipe_name}') - # menuActions[recipe_name].setIcon(QtGui.QIcon(icons['recipe'])) + # menuActions[recipe_name].setIcon(icons['recipe']) # if len(recipe_name_list) != 0: # choice = menu.exec_(self.tree.viewport().mapToGlobal(position)) @@ -256,6 +206,20 @@ def rightClick(self, position: QtCore.QPoint): # break + def copyStep(self, name: str): + """ Copy step information """ + self.gui._copy_step_info = self.gui.configManager.getRecipeStep( + self.recipe_name, name) + + def pasteStep(self): + """ Paste step information """ + step_info = self.gui._copy_step_info + if step_info is not None: + self.gui.configManager.addRecipeStep( + self.recipe_name, step_info['stepType'], + step_info['element'], step_info['name'], + value=step_info['value']) + def renameStep(self, name: str): """ Prompts the user for a new step name and apply it to the selected step """ newName, state = QtWidgets.QInputDialog.getText( @@ -267,28 +231,34 @@ def renameStep(self, name: str): self.gui.configManager.renameRecipeStep( self.recipe_name, name, newName) + def removeStep(self, name: str): + self.gui.configManager.delRecipeStep(self.recipe_name, name) + def setStepValue(self, name: str): """ Prompts the user for a new step value and apply it to the selected step """ element = self.gui.configManager.getRecipeStepElement( self.recipe_name, name) value = self.gui.configManager.getRecipeStepValue( self.recipe_name, name) - # Default value displayed in the QInputDialog - if variables.has_eval(value): + if has_eval(value): defaultValue = f'{value}' else: if element.type in [np.ndarray]: defaultValue = array_to_str(value, threshold=1000000, max_line_width=100) elif element.type in [pd.DataFrame]: defaultValue = dataframe_to_str(value, threshold=1000000) + if element.type in [bytes] and isinstance(value, bytes): + defaultValue = f'{value.decode()}' else: try: defaultValue = f'{value:.{self.precision}g}' except (ValueError, TypeError): defaultValue = f'{value}' - main_dialog = variables.VariablesDialog(self.gui, name, defaultValue) + main_dialog = MyInputDialog(self.gui, name) + main_dialog.setWindowModality(QtCore.Qt.ApplicationModal) # block GUI interaction + main_dialog.setTextValue(defaultValue) main_dialog.show() if main_dialog.exec_() == QtWidgets.QInputDialog.Accepted: @@ -296,7 +266,7 @@ def setStepValue(self, name: str): try: try: - assert variables.has_eval(value), "Need $eval: to evaluate the given string" + assert has_eval(value), "Need $eval: to evaluate the given string" except: # Type conversions if element.type in [int]: @@ -305,21 +275,23 @@ def setStepValue(self, name: str): value = float(value) elif element.type in [str]: value = str(value) + elif element.type in [bytes]: + value = value.encode() elif element.type in [bool]: if value == "False": value = False elif value == "True": value = True value = int(value) assert value in [0, 1] value = bool(value) - # elif element.type in [tuple]: - # pass # OPTIMIZE: don't know what todo here, key or tuple? how tuple without reading driver, how key without knowing tuple! -> forbid setting tuple in scan + elif element.type in [tuple]: + value = str_to_tuple(value) # OPTIMIZE: bad with large data (truncate), but nobody will use it for large data right? elif element.type in [np.ndarray]: value = str_to_array(value) elif element.type in [pd.DataFrame]: value = str_to_dataframe(value) else: - assert variables.has_eval(value), "Need $eval: to evaluate the given string" + assert has_eval(value), "Need $eval: to evaluate the given string" # Apply modification self.gui.configManager.setRecipeStepValue( self.recipe_name, name, value) @@ -340,11 +312,13 @@ def itemDoubleClicked(self, item: QtWidgets.QTreeWidgetItem, column: int): self.renameStep(name) elif column == 4: if stepType == 'set' or (stepType == 'action' and element.type in [ - int, float, str, np.ndarray, pd.DataFrame]): + int, float, bool, str, bytes, tuple, np.ndarray, pd.DataFrame]): self.setStepValue(name) def setStepProcessingState(self, name: str, state: str): """ Sets the background color of a recipe step during the scan """ + if not qt_object_exists(self.tree): + return None item = self.tree.findItems(name, QtCore.Qt.MatchExactly, 0)[0] if state is None: diff --git a/autolab/core/gui/scanning/scan.py b/autolab/core/gui/scanning/scan.py index c7a8f944..2f3eaeea 100644 --- a/autolab/core/gui/scanning/scan.py +++ b/autolab/core/gui/scanning/scan.py @@ -16,10 +16,11 @@ import numpy as np from qtpy import QtCore, QtWidgets -from .. import variables -from ... import paths -from ..GUI_utilities import qt_object_exists -from ...utilities import create_array, SUPPORTED_EXTENSION +from ..GUI_utilities import qt_object_exists, MyInputDialog, MyFileDialog +from ..GUI_instances import instances +from ...paths import PATHS +from ...variables import eval_variable, set_variable, has_eval +from ...utilities import create_array class ScanManager: @@ -30,6 +31,8 @@ def __init__(self, gui: QtWidgets.QMainWindow): # Start / Stop button configuration self.gui.start_pushButton.clicked.connect(self.startButtonClicked) + self.gui.stop_pushButton.clicked.connect(self.stopButtonClicked) + self.gui.stop_pushButton.setEnabled(False) # Pause / Resume button configuration self.gui.pause_pushButton.clicked.connect(self.pauseButtonClicked) @@ -47,9 +50,16 @@ def __init__(self, gui: QtWidgets.QMainWindow): ############################################################################# def startButtonClicked(self): - """ Called when the start/stop button is pressed. + """ Called when the start button is pressed. Do the expected action """ - self.stop() if self.isStarted() else self.start() + if not self.isStarted(): + self.start() + + def stopButtonClicked(self): + """ Called when the stop button is pressed. + Do the expected action """ + if self.isStarted(): + self.stop() def isStarted(self): """ Returns True or False whether the scan is currently running or not """ @@ -57,56 +67,88 @@ def isStarted(self): def start(self): """ Starts a scan """ - try: self.gui.configManager.checkConfig() # raise error if config not valid config = self.gui.configManager.config except Exception as e: self.gui.setStatus(f'ERROR The scan cannot start with the current configuration: {str(e)}', 10000, False) + return None + + # Should not be possible + if self.thread is not None: + self.gui.setStatus('ERROR: A scan thread already exists!', 10000, False) + try: self.thread.finished() + except: pass + self.thread = None + return None + # Only if current config is valid to start a scan - else: - # Prepare a new dataset in the datacenter - self.gui.dataManager.newDataset(config) - - # put dataset id onto the combobox and associate data to it - dataSet_id = len(self.gui.dataManager.datasets) - self.gui.data_comboBox.addItem(f'Scan{dataSet_id}') - self.gui.data_comboBox.setCurrentIndex(int(dataSet_id)-1) # trigger the currentIndexChanged event but don't trigger activated - - # Start a new thread - ## Opening - self.thread = ScanThread(self.gui.dataManager.queue, config) - ## Signal connections - self.thread.errorSignal.connect(self.error) - self.thread.userSignal.connect(self.handler_user_input) - - self.thread.startParameterSignal.connect(lambda recipe_name, param_name: self.setParameterProcessingState(recipe_name, param_name, 'started')) - self.thread.finishParameterSignal.connect(lambda recipe_name, param_name: self.setParameterProcessingState(recipe_name, param_name, 'finished')) - self.thread.parameterCompletedSignal.connect(lambda recipe_name, param_name: self.resetParameterProcessingState(recipe_name, param_name)) - - self.thread.startStepSignal.connect(lambda recipe_name, stepName: self.setStepProcessingState(recipe_name, stepName, 'started')) - self.thread.finishStepSignal.connect(lambda recipe_name, stepName: self.setStepProcessingState(recipe_name, stepName, 'finished')) - self.thread.recipeCompletedSignal.connect(lambda recipe_name: self.resetStepsProcessingState(recipe_name)) - self.thread.scanCompletedSignal.connect(self.scanCompleted) - - self.thread.finished.connect(self.finished) - - # Starting - self.thread.start() - - # Start data center timer - self.gui.dataManager.timer.start() - - # Update gui - self.gui.start_pushButton.setText('Stop') - self.gui.pause_pushButton.setEnabled(True) - self.gui.clear_pushButton.setEnabled(False) - self.gui.progressBar.setValue(0) - self.gui.importAction.setEnabled(False) - self.gui.openRecentMenu.setEnabled(False) - self.gui.undo.setEnabled(False) - self.gui.redo.setEnabled(False) - self.gui.setStatus('Scan started!', 5000) + + # Pause monitors if option selected in monitors + for var_id in set([id(step['element']) + for recipe in config.values() + for step in recipe['recipe']+recipe['parameter']]): + if var_id in instances['monitors']: + monitor = instances['monitors'][var_id] + if (monitor.pause_on_scan + and not monitor.monitorManager.isPaused()): + monitor.pauseButtonClicked() + + # Prepare a new dataset in the datacenter + self.gui.dataManager.newDataset(config) + + # put dataset id onto the combobox and associate data to it + dataSet_id = len(self.gui.dataManager.datasets) + self.gui.data_comboBox.addItem(f'scan{dataSet_id}') + self.gui.data_comboBox.setCurrentIndex(int(dataSet_id)-1) # trigger the currentIndexChanged event but don't trigger activated + + # Start a new thread + ## Opening + self.thread = ScanThread(self.gui.dataManager.queue, config) + ## Signal connections + self.thread.errorSignal.connect(self.error) + self.thread.userSignal.connect(self.handler_user_input) + + self.thread.startParameterSignal.connect( + lambda recipe_name, param_name: self.setParameterProcessingState( + recipe_name, param_name, 'started')) + self.thread.finishParameterSignal.connect( + lambda recipe_name, param_name: self.setParameterProcessingState( + recipe_name, param_name, 'finished')) + self.thread.parameterCompletedSignal.connect( + lambda recipe_name, param_name: self.resetParameterProcessingState( + recipe_name, param_name)) + + self.thread.startStepSignal.connect( + lambda recipe_name, stepName: self.setStepProcessingState( + recipe_name, stepName, 'started')) + self.thread.finishStepSignal.connect( + lambda recipe_name, stepName: self.setStepProcessingState( + recipe_name, stepName, 'finished')) + self.thread.recipeCompletedSignal.connect( + lambda recipe_name: self.resetStepsProcessingState(recipe_name)) + self.thread.scanCompletedSignal.connect(self.scanCompleted) + + self.thread.finished.connect(self.finished) + + # Starting + self.thread.start() + + # Start data center timer + self.gui.dataManager.timer.start() + + # Update gui + self.gui.start_pushButton.setEnabled(False) + self.gui.stop_pushButton.setEnabled(True) + self.gui.pause_pushButton.setEnabled(True) + self.gui.clear_pushButton.setEnabled(False) + self.gui.progressBar.setValue(0) + self.gui.importAction.setEnabled(False) + self.gui.openRecentMenu.setEnabled(False) + self.gui.undo.setEnabled(False) + self.gui.redo.setEnabled(False) + self.gui.setStatus('Scan started!', 5000) + self.gui.refresh_widget(self.gui.start_pushButton) def handler_user_input(self, stepInfos: dict): unit = stepInfos['element'].unit @@ -114,41 +156,9 @@ def handler_user_input(self, stepInfos: dict): if unit in ("open-file", "save-file"): - class FileDialog(QtWidgets.QDialog): - - def __init__(self, parent: QtWidgets.QMainWindow, name: str, - mode: QtWidgets.QFileDialog): - - super().__init__(parent) - if mode == QtWidgets.QFileDialog.AcceptOpen: - self.setWindowTitle(f"Open file - {name}") - elif mode == QtWidgets.QFileDialog.AcceptSave: - self.setWindowTitle(f"Save file - {name}") - - file_dialog = QtWidgets.QFileDialog(self, QtCore.Qt.Widget) - file_dialog.setAcceptMode(mode) - file_dialog.setOption(QtWidgets.QFileDialog.DontUseNativeDialog) - file_dialog.setWindowFlags(file_dialog.windowFlags() & ~QtCore.Qt.Dialog) - file_dialog.setDirectory(paths.USER_LAST_CUSTOM_FOLDER) - file_dialog.setNameFilters(SUPPORTED_EXTENSION.split(";;")) - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(file_dialog) - layout.addStretch() - layout.setSpacing(0) - layout.setContentsMargins(0,0,0,0) - - self.exec_ = file_dialog.exec_ - self.selectedFiles = file_dialog.selectedFiles - - def closeEvent(self, event): - for children in self.findChildren(QtWidgets.QWidget): - children.deleteLater() - - super().closeEvent(event) - if unit == "open-file": - self.main_dialog = FileDialog(self.gui, name, QtWidgets.QFileDialog.AcceptOpen) + self.main_dialog = MyFileDialog(self.gui, name, + QtWidgets.QFileDialog.AcceptOpen) self.main_dialog.show() if self.main_dialog.exec_() == QtWidgets.QInputDialog.Accepted: @@ -157,7 +167,8 @@ def closeEvent(self, event): filename = '' elif unit == "save-file": - self.main_dialog = FileDialog(self.gui, name, QtWidgets.QFileDialog.AcceptSave) + self.main_dialog = MyFileDialog(self.gui, name, + QtWidgets.QFileDialog.AcceptSave) self.main_dialog.show() if self.main_dialog.exec_() == QtWidgets.QInputDialog.Accepted: @@ -167,43 +178,13 @@ def closeEvent(self, event): if filename != '': path = os.path.dirname(filename) - paths.USER_LAST_CUSTOM_FOLDER = path + PATHS['last_folder'] = path if qt_object_exists(self.main_dialog): self.main_dialog.deleteLater() if self.thread is not None: self.thread.user_response = filename elif unit == 'user-input': - - class InputDialog(QtWidgets.QDialog): - - def __init__(self, parent: QtWidgets.QMainWindow, name: str): - - super().__init__(parent) - self.setWindowTitle(name) - - input_dialog = QtWidgets.QInputDialog(self) - input_dialog.setLabelText(f"Set {name} value") - input_dialog.setInputMode(QtWidgets.QInputDialog.TextInput) - input_dialog.setWindowFlags(input_dialog.windowFlags() & ~QtCore.Qt.Dialog) - - lineEdit = input_dialog.findChild(QtWidgets.QLineEdit) - lineEdit.setMaxLength(10000000) - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(input_dialog) - layout.addStretch() - layout.setSpacing(0) - layout.setContentsMargins(0,0,0,0) - - self.exec_ = input_dialog.exec_ - self.textValue = input_dialog.textValue - - def closeEvent(self, event): - for children in self.findChildren(QtWidgets.QWidget): - children.deleteLater() - super().closeEvent(event) - - self.main_dialog = InputDialog(self.gui, name) + self.main_dialog = MyInputDialog(self.gui, name) self.main_dialog.show() if self.main_dialog.exec_() == QtWidgets.QInputDialog.Accepted: @@ -212,11 +193,21 @@ def closeEvent(self, event): response = '' if qt_object_exists(self.main_dialog): self.main_dialog.deleteLater() + + if has_eval(response): + try: + response = eval_variable(response) + except Exception as e: + self.thread.errorSignal.emit(e) + self.thread.stopFlag.set() + if self.thread is not None: self.thread.user_response = response else: if self.thread is not None: self.thread.user_response = f"Unknown unit '{unit}'" def scanCompleted(self): + if not qt_object_exists(self.gui.progressBar): + return None self.gui.progressBar.setStyleSheet("") if self.thread.stopFlag.is_set(): @@ -227,6 +218,16 @@ def scanCompleted(self): self.gui.progressBar.setMaximum(1) self.gui.progressBar.setValue(1) + # Start monitors if option selected in monitors + for var_id in set([id(step['element']) + for recipe in self.thread.config.values() + for step in recipe['recipe']+recipe['parameter']]): + if var_id in instances['monitors']: + monitor = instances['monitors'][var_id] + if (monitor.start_on_scan + and monitor.monitorManager.isPaused()): + monitor.pauseButtonClicked() + def setStepProcessingState(self, recipe_name: str, stepName: str, state: str): self.gui.recipeDict[recipe_name]['recipeManager'].setStepProcessingState(stepName, state) @@ -245,7 +246,8 @@ def stop(self): self.thread.stopFlag.set() self.thread.user_response = 'Close' # needed to stop scan self.resume() - if self.main_dialog is not None and qt_object_exists(self.main_dialog): self.main_dialog.deleteLater() + if self.main_dialog and qt_object_exists(self.main_dialog): + self.main_dialog.deleteLater() self.thread.wait() # SIGNALS @@ -255,19 +257,23 @@ def finished(self): """ This function is called when the scan thread is finished. It restores the GUI in a ready mode, and start a new scan if in continuous mode """ - self.gui.start_pushButton.setText('Start') + if not qt_object_exists(self.gui.stop_pushButton): + return None + self.gui.stop_pushButton.setEnabled(False) self.gui.pause_pushButton.setEnabled(False) self.gui.clear_pushButton.setEnabled(True) - self.gui.displayScanData_pushButton.setEnabled(True) self.gui.importAction.setEnabled(True) self.gui.openRecentMenu.setEnabled(True) self.gui.configManager.updateUndoRedoButtons() self.gui.dataManager.timer.stop() self.gui.dataManager.sync() # once again to be sure we grabbed every data self.thread = None + self.gui.refresh_widget(self.gui.stop_pushButton) if self.isContinuousModeEnabled(): self.start() + else: + self.gui.start_pushButton.setEnabled(True) def error(self, error: Exception): """ Called if an error occured during the scan. @@ -304,10 +310,17 @@ def pause(self): """ Pauses the scan """ self.thread.pauseFlag.set() self.gui.dataManager.timer.stop() + self.gui.dataManager.sync() # once again to be sure we grabbed every data self.gui.pause_pushButton.setText('Resume') def resume(self): """ Resumes the scan """ + try: + # One possible error is device closed while scan was paused + self.gui.configManager.checkConfig() # raise error if config not valid + except Exception as e: + self.gui.setStatus(f'WARNING The scan cannot resume: {str(e)}', 10000, False) + return None self.thread.pauseFlag.clear() self.gui.dataManager.timer.start() self.gui.pause_pushButton.setText('Pause') @@ -360,7 +373,7 @@ def execRecipe(self, recipe_name: str, if 'values' in parameter: paramValues = parameter['values'] try: - paramValues = variables.eval_variable(paramValues) + paramValues = eval_variable(paramValues) paramValues = create_array(paramValues) except Exception as e: self.errorSignal.emit(e) @@ -376,7 +389,7 @@ def execRecipe(self, recipe_name: str, else: paramValues = np.linspace(startValue, endValue, nbpts, endpoint=True) - variables.set_variable(param_name, paramValues[0]) + set_variable(param_name, paramValues[0]) paramValues_list.append(paramValues) ID = 0 @@ -394,7 +407,7 @@ def execRecipe(self, recipe_name: str, try: self._source_of_error = None ID += 1 - variables.set_variable('ID', ID) + set_variable('ID', ID) for parameter, paramValue in zip( self.config[recipe_name]['parameter'], paramValueList): @@ -402,7 +415,7 @@ def execRecipe(self, recipe_name: str, element = parameter['element'] param_name = parameter['name'] - variables.set_variable(param_name, element.type( + set_variable(param_name, element.type( paramValue) if element is not None else paramValue) # Set the parameter value @@ -430,12 +443,12 @@ def execRecipe(self, recipe_name: str, try: from pyvisa import VisaIOError except: - e = f"In recipe '{recipe_name}' for element '{name}'{address}: {e}" + e = f"In recipe '{recipe_name}' for step '{name}': {e}" else: if str(e) == str(VisaIOError(-1073807339)): e = f"Timeout reached for device {address}. Acquisition time may be too long. If so, you can increase timeout delay in the driver to avoid this error." else: - e = f"In recipe '{recipe_name}' for element '{name}'{address}: {e}" + e = f"In recipe '{recipe_name}' for step '{name}': {e}" self.errorSignal.emit(e) self.stopFlag.set() @@ -482,15 +495,16 @@ def processElement(self, recipe_name: str, stepInfos: dict, if stepType == 'measure': result = element() - variables.set_variable(stepInfos['name'], result) + set_variable(stepInfos['name'], result) elif stepType == 'set': - value = variables.eval_variable(stepInfos['value']) + value = eval_variable(stepInfos['value']) + if element.type in [bytes] and isinstance(value, str): value = value.encode() if element.type in [np.ndarray]: value = create_array(value) element(value) elif stepType == 'action': if stepInfos['value'] is not None: # Open dialog for open file, save file or input text - if stepInfos['value'] == '': + if isinstance(stepInfos['value'], str) and stepInfos['value'] == '': self.userSignal.emit(stepInfos) while (not self.stopFlag.is_set() and self.user_response is None): @@ -499,7 +513,9 @@ def processElement(self, recipe_name: str, stepInfos: dict, element(self.user_response) self.user_response = None else: - value = variables.eval_variable(stepInfos['value']) + value = eval_variable(stepInfos['value']) + if element.type in [bytes] and isinstance(value, str): value = value.encode() + if element.type in [np.ndarray]: value = create_array(value) element(value) else: element() diff --git a/autolab/core/gui/theme/__init__.py b/autolab/core/gui/theme/__init__.py new file mode 100644 index 00000000..3b1df56d --- /dev/null +++ b/autolab/core/gui/theme/__init__.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +""" +Created on Thu Sep 5 18:45:37 2024 + +@author: jonathan +""" + +from typing import Dict + + +theme = { + 'dark': { + 'text_color': "#f0f0f0", + 'text_disabled_color': "#a0a0a0", + 'default': "#3c3c3c", + 'primary_color': "#3c3c3c", + 'secondary_color': "#2c2c2c", + 'tertiary_color': "#2e2e2e", + 'hover_color': "#555555", + 'pressed_color': "#666666", + 'selected_color': "#005777", + 'border_color': "#5c5c5c", + 'main_hover': "#005777", + } + } + + +def get_theme(theme_name: str) -> Dict[str, str]: + return theme.get(theme_name) + + +def create_stylesheet(theme: Dict[str, str]) -> str: + stylesheet = f""" + QWidget {{ + background-color: {theme['default']}; + color: {theme['text_color']}; + }} + QFrame {{ + background-color: {theme['primary_color']}; + border-radius: 8px; + }} + QFrame[frameShape='1'], QFrame[frameShape='2'], QFrame[frameShape='3'], QFrame[frameShape='6'] {{ + border: 1px solid {theme['border_color']}; + }} + #line_V {{ + border-left: 1px solid {theme['border_color']}; + }} + #line_H {{ + border-top: 1px solid {theme['border_color']}; + }} + QPushButton {{ + background-color: {theme['secondary_color']}; + border: 1px solid {theme['border_color']}; + border-radius: 6px; + padding: 4px; + margin: 2px; + }} + QPushButton:hover {{ + background-color: {theme['hover_color']}; + border: 1px solid {theme['border_color']}; + }} + QPushButton:pressed {{ + background-color: {theme['pressed_color']}; + }} + QPushButton:disabled {{ + background-color: {theme['primary_color']}; + color: {theme['text_disabled_color']}; + border: 1px solid {theme['border_color']}; + border-radius: 6px; + }} + QCheckBox {{ + background-color: {theme['primary_color']}; + border-radius: 4px; + }} + QLineEdit, QTextEdit {{ + background-color: {theme['secondary_color']}; + border: 1px solid {theme['border_color']}; + border-radius: 4px; + }} + QLineEdit:disabled, QLineEdit[readOnly="true"] {{ + background-color: {theme['primary_color']}; + color: {theme['text_disabled_color']}; + border: 1px solid {theme['border_color']}; + border-radius: 4px; + }} + QComboBox {{ + background-color: {theme['secondary_color']}; + padding: 3px; + border-radius: 5px; + }} + QTreeView {{ + alternate-background-color: {theme['secondary_color']}; + border-radius: 6px; + }} + QHeaderView::section {{ + background-color: {theme['secondary_color']}; + border: 1px solid {theme['primary_color']}; + }} + QTabWidget::pane {{ + border: 1px solid {theme['border_color']}; + border-radius: 5px; + }} + QTabBar::tab {{ + background-color: {theme['tertiary_color']}; + border: 1px solid {theme['border_color']}; + padding: 5px; + border-radius: 4px; + }} + QTabBar::tab:hover {{ + background-color: {theme['primary_color']}; + border: 1px solid {theme['hover_color']}; + }} + QTabBar::tab:selected {{ + background-color: {theme['primary_color']}; + border: 1px solid {theme['hover_color']}; + margin-top: -2px; + border-radius: 4px; + }} + QTabBar::tab:selected:hover {{ + background-color: {theme['primary_color']}; + border: 1px solid {theme['hover_color']}; + margin-top: -2px; + }} + QTabBar::tab:!selected {{ + margin-top: 2px; + }} + QMenuBar {{ + background-color: {theme['secondary_color']}; + border-radius: 5px; + }} + QMenuBar::item {{ + background-color: transparent; + padding: 4px 10px; + border-radius: 4px; + }} + QMenuBar::item:selected {{ + background-color: {theme['primary_color']}; + border: 1px solid {theme['pressed_color']}; + }} + QMenu {{ + background-color: {theme['secondary_color']}; + border: 1px solid {theme['border_color']}; + border-radius: 6px; + }} + QMenu::item {{ + background-color: {theme['secondary_color']}; + }} + QMenu::item:selected {{ + background-color: {theme['primary_color']}; + }} + QMenu::item:disabled {{ + background-color: {theme['primary_color']}; + color: {theme['text_disabled_color']}; + }} + QTreeWidget {{ + background-color: {theme['tertiary_color']}; + alternate-background-color: {theme['primary_color']}; + }} + QTreeWidget::item {{ + border: none; + }} + QTreeWidget::item:hover {{ + background-color: {theme['main_hover']}; + }} + QTreeWidget::item:selected {{ + background-color: {theme['selected_color']}; + }} + QTreeWidget::item:selected:hover {{ + background-color: {theme['main_hover']}; + }} + QScrollBar:vertical {{ + border: 1px solid {theme['primary_color']}; + background-color: {theme['tertiary_color']}; + width: 16px; + border-radius: 6px; + }} + QScrollBar:horizontal {{ + border: 1px solid {theme['primary_color']}; + background-color: {theme['tertiary_color']}; + height: 16px; + border-radius: 6px; + }} + QScrollBar::handle {{ + background-color: {theme['border_color']}; + border: 1px solid {theme['pressed_color']}; + border-radius: 6px; + }} + QScrollBar::handle:hover {{ + background-color: {theme['pressed_color']}; + }} + QScrollBar::add-line, QScrollBar::sub-line {{ + background-color: {theme['primary_color']}; + }} + """ + + return stylesheet diff --git a/autolab/core/gui/variables.py b/autolab/core/gui/variables.py deleted file mode 100644 index 44edbaaf..00000000 --- a/autolab/core/gui/variables.py +++ /dev/null @@ -1,711 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Created on Mon Mar 4 14:54:41 2024 - -@author: Jonathan -""" - -import sys -import re -# import ast -from typing import Any, List, Tuple, Union - -import numpy as np -import pandas as pd -from qtpy import QtCore, QtWidgets, QtGui - -from .GUI_utilities import setLineEditBackground -from .icons import icons -from ..devices import DEVICES -from ..utilities import (str_to_array, str_to_dataframe, str_to_value, - array_to_str, dataframe_to_str, clean_string) - -from .monitoring.main import Monitor -from .slider import Slider - - -# class AddVarSignal(QtCore.QObject): -# add = QtCore.Signal(object, object) -# def emit_add(self, name, value): -# self.add.emit(name, value) - - -# class RemoveVarSignal(QtCore.QObject): -# remove = QtCore.Signal(object) -# def emit_remove(self, name): -# self.remove.emit(name) - - -# class MyDict(dict): - -# def __init__(self): -# self.addVarSignal = AddVarSignal() -# self.removeVarSignal = RemoveVarSignal() - -# def __setitem__(self, item, value): -# super(MyDict, self).__setitem__(item, value) -# self.addVarSignal.emit_add(item, value) - -# def pop(self, item): -# super(MyDict, self).pop(item) -# self.removeVarSignal.emit_remove(item) - - -# VARIABLES = MyDict() -VARIABLES = {} - -EVAL = "$eval:" - - -def update_allowed_dict() -> dict: - global allowed_dict # needed to remove variables instead of just adding new one - allowed_dict = {"np": np, "pd": pd} - allowed_dict.update(DEVICES) - allowed_dict.update(VARIABLES) - return allowed_dict - - -allowed_dict = update_allowed_dict() - -# TODO: replace refresh by (value)? -# OPTIMIZE: Variable becomes closer and closer to core.elements.Variable, could envision a merge -# TODO: refresh menu display by looking if has eval (no -> can refresh) -# TODO add read signal to update gui (seperate class for event and use it on itemwidget creation to change setText with new value) -class Variable(): - """ Class used to control basic variable """ - - def __init__(self, name: str, var: Any): - """ name: name of the variable, var: value of the variable """ - self.refresh(name, var) - - def refresh(self, name: str, var: Any): - if isinstance(var, Variable): - self.raw = var.raw - self.value = var.value - else: - self.raw = var - self.value = 'Need update' if has_eval(self.raw) else self.raw - - if not has_variable(self.raw): - try: self.value = self.evaluate() # If no devices or variables found in name, can evaluate value safely - except Exception as e: self.value = str(e) - - self.name = name - self.unit = None - self.address = lambda: name - self.type = type(self.raw) # For slider - - def __call__(self, value: Any = None) -> Any: - if value is None: - return self.evaluate() - - self.refresh(self.name, value) - return None - - def evaluate(self): - if has_eval(self.raw): - value = str(self.raw)[len(EVAL): ] - call = eval(str(value), {}, allowed_dict) - self.value = call - else: - call = self.value - - return call - - def __repr__(self) -> str: - if isinstance(self.raw, np.ndarray): - raw_value_str = array_to_str(self.raw, threshold=1000000, max_line_width=9000000) - elif isinstance(self.raw, pd.DataFrame): - raw_value_str = dataframe_to_str(self.raw, threshold=1000000) - else: - raw_value_str = str(self.raw) - return raw_value_str - - -def rename_variable(name, new_name): - var = remove_variable(name) - assert var is not None - set_variable(new_name, var) - - -def set_variable(name: str, value: Any): - ''' Create or modify a Variable with provided name and value ''' - name = clean_string(name) - - if is_Variable(value): - var = value - var.refresh(name, value) - else: - var = get_variable(name) - if var is None: - var = Variable(name, value) - else: - assert is_Variable(var) - var.refresh(name, value) - - VARIABLES[name] = var - update_allowed_dict() - - -def get_variable(name: str) -> Union[Variable, None]: - ''' Return Variable with provided name if exists else None ''' - return VARIABLES.get(name) - - -def remove_variable(name: str) -> Any: - value = VARIABLES.pop(name) if name in VARIABLES else None - update_allowed_dict() - return value - - -def remove_from_config(listVariable: List[Tuple[str, Any]]): - for var in listVariable: - remove_variable(var[0]) - - -def update_from_config(listVariable: List[Tuple[str, Any]]): - for var in listVariable: - set_variable(var[0], var[1]) - - -def convert_str_to_data(raw_value: str) -> Any: - """ Convert data in str format to proper format """ - if not has_eval(raw_value): - if '\t' in raw_value and '\n' in raw_value: - try: raw_value = str_to_dataframe(raw_value) - except: pass - elif '[' in raw_value: - try: raw_value = str_to_array(raw_value) - except: pass - else: - try: raw_value = str_to_value(raw_value) - except: pass - return raw_value - - -def has_variable(value: str) -> bool: - pattern = r'[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?' - - for key in (list(DEVICES) + list(VARIABLES)): - if key in [var.split('.')[0] for var in re.findall(pattern, str(value))]: - return True - return False - - -def has_eval(value: Any) -> bool: - """ Checks if value is a string starting with '$eval:'""" - return True if isinstance(value, str) and value.startswith(EVAL) else False - - -def is_Variable(value: Any): - """ Returns True if value of type Variable """ - return isinstance(value, Variable) - - -def eval_variable(value: Any) -> Any: - """ Evaluate the given python string. String can contain variables, - devices, numpy arrays and pandas dataframes.""" - if has_eval(value): value = Variable('temp', value) - - if is_Variable(value): return value() - return value - - -def eval_safely(value: Any) -> Any: - """ Same as eval_variable but do not evaluate if contains devices or variables """ - if has_eval(value): value = Variable('temp', value) - - if is_Variable(value): return value.value - return value - - -class VariablesDialog(QtWidgets.QDialog): - - def __init__(self, parent: QtWidgets.QMainWindow, name: str, defaultValue: str): - - super().__init__(parent) - self.setWindowTitle(name) - self.setWindowModality(QtCore.Qt.ApplicationModal) # block GUI interaction - - self.variablesMenu = None - # ORDER of creation mater to have button OK selected instead of Variables - variablesButton = QtWidgets.QPushButton('Variables', self) - variablesButton.clicked.connect(self.variablesButtonClicked) - - hbox = QtWidgets.QHBoxLayout(self) - hbox.addStretch() - hbox.addWidget(variablesButton) - hbox.setContentsMargins(10,0,10,10) - - widget = QtWidgets.QWidget(self) - widget.setLayout(hbox) - - dialog = QtWidgets.QInputDialog(self) - dialog.setLabelText(f"Set {name} value") - dialog.setInputMode(QtWidgets.QInputDialog.TextInput) - dialog.setWindowFlags(dialog.windowFlags() & ~QtCore.Qt.Dialog) - - lineEdit = dialog.findChild(QtWidgets.QLineEdit) - lineEdit.setMaxLength(10000000) - dialog.setTextValue(defaultValue) - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(dialog) - layout.addWidget(widget) - layout.addStretch() - layout.setSpacing(0) - layout.setContentsMargins(0,0,0,0) - - self.exec_ = dialog.exec_ - self.textValue = dialog.textValue - self.setTextValue = dialog.setTextValue - - def variablesButtonClicked(self): - if self.variablesMenu is None: - self.variablesMenu = VariablesMenu(self) - self.variablesMenu.setWindowTitle( - self.windowTitle()+": "+self.variablesMenu.windowTitle()) - - self.variablesMenu.variableSignal.connect(self.toggleVariableName) - self.variablesMenu.deviceSignal.connect(self.toggleDeviceName) - self.variablesMenu.show() - else: - self.variablesMenu.refresh() - - def clearVariablesMenu(self): - """ This clear the variables menu instance reference when quitted """ - self.variablesMenu = None - - def toggleVariableName(self, name): - value = self.textValue() - if is_Variable(get_variable(name)): name += '()' - - if value in ('0', "''"): value = '' - if not has_eval(value): value = EVAL + value - - if value.endswith(name): value = value[:-len(name)] - else: value += name - - if value == EVAL: value = '' - - self.setTextValue(value) - - def toggleDeviceName(self, name): - name += '()' - self.toggleVariableName(name) - - def closeEvent(self, event): - for children in self.findChildren(QtWidgets.QWidget): - children.deleteLater() - super().closeEvent(event) - - -class VariablesMenu(QtWidgets.QMainWindow): - - variableSignal = QtCore.Signal(object) - deviceSignal = QtCore.Signal(object) - - def __init__(self, parent: QtWidgets.QMainWindow = None): - - super().__init__(parent) - self.gui = parent - self.setWindowTitle('Variables manager') - if self.gui is None: self.setWindowIcon(QtGui.QIcon(icons['autolab'])) - - self.statusBar = self.statusBar() - - # Main widgets creation - self.variablesWidget = QtWidgets.QTreeWidget(self) - self.variablesWidget.setHeaderLabels( - ['', 'Name', 'Value', 'Evaluated value', 'Type', 'Action']) - self.variablesWidget.setAlternatingRowColors(True) - self.variablesWidget.setIndentation(0) - self.variablesWidget.setStyleSheet( - "QHeaderView::section { background-color: lightgray; }") - header = self.variablesWidget.header() - header.setMinimumSectionSize(20) - header.resizeSection(0, 20) - header.resizeSection(1, 90) - header.resizeSection(2, 120) - header.resizeSection(3, 120) - header.resizeSection(4, 50) - header.resizeSection(5, 100) - self.variablesWidget.itemDoubleClicked.connect(self.variableActivated) - self.variablesWidget.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) - self.variablesWidget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - self.variablesWidget.customContextMenuRequested.connect(self.rightClick) - - addButton = QtWidgets.QPushButton('Add') - addButton.clicked.connect(self.addVariableAction) - - removeButton = QtWidgets.QPushButton('Remove') - removeButton.clicked.connect(self.removeVariableAction) - - self.devicesWidget = QtWidgets.QTreeWidget(self) - self.devicesWidget.setHeaderLabels(['Name']) - self.devicesWidget.setAlternatingRowColors(True) - self.devicesWidget.setIndentation(10) - self.devicesWidget.setStyleSheet("QHeaderView::section { background-color: lightgray; }") - self.devicesWidget.itemDoubleClicked.connect(self.deviceActivated) - - # Main layout creation - layoutWindow = QtWidgets.QVBoxLayout() - layoutTab = QtWidgets.QHBoxLayout() - layoutWindow.addLayout(layoutTab) - - centralWidget = QtWidgets.QWidget() - centralWidget.setLayout(layoutWindow) - self.setCentralWidget(centralWidget) - - refreshButtonWidget = QtWidgets.QPushButton() - refreshButtonWidget.setText('Refresh Manager') - refreshButtonWidget.clicked.connect(self.refresh) - - # Main layout definition - layoutButton = QtWidgets.QHBoxLayout() - layoutButton.addWidget(addButton) - layoutButton.addWidget(removeButton) - layoutButton.addWidget(refreshButtonWidget) - layoutButton.addStretch() - - frameVariables = QtWidgets.QFrame() - layoutVariables = QtWidgets.QVBoxLayout(frameVariables) - layoutVariables.addWidget(self.variablesWidget) - layoutVariables.addLayout(layoutButton) - - frameDevices = QtWidgets.QFrame() - layoutDevices = QtWidgets.QVBoxLayout(frameDevices) - layoutDevices.addWidget(self.devicesWidget) - - tab = QtWidgets.QTabWidget(self) - tab.addTab(frameVariables, 'Variables') - tab.addTab(frameDevices, 'Devices') - - layoutTab.addWidget(tab) - - self.resize(550, 300) - self.refresh() - - self.monitors = {} - self.sliders = {} - # self.timer = QtCore.QTimer(self) - # self.timer.setInterval(400) # ms - # self.timer.timeout.connect(self.refresh_new) - # self.timer.start() - # VARIABLES.removeVarSignal.remove.connect(self.removeVarSignalChanged) - # VARIABLES.addVarSignal.add.connect(self.addVarSignalChanged) - - def variableActivated(self, item: QtWidgets.QTreeWidgetItem): - self.variableSignal.emit(item.name) - - def rightClick(self, position: QtCore.QPoint): - """ Provides a menu where the user right clicked to manage a variable """ - item = self.variablesWidget.itemAt(position) - if hasattr(item, 'menu'): item.menu(position) - - def deviceActivated(self, item: QtWidgets.QTreeWidgetItem): - if hasattr(item, 'name'): self.deviceSignal.emit(item.name) - - def removeVariableAction(self): - for variableItem in self.variablesWidget.selectedItems(): - remove_variable(variableItem.name) - self.removeVariableItem(variableItem) - - # def addVariableItem(self, name): - # MyQTreeWidgetItem(self.variablesWidget, name, self) - - def removeVariableItem(self, item: QtWidgets.QTreeWidgetItem): - index = self.variablesWidget.indexFromItem(item) - self.variablesWidget.takeTopLevelItem(index.row()) - - def addVariableAction(self): - basename = 'var' - name = basename - names = list(VARIABLES) - - compt = 0 - while True: - if name in names: - compt += 1 - name = basename + str(compt) - else: - break - - set_variable(name, 0) - - variable = get_variable(name) - MyQTreeWidgetItem(self.variablesWidget, name, variable, self) # not catched by VARIABLES signal - - # def addVarSignalChanged(self, key, value): - # print('got add signal', key, value) - # all_items = [self.variablesWidget.topLevelItem(i) for i in range( - # self.variablesWidget.topLevelItemCount())] - - # for variableItem in all_items: - # if variableItem.name == key: - # variableItem.raw_value = get_variable(variableItem.name) - # variableItem.refresh_rawValue() - # variableItem.refresh_value() - # break - # else: - # self.addVariableItem(key) - # # self.refresh() # TODO: check if item exists, create if not, update if yes - - # def removeVarSignalChanged(self, key): - # print('got remove signal', key) - # all_items = [self.variablesWidget.topLevelItem(i) for i in range( - # self.variablesWidget.topLevelItemCount())] - - # for variableItem in all_items: - # if variableItem.name == key: - # self.removeVariableItem(variableItem) - - # # self.refresh() # TODO: check if exists, remove if yes - - def refresh(self): - self.variablesWidget.clear() - for var_name in VARIABLES: - variable = get_variable(var_name) - MyQTreeWidgetItem(self.variablesWidget, var_name, variable, self) - - self.devicesWidget.clear() - for device_name in DEVICES: - device = DEVICES[device_name] - deviceItem = QtWidgets.QTreeWidgetItem( - self.devicesWidget, [device_name]) - deviceItem.setBackground(0, QtGui.QColor('#9EB7F5')) # blue - deviceItem.setExpanded(True) - for elements in device.get_structure(): - deviceItem2 = QtWidgets.QTreeWidgetItem( - deviceItem, [elements[0]]) - deviceItem2.name = elements[0] - - def setStatus(self, message: str, timeout: int = 0, stdout: bool = True): - """ Modify the message displayed in the status bar and add error message to logger """ - self.statusBar.showMessage(message, timeout) - if not stdout: print(message, file=sys.stderr) - - def closeEvent(self, event): - # self.timer.stop() - if hasattr(self.gui, 'clearVariablesMenu'): - self.gui.clearVariablesMenu() - - for monitor in list(self.monitors.values()): - monitor.close() - - for slider in list(self.sliders.values()): - slider.close() - - for children in self.findChildren(QtWidgets.QWidget): - children.deleteLater() - - super().closeEvent(event) - - if self.gui is None: - QtWidgets.QApplication.quit() # close the variable app - -class MyQTreeWidgetItem(QtWidgets.QTreeWidgetItem): - - def __init__(self, itemParent, name, variable, gui): - - super().__init__(itemParent, ['', name]) - - self.itemParent = itemParent - self.gui = gui - self.name = name - self.variable = variable - - nameWidget = QtWidgets.QLineEdit() - nameWidget.setText(name) - nameWidget.setAlignment(QtCore.Qt.AlignCenter) - nameWidget.returnPressed.connect(self.renameVariable) - nameWidget.textEdited.connect(lambda: setLineEditBackground( - nameWidget, 'edited')) - setLineEditBackground(nameWidget, 'synced') - self.gui.variablesWidget.setItemWidget(self, 1, nameWidget) - self.nameWidget = nameWidget - - rawValueWidget = QtWidgets.QLineEdit() - rawValueWidget.setMaxLength(10000000) - rawValueWidget.setAlignment(QtCore.Qt.AlignCenter) - rawValueWidget.returnPressed.connect(self.changeRawValue) - rawValueWidget.textEdited.connect(lambda: setLineEditBackground( - rawValueWidget, 'edited')) - self.gui.variablesWidget.setItemWidget(self, 2, rawValueWidget) - self.rawValueWidget = rawValueWidget - - valueWidget = QtWidgets.QLineEdit() - valueWidget.setMaxLength(10000000) - valueWidget.setReadOnly(True) - valueWidget.setStyleSheet( - "QLineEdit {border: 1px solid #a4a4a4; background-color: #f4f4f4}") - valueWidget.setAlignment(QtCore.Qt.AlignCenter) - self.gui.variablesWidget.setItemWidget(self, 3, valueWidget) - self.valueWidget = valueWidget - - typeWidget = QtWidgets.QLabel() - typeWidget.setAlignment(QtCore.Qt.AlignCenter) - self.gui.variablesWidget.setItemWidget(self, 4, typeWidget) - self.typeWidget = typeWidget - - self.actionButtonWidget = None - - self.refresh_rawValue() - self.refresh_value() - - def menu(self, position: QtCore.QPoint): - """ This function provides the menu when the user right click on an item """ - menu = QtWidgets.QMenu() - monitoringAction = menu.addAction("Start monitoring") - monitoringAction.setIcon(QtGui.QIcon(icons['monitor'])) - monitoringAction.setEnabled(has_eval(self.variable.raw) or isinstance( - self.variable.value, (int, float, np.ndarray, pd.DataFrame))) - - menu.addSeparator() - sliderAction = menu.addAction("Create a slider") - sliderAction.setIcon(QtGui.QIcon(icons['slider'])) - sliderAction.setEnabled(self.variable.type in (int, float)) - - choice = menu.exec_(self.gui.variablesWidget.viewport().mapToGlobal(position)) - if choice == monitoringAction: self.openMonitor() - elif choice == sliderAction: self.openSlider() - - def openMonitor(self): - """ This function open the monitor associated to this variable. """ - # If the monitor is not already running, create one - if id(self) not in self.gui.monitors: - self.gui.monitors[id(self)] = Monitor(self) - self.gui.monitors[id(self)].show() - # If the monitor is already running, just make as the front window - else: - monitor = self.gui.monitors[id(self)] - monitor.setWindowState( - monitor.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) - monitor.activateWindow() - - def openSlider(self): - """ This function open the slider associated to this variable. """ - # If the slider is not already running, create one - if id(self) not in self.gui.sliders: - self.gui.sliders[id(self)] = Slider(self.variable, self) - self.gui.sliders[id(self)].show() - # If the slider is already running, just make as the front window - else: - slider = self.gui.sliders[id(self)] - slider.setWindowState( - slider.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) - slider.activateWindow() - - def clearMonitor(self): - """ This clear monitor instances reference when quitted """ - if id(self) in self.gui.monitors: - self.gui.monitors.pop(id(self)) - - def clearSlider(self): - """ This clear the slider instances reference when quitted """ - if id(self) in self.gui.sliders: - self.gui.sliders.pop(id(self)) - - def renameVariable(self): - new_name = self.nameWidget.text() - if new_name == self.name: - setLineEditBackground(self.nameWidget, 'synced') - return None - - if new_name in VARIABLES: - self.gui.setStatus( - f"Error: {new_name} already exist!", 10000, False) - return None - - for character in r'$*."/\[]:;|, -(){}^=': - new_name = new_name.replace(character, '') - - try: - rename_variable(self.name, new_name) - except Exception as e: - self.gui.setStatus(f'Error: {e}', 10000, False) - else: - self.name = new_name - new_name = self.nameWidget.setText(self.name) - setLineEditBackground(self.nameWidget, 'synced') - self.gui.setStatus('') - - def refresh_rawValue(self): - raw_value = self.variable.raw - - if isinstance(raw_value, np.ndarray): - raw_value_str = array_to_str(raw_value) - elif isinstance(raw_value, pd.DataFrame): - raw_value_str = dataframe_to_str(raw_value) - else: - raw_value_str = str(raw_value) - - self.rawValueWidget.setText(raw_value_str) - setLineEditBackground(self.rawValueWidget, 'synced') - - if has_variable(self.variable): # OPTIMIZE: use hide and show instead but doesn't hide on instantiation - if self.actionButtonWidget is None: - actionButtonWidget = QtWidgets.QPushButton() - actionButtonWidget.setText('Update value') - actionButtonWidget.setMinimumSize(0, 23) - actionButtonWidget.setMaximumSize(85, 23) - actionButtonWidget.clicked.connect(self.convertVariableClicked) - self.gui.variablesWidget.setItemWidget(self, 5, actionButtonWidget) - self.actionButtonWidget = actionButtonWidget - else: - self.gui.variablesWidget.removeItemWidget(self, 5) - self.actionButtonWidget = None - - def refresh_value(self): - value = self.variable.value - - if isinstance(value, np.ndarray): - value_str = array_to_str(value) - elif isinstance(value, pd.DataFrame): - value_str = dataframe_to_str(value) - else: - value_str = str(value) - - self.valueWidget.setText(value_str) - self.typeWidget.setText(str(type(value).__name__)) - - def changeRawValue(self): - name = self.name - raw_value = self.rawValueWidget.text() - try: - if not has_eval(raw_value): - raw_value = convert_str_to_data(raw_value) - else: - # get all variables - pattern1 = r'[a-zA-Z][a-zA-Z0-9._]*' - matches1 = re.findall(pattern1, raw_value) - # get variables not unclosed by ' or " (gives bad name so needs to check with all variables) - pattern2 = r'(? str: - ''' Returns a list of all the drivers with categories by sections (autolab drivers, local drivers) ''' - drivers.update_drivers_paths() +def _list_drivers(_print: bool = True) -> str: + ''' Returns a list of all the drivers with categories by sections + (autolab drivers, local drivers) ''' + update_drivers_paths() s = '\n' - s += f'{len(drivers.DRIVERS_PATHS)} drivers found\n\n' + s += f'{len(DRIVERS_PATHS)} drivers found\n\n' - for i, (source_name, source) in enumerate(paths.DRIVER_SOURCES.items()): - sub_driver_list = sorted([key for key, val in drivers.DRIVERS_PATHS.items() if val['source']==source_name]) + for i, (source_name, source) in enumerate(DRIVER_SOURCES.items()): + sub_driver_list = sorted([key for key, val in DRIVERS_PATHS.items( + ) if val['source'] == source_name]) s += f'Drivers in {source}:\n' if len(sub_driver_list) > 0: - txt_list = [[f' - {driver_name}', - f'({drivers.get_driver_category(driver_name)})'] + txt_list = [[f' - {driver_name}', + f'({get_driver_category(driver_name)})'] for driver_name in sub_driver_list] - s += utilities.two_columns(txt_list) + '\n\n' + s += two_columns(txt_list) + '\n\n' else: - if (i + 1) == len(paths.DRIVER_SOURCES): - s += ' \n\n' + if (i + 1) == len(DRIVER_SOURCES): + s += ' \n\n' else: - s += ' (or overwritten)\n\n' + s += ' (or overwritten)\n\n' if _print: print(s) @@ -38,19 +43,20 @@ def list_drivers(_print: bool = True) -> str: return s -def list_devices(_print: bool = True) -> str: - ''' Returns a list of all the devices and their associated drivers from devices_config.ini ''' +def _list_devices(_print: bool = True) -> str: + ''' Returns a list of all the devices and their associated drivers + from devices_config.ini ''' # Gather local config informations - devices_names = devices.list_devices() - devices_names_loaded = devices.list_loaded_devices() + devices_names = list_devices() + devices_names_loaded = list_loaded_devices() # Build infos str for devices s = '\n' s += f'{len(devices_names)} devices found\n\n' - txt_list = [[f' - {name} ' + ('[loaded]' if name in devices_names_loaded else ''), - f'({config.get_device_config(name)["driver"]})'] - for name in devices_names] - s += utilities.two_columns(txt_list) + '\n' + txt_list = [ + [f' - {name} ' + ('[loaded]' if name in devices_names_loaded else ''), + f'({get_device_config(name)["driver"]})'] for name in devices_names] + s += two_columns(txt_list) + '\n' if _print: print(s) @@ -59,10 +65,11 @@ def list_devices(_print: bool = True) -> str: def infos(_print: bool = True) -> str: - ''' Returns a list of all the drivers and all the devices, along with their associated drivers from devices_config.ini ''' + ''' Returns a list of all the drivers and all the devices, + along with their associated drivers from devices_config.ini ''' s = '' - s += list_drivers(_print=False) - s += list_devices(_print=False) + s += _list_drivers(_print=False) + s += _list_devices(_print=False) if _print: print(s) @@ -76,31 +83,31 @@ def infos(_print: bool = True) -> str: def config_help(driver_name: str, _print: bool = True, _parser: bool = False) -> str: ''' Display the help of a particular driver (connection types, modules, ...) ''' try: - driver_name = devices.get_final_device_config(driver_name)["driver"] + driver_name = get_final_device_config(driver_name)["driver"] except: pass # Load list of all parameters try: - driver_lib = drivers.load_driver_lib(driver_name) + driver_lib = load_driver_lib(driver_name) except Exception as e: print(f"Can't load {driver_name}: {e}", file=sys.stderr) return None params = {} params['driver'] = driver_name params['connection'] = {} - for conn in drivers.get_connection_names(driver_lib): - params['connection'][conn] = drivers.get_class_args( - drivers.get_connection_class(driver_lib, conn)) - params['other'] = drivers.get_class_args(drivers.get_driver_class(driver_lib)) - if hasattr(drivers.get_driver_class(driver_lib), 'slot_config'): - params['other']['slot1'] = f'{drivers.get_driver_class(driver_lib).slot_config}' + for conn in get_connection_names(driver_lib): + params['connection'][conn] = get_class_args( + get_connection_class(driver_lib, conn)) + params['other'] = get_class_args(get_driver_class(driver_lib)) + if hasattr(get_driver_class(driver_lib), 'slot_config'): + params['other']['slot1'] = f'{get_driver_class(driver_lib).slot_config}' params['other']['slot1_name'] = 'my_' mess = '\n' # Name and category if available - submess = f'Driver "{driver_name}" ({drivers.get_driver_category(driver_name)})' - mess += utilities.emphasize(submess, sign='=') + '\n' + submess = f'Driver "{driver_name}" ({get_driver_category(driver_name)})' + mess += emphasize(submess, sign='=') + '\n' # Connections types c_option=' (-C option)' if _parser else '' @@ -110,30 +117,30 @@ def config_help(driver_name: str, _print: bool = True, _parser: bool = False) -> mess += '\n' # Modules - if hasattr(drivers.get_driver_class(driver_lib), 'slot_config'): + if hasattr(get_driver_class(driver_lib), 'slot_config'): mess += 'Available modules:\n' - modules = drivers.get_module_names(driver_lib) + modules = get_module_names(driver_lib) for module in modules: - moduleClass = drivers.get_module_class(driver_lib, module) + moduleClass = get_module_class(driver_lib, module) mess += f' - {module}' if hasattr(moduleClass, 'category'): mess += f' ({moduleClass.category})' mess += '\n' mess += '\n' # Example of a devices_config.ini section - mess += '\n' + utilities.underline( + mess += '\n' + underline( 'Saving a Device configuration in devices_config.ini:') + '\n' for conn in params['connection']: - mess += f"\n [my_{params['driver']}]\n" - mess += f" driver = {params['driver']}\n" - mess += f" connection = {conn}\n" + mess += f"\n[my_{params['driver']}]\n" + mess += f"driver = {params['driver']}\n" + mess += f"connection = {conn}\n" for arg, value in params['connection'][conn].items(): - mess += f" {arg} = {value}\n" + mess += f"{arg} = {value}\n" for arg, value in params['other'].items(): - mess += f" {arg} = {value}\n" + mess += f"{arg} = {value}\n" # Example of get_driver - mess += '\n' + utilities.underline('Loading a Driver:') + '\n\n' + mess += '\n\n' + underline('Loading a Driver:') + '\n\n' for conn in params['connection']: if not _parser: args_str = f"'{params['driver']}', connection='{conn}'" @@ -144,7 +151,7 @@ def config_help(driver_name: str, _print: bool = True, _parser: bool = False) -> args_str += f", {arg}='{value}'" else: args_str += f", {arg}={value}" - mess += f" a = autolab.get_driver({args_str})\n" + mess += f"a = autolab.get_driver({args_str})\n" else: args_str = f"-D {params['driver']} -C {conn} " for arg,value in params['connection'][conn].items(): @@ -153,15 +160,15 @@ def config_help(driver_name: str, _print: bool = True, _parser: bool = False) -> if len(params['other']) > 0: args_str += '-O ' for arg,value in params['other'].items(): args_str += f"{arg}={value} " - mess += f" autolab driver {args_str} -m method(value) \n" + mess += f"autolab driver {args_str} -m method(value) \n" # Example of get_device - mess += '\n\n' + utilities.underline( + mess += '\n\n' + underline( 'Loading a Device configured in devices_config.ini:') + '\n\n' if not _parser: - mess += f" a = autolab.get_device('my_{params['driver']}')" + mess += f"a = autolab.get_device('my_{params['driver']}')" else: - mess += f" autolab device -D my_{params['driver']} -e element -v value \n" + mess += f"autolab device -D my_{params['driver']} -e element -v value \n" if _print: print(mess) diff --git a/autolab/core/paths.py b/autolab/core/paths.py index 8725c115..905101e1 100644 --- a/autolab/core/paths.py +++ b/autolab/core/paths.py @@ -7,22 +7,21 @@ import os - AUTOLAB_FOLDER = os.path.dirname(os.path.dirname(__file__)) VERSION = os.path.join(AUTOLAB_FOLDER, 'version.txt') +LAST_FOLDER = os.path.expanduser('~') USER_FOLDER = os.path.join(os.path.expanduser('~'), 'autolab') -USER_LAST_CUSTOM_FOLDER = os.path.expanduser('~') DEVICES_CONFIG = os.path.join(USER_FOLDER, 'devices_config.ini') AUTOLAB_CONFIG = os.path.join(USER_FOLDER, 'autolab_config.ini') PLOTTER_CONFIG = os.path.join(USER_FOLDER, 'plotter_config.ini') HISTORY_CONFIG = os.path.join(USER_FOLDER, '.history_config.txt') # Drivers locations -DRIVERS = os.path.join(USER_FOLDER,'drivers') +DRIVERS = os.path.join(USER_FOLDER, 'drivers') DRIVER_LEGACY = {'official': os.path.join(AUTOLAB_FOLDER, 'drivers'), - 'local': os.path.join(USER_FOLDER, 'local_drivers')} + 'local': os.path.join(USER_FOLDER, 'local_drivers')} # can add paths in autolab_config.ini [extra_driver_path] DRIVER_SOURCES = {'official': os.path.join(DRIVERS, 'official'), 'local': os.path.join(DRIVERS, 'local')} @@ -31,3 +30,12 @@ # can add paths in autolab_config.ini [extra_driver_url_repo] # format is {'path to install': 'url to download'} DRIVER_REPOSITORY = {DRIVER_SOURCES['official']: 'https://github.com/autolab-project/autolab-drivers'} + +PATHS = {'autolab_folder': AUTOLAB_FOLDER, 'version': VERSION, + 'user_folder': USER_FOLDER, 'drivers': DRIVERS, + 'devices_config': DEVICES_CONFIG, 'autolab_config': AUTOLAB_CONFIG, + 'plotter_config': PLOTTER_CONFIG, 'history_config': HISTORY_CONFIG, + 'last_folder': LAST_FOLDER} + +# Storage of the drivers paths +DRIVERS_PATHS = {} diff --git a/autolab/core/repository.py b/autolab/core/repository.py index 10377269..793f3123 100644 --- a/autolab/core/repository.py +++ b/autolab/core/repository.py @@ -14,8 +14,8 @@ import json from typing import Union, Tuple -from . import paths -from . import drivers +from .paths import DRIVER_SOURCES, DRIVER_REPOSITORY +from .drivers import update_drivers_paths from .utilities import input_wrap from .gitdir import download @@ -117,25 +117,21 @@ def _copy_move(temp_unzip_repo, filename, output_dir): def _check_empty_driver_folder(): - if not os.listdir(paths.DRIVER_SOURCES['official']): - print(f"No drivers found in {paths.DRIVER_SOURCES['official']}") + if not os.listdir(DRIVER_SOURCES['official']): + print(f"No drivers found in {DRIVER_SOURCES['official']}") install_drivers() def install_drivers(*repo_url: Union[None, str, Tuple[str, str]], - skip_input=False, experimental_feature=False): + skip_input=False): """ Ask if want to install drivers from repo url. repo_url: can be url or tuple ('path to install', 'url to download'). If no argument passed, download official drivers to official driver folder. If only url given, use official driver folder. Also install mandatory drivers (system, dummy, plotter) from official repo.""" - if experimental_feature: - _install_drivers_custom() - return None - # Download mandatory drivers - official_folder = paths.DRIVER_SOURCES['official'] - official_url = paths.DRIVER_REPOSITORY[official_folder] + official_folder = DRIVER_SOURCES['official'] + official_url = DRIVER_REPOSITORY[official_folder] mandatory_drivers = ['system', 'dummy', 'plotter'] for driver in mandatory_drivers: @@ -149,7 +145,7 @@ def install_drivers(*repo_url: Union[None, str, Tuple[str, str]], # create list of tuple with tuple being ('path to install', 'url to download') if len(repo_url) == 0: - list_repo_tuple = list(paths.DRIVER_REPOSITORY.items()) # This variable can be modified in autolab_config.ini + list_repo_tuple = list(DRIVER_REPOSITORY.items()) # This variable can be modified in autolab_config.ini else: list_repo_tuple = list(repo_url) for i, repo_url_tmp in enumerate(list_repo_tuple): @@ -185,7 +181,7 @@ def install_drivers(*repo_url: Union[None, str, Tuple[str, str]], os.rmdir(temp_repo_folder) # Update available drivers - drivers.update_drivers_paths() + update_drivers_paths() # ============================================================================= @@ -234,151 +230,3 @@ def _download_driver(url, driver_name, output_dir, _print=True): print(e, file=sys.stderr) else: return e - - -def _install_drivers_custom(_print=True, parent=None): - """ Ask the user which driver to install from the official autolab driver github repo. - If qtpy is install, open a GUI to select the driver. - Else, prompt the user to install individual drivers. """ - official_folder = paths.DRIVER_SOURCES['official'] - official_url = paths.DRIVER_REPOSITORY[official_folder] - - try: - list_driver = _get_drivers_list_from_github(official_url) - except: - print(f'Cannot access {official_url}, skip installation') - return None - - try: - from qtpy import QtWidgets, QtGui - except: - print("No qtpy installed. Using the console to install drivers instead") - - if _print: - print(f"Drivers will be downloaded to {official_folder}") - for driver_name in list_driver: - ans = input(f'Download {driver_name}? [default:yes] > ') # didn't use input_wrap because don't want to say yes to download all drivers - if ans.strip().lower() == 'stop': - break - if ans.strip().lower() != 'no': - _download_driver(official_url, driver_name, official_folder, _print=_print) - else: - - class DriverInstaller(QtWidgets.QMainWindow): - - def __init__(self, url, list_driver, OUTPUT_DIR, parent=None): - """ GUI to select which driver to install from the official github repo """ - - self.gui = parent - self.url = url - self.list_driver = list_driver - self.OUTPUT_DIR = OUTPUT_DIR - - super().__init__(parent) - - self.setWindowTitle("Autolab - Driver Installer") - self.setFocus() - self.activateWindow() - - self.statusBar = self.statusBar() - - centralWidget = QtWidgets.QWidget() - self.setCentralWidget(centralWidget) - - # OFFICIAL DRIVERS - formLayout = QtWidgets.QFormLayout() - - self.masterCheckBox = QtWidgets.QCheckBox(f"From {paths.DRIVER_REPOSITORY[paths.DRIVER_SOURCES['official']]}:") - self.masterCheckBox.setChecked(False) - self.masterCheckBox.stateChanged.connect(self.masterCheckBoxChanged) - formLayout.addRow(self.masterCheckBox) - - # Init table size - sti = QtGui.QStandardItemModel() - for i in range(len(self.list_driver)): - sti.appendRow([QtGui.QStandardItem(str())]) - - # Create table - tab = QtWidgets.QTableView() - tab.setModel(sti) - tab.verticalHeader().setVisible(False) - tab.horizontalHeader().setVisible(False) - tab.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) - tab.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection) - tab.setAlternatingRowColors(True) - tab.horizontalHeader().setSectionResizeMode( - 0, QtWidgets.QHeaderView.ResizeToContents) - tab.setSizePolicy(QtWidgets.QSizePolicy.Expanding, - QtWidgets.QSizePolicy.Expanding) - tab.setSizeAdjustPolicy(tab.AdjustToContents) - - # Init checkBox - self.list_checkBox = [] - for i, driver_name in enumerate(self.list_driver): - checkBox = QtWidgets.QCheckBox(f"{driver_name}") - checkBox.setChecked(False) - self.list_checkBox.append(checkBox) - tab.setIndexWidget(sti.index(i, 0), checkBox) - - formLayout.addRow(QtWidgets.QLabel(""), tab) - - download_pushButton = QtWidgets.QPushButton() - download_pushButton.clicked.connect(self.installListDriver) - download_pushButton.setText("Download") - formLayout.addRow(download_pushButton) - - centralWidget.setLayout(formLayout) - - def masterCheckBoxChanged(self): - """ Checked all the checkBox related to the official github repo """ - state = self.masterCheckBox.isChecked() - for checkBox in self.list_checkBox: - checkBox.setChecked(state) - - def installListDriver(self): - """ Install all the drivers for which the corresponding checkBox has been checked """ - list_bool = [ - checkBox.isChecked() for checkBox in self.list_checkBox] - list_driver_to_download = [ - driver_name for (driver_name, driver_bool) in zip( - self.list_driver, list_bool) if driver_bool] - - if all(list_bool): # Better for all drivers - install_drivers(skip_input=True, experimental_feature=False) - self.close() - elif any(list_bool): # Better for couple drivers - for driver_name in list_driver_to_download: - if _print: - print(f"Downloading {driver_name}") - # self.setStatus(f"Downloading {driver_name}", 5000) # OPTIMIZE: currently thread blocked by installer so don't show anything until the end - e = _download_driver(self.url, driver_name, - self.OUTPUT_DIR, _print=False) - if e is not None: - print(e, file=sys.stderr) - # self.setStatus(e, 10000, False) - self.setStatus('Finished!', 5000) - - def closeEvent(self, event): - """ This function does some steps before the window is really killed """ - super().closeEvent(event) - - if self.gui is None: - QtWidgets.QApplication.quit() # close the app - - def setStatus(self, message: str, timeout: int = 0, stdout: bool = True): - """ Modify the message displayed in the status bar and add error message to logger """ - self.statusBar.showMessage(message, timeout) - if not stdout: print(message, file=sys.stderr) - - if parent is None: - if _print: print("Open driver installer") - app = QtWidgets.QApplication.instance() - if app is None: app = QtWidgets.QApplication([]) - - driverInstaller = DriverInstaller( - official_url, list_driver, official_folder, parent=parent) - driverInstaller.show() - if parent is None: app.exec() - - # Update available drivers - drivers.update_drivers_paths() diff --git a/autolab/core/server.py b/autolab/core/server.py index 9460c967..76f93d09 100644 --- a/autolab/core/server.py +++ b/autolab/core/server.py @@ -3,10 +3,12 @@ import socket import pickle import threading -from . import config, devices import datetime as dt from functools import partial +from .config import get_server_config +from .devices import get_devices_status, get_device + class Driver_SOCKET(): @@ -110,14 +112,14 @@ def process_command(self, command): if command == 'CLOSE_CONNECTION': self.stop_flag.set() elif command == 'DEVICES_STATUS?': - return self.write(devices.get_devices_status()) + return self.write(get_devices_status()) else: if command['command'] == 'get_device_model': device_name = command['device_name'] - structure = devices.get_device(device_name).get_structure() + structure = get_device(device_name).get_structure() self.write(structure) elif command['command'] == 'request': - devices.get_devices(device_name).get_by_adress(command['element_adress']) + get_device(device_name).get_by_adress(command['element_adress']) # element_address --> my_yenista::submodule::wavelength wavelength() @@ -148,7 +150,7 @@ def __init__(self, port=None): self.active_connection_thread = None # Load server config in autolab_config.ini - server_config = config.get_server_config() + server_config = get_server_config() if not port: port = int(server_config['port']) self.port = port diff --git a/autolab/core/utilities.py b/autolab/core/utilities.py index eb9048fe..25492aaa 100644 --- a/autolab/core/utilities.py +++ b/autolab/core/utilities.py @@ -4,7 +4,7 @@ @author: qchat """ -from typing import Any, List +from typing import Any, List, Tuple import re import ast from io import StringIO @@ -15,7 +15,7 @@ import pandas as pd -SUPPORTED_EXTENSION = "Text Files (*.txt);; Supported text Files (*.txt;*.csv;*.dat);; All Files (*)" +SUPPORTED_EXTENSION = "Text Files (*.txt);; Supported text Files (*.txt;*.csv;*.dat);; Any Files (*)" def emphasize(txt: str, sign: str = '-') -> str: @@ -81,12 +81,33 @@ def str_to_value(s: str) -> Any: return s +def str_to_tuple(s: str) -> Tuple[List[str], int]: + ''' Convert string to Tuple[List[str], int] ''' + e = "Input string does not match the required format Tuple[List[str], int]" + try: + result = ast.literal_eval(s) + e = f"{result} does not match the required format Tuple[List[str], int]" + assert (isinstance(result, (tuple, list)) + and len(result) == 2 + and isinstance(result[0], (list, tuple)) + and isinstance(result[1], int)), e + result = ([str(res) for res in result[0]], result[1]) + return result + except Exception: + raise Exception(e) + def create_array(value: Any) -> np.ndarray: - ''' Format an int, float, list or numpy array to a numpy array with minimal one dimension ''' - # ndim=1 to avoid having float if 0D - array = np.array(value, ndmin=1, dtype=float) # check validity of array - array = np.array(value, ndmin=1, copy=False) # keep original dtype - return array + ''' Format an int, float, list or numpy array to a numpy array with at least + one dimension ''' + # check validity of array, raise error if dtype not int or float + np.array(value, ndmin=1, dtype=float) + # Convert to ndarray and keep original dtype + value = np.asarray(value) + + # want ndim >= 1 to avoid having float if 0D + while value.ndim < 1: + value = np.expand_dims(value, axis=0) + return value def str_to_array(s: str) -> np.ndarray: @@ -106,9 +127,11 @@ def array_to_str(value: Any, threshold: int = None, max_line_width: int = None) def str_to_dataframe(s: str) -> pd.DataFrame: ''' Convert a string to a pandas DataFrame ''' - value_io = StringIO(s) - # TODO: find sep (use \t to be compatible with excel but not nice to write by hand) - df = pd.read_csv(value_io, sep="\t") + if s == '\r\n': # empty + df = pd.DataFrame() + else: + value_io = StringIO(s) + df = pd.read_csv(value_io, sep="\t") return df @@ -118,7 +141,32 @@ def dataframe_to_str(value: pd.DataFrame, threshold=1000) -> str: return pd.DataFrame(value).head(threshold).to_csv(index=False, sep="\t") # can't display full data to QLineEdit, need to truncate (numpy does the same) -def openFile(filename: str): +def str_to_data(s: str) -> Any: + """ Convert str to data with special format for ndarray and dataframe """ + if '\t' in s and '\n' in s: + try: s = str_to_dataframe(s) + except: pass + elif '[' in s: + try: s = str_to_array(s) + except: pass + else: + try: s = str_to_value(s) + except: pass + return s + + +def data_to_str(value: Any) -> str: + """ Convert data to str with special format for ndarray and dataframe """ + if isinstance(value, np.ndarray): + raw_value_str = array_to_str(value, threshold=1000000, max_line_width=9000000) + elif isinstance(value, pd.DataFrame): + raw_value_str = dataframe_to_str(value, threshold=1000000) + else: + raw_value_str = str(value) + return raw_value_str + + +def open_file(filename: str): ''' Opens a file using the platform specific command ''' system = platform.system() if system == 'Windows': os.startfile(filename) @@ -126,7 +174,7 @@ def openFile(filename: str): elif system == 'Darwin': os.system(f'open "{filename}"') -def formatData(data: Any) -> pd.DataFrame: +def data_to_dataframe(data: Any) -> pd.DataFrame: """ Format data to DataFrame """ try: data = pd.DataFrame(data) except ValueError: data = pd.DataFrame([data]) @@ -140,12 +188,12 @@ def formatData(data: Any) -> pd.DataFrame: pass # OPTIMIZE: This happens when there is identical column name if len(data) != 0: - assert not data.isnull().values.all(), f"Datatype '{data_type}' not supported" + assert not data.isnull().values.all(), f"Datatype '{data_type}' is not supported" if data.iloc[-1].isnull().values.all(): # if last line is full of nan, remove it data = data[:-1] if data.shape[1] == 1: - data.rename(columns = {'0':'1'}, inplace=True) + data.rename(columns = {'0': '1'}, inplace=True) data.insert(0, "0", range(data.shape[0])) return data diff --git a/autolab/core/variables.py b/autolab/core/variables.py new file mode 100644 index 00000000..a73fcac4 --- /dev/null +++ b/autolab/core/variables.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +""" +Created on Mon Mar 4 14:54:41 2024 + +@author: Jonathan +""" + +import re +from typing import Any, List, Tuple + +import numpy as np +import pandas as pd + +from .devices import DEVICES +from .utilities import clean_string + + +# class AddVarSignal(QtCore.QObject): +# add = QtCore.Signal(object, object) +# def emit_add(self, name, value): +# self.add.emit(name, value) + + +# class RemoveVarSignal(QtCore.QObject): +# remove = QtCore.Signal(object) +# def emit_remove(self, name): +# self.remove.emit(name) + + +# class MyDict(dict): + +# def __init__(self): +# self.addVarSignal = AddVarSignal() +# self.removeVarSignal = RemoveVarSignal() + +# def __setitem__(self, item, value): +# super(MyDict, self).__setitem__(item, value) +# self.addVarSignal.emit_add(item, value) + +# def pop(self, item): +# super(MyDict, self).pop(item) +# self.removeVarSignal.emit_remove(item) + + +# VARIABLES = MyDict() +VARIABLES = {} + +EVAL = "$eval:" + + +def update_allowed_dict() -> dict: + global allowed_dict # needed to remove variables instead of just adding new one + allowed_dict = {"np": np, "pd": pd} + allowed_dict.update(DEVICES) + allowed_dict.update(VARIABLES) + return allowed_dict + + +allowed_dict = update_allowed_dict() + +# OPTIMIZE: Variable becomes closer and closer to core.elements.Variable, could envision a merge +# TODO: refresh menu display by looking if has eval (no -> can refresh) +# TODO add read signal to update gui (separate class for event and use it on itemwidget creation to change setText with new value) +class Variable(): + """ Class used to control basic variable """ + + raw: Any + value: Any + + def __init__(self, name: str, var: Any): + """ name: name of the variable, var: value of the variable """ + self.unit = None + self.writable = True + self.readable = True + self._rename(name) + self.write_function(var) + + def _rename(self, new_name: str): + self.name = new_name + self.address = lambda: new_name + + def write_function(self, var: Any): + if isinstance(var, Variable): + self.raw = var.raw + self.value = var.value + else: + self.raw = var + self.value = 'Need update' if has_eval(self.raw) else self.raw + + # If no devices or variables with char '(' found in raw, can evaluate value safely + if not has_variable(self.raw) or '(' not in self.raw: + try: self.value = self.read_function() + except Exception as e: self.value = str(e) + + self.type = type(self.raw) # For slider + + def read_function(self): + if has_eval(self.raw): + value = str(self.raw)[len(EVAL): ] + call = eval(str(value), {}, allowed_dict) + self.value = call + else: + call = self.value + + return call + + def __call__(self, value: Any = None) -> Any: + if value is None: + return self.read_function() + + self.write_function(value) + return None + + +def list_variables() -> List[str]: + ''' Returns a list of Variables ''' + return list(VARIABLES) + + +def rename_variable(name: str, new_name: str): + ''' Rename an existing Variable ''' + new_name = clean_string(new_name) + var = VARIABLES.pop(name) + VARIABLES[new_name] = var + var._rename(new_name) + update_allowed_dict() + + +def set_variable(name: str, value: Any) -> Variable: + ''' Create or modify a Variable with provided name and value ''' + name = clean_string(name) + + if is_Variable(value): + var = value + var(value) + else: + if name in VARIABLES: + var = get_variable(name) + var(value) + else: + var = Variable(name, value) + + VARIABLES[name] = var + update_allowed_dict() + return var + + +def get_variable(name: str) -> Variable: + ''' Return Variable with provided name if exists else None ''' + assert name in VARIABLES, f"Variable name '{name}' not found in {list_variables()}" + return VARIABLES[name] + + +def remove_variable(name: str) -> Variable: + var = VARIABLES.pop(name) + update_allowed_dict() + return var + + +def remove_from_config(variables: List[Tuple[str, Any]]): + for name, _ in variables: + if name in VARIABLES: + remove_variable(name) + + +def update_from_config(variables: List[Tuple[str, Any]]): + for var in variables: + set_variable(var[0], var[1]) + + +def has_variable(value: str) -> bool: + if not isinstance(value, str): return False + if has_eval(value): value = value[len(EVAL): ] + + pattern = r'[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*' + pattern_match = [var.split('.')[0] for var in re.findall(pattern, value)] + + for key in (list(DEVICES) + list(VARIABLES)): + if key in pattern_match: + return True + return False + + +def has_eval(value: Any) -> bool: + """ Checks if value is a string starting with '$eval:'""" + return True if isinstance(value, str) and value.startswith(EVAL) else False + + +def is_Variable(value: Any): + """ Returns True if value of type Variable """ + return isinstance(value, Variable) + + +def eval_variable(value: Any) -> Any: + """ Evaluate the given python string. String can contain variables, + devices, numpy arrays and pandas dataframes.""" + if has_eval(value): value = Variable('temp', value) + + if is_Variable(value): return value() + return value + + +def eval_safely(value: Any) -> Any: + """ Same as eval_variable but do not evaluate if contains devices or variables """ + if has_eval(value): value = Variable('temp', value) + + if is_Variable(value): return value.value + return value diff --git a/autolab/core/version_adapter.py b/autolab/core/version_adapter.py index e0ba4efb..bfd43b80 100644 --- a/autolab/core/version_adapter.py +++ b/autolab/core/version_adapter.py @@ -3,6 +3,9 @@ import os import shutil +from .paths import PATHS, DRIVER_LEGACY, DRIVER_SOURCES +from .repository import install_drivers + def process_all_changes(): ''' Apply all changes ''' @@ -12,31 +15,28 @@ def process_all_changes(): def rename_old_devices_config_file(): ''' Rename local_config.ini into devices_config.ini''' - from .paths import USER_FOLDER - if os.path.exists(os.path.join(USER_FOLDER, 'local_config.ini')): - os.rename(os.path.join(USER_FOLDER, 'local_config.ini'), - os.path.join(USER_FOLDER, 'devices_config.ini')) + if (not os.path.exists(os.path.join(PATHS['user_folder'], 'devices_config.ini')) + and os.path.exists(os.path.join(PATHS['user_folder'], 'local_config.ini'))): + os.rename(os.path.join(PATHS['user_folder'], 'local_config.ini'), + os.path.join(PATHS['user_folder'], 'devices_config.ini')) def move_driver(): """ Move old driver directory to new one """ - from .paths import USER_FOLDER, DRIVERS, DRIVER_LEGACY, DRIVER_SOURCES - from .repository import install_drivers - - if os.path.exists(os.path.join(USER_FOLDER)) and not os.path.exists(DRIVERS): - os.mkdir(DRIVERS) - print(f"The new driver directory has been created: {DRIVERS}") + if os.path.exists(os.path.join(PATHS['user_folder'])) and not os.path.exists(PATHS['drivers']): + os.mkdir(PATHS['drivers']) + print(f"The new driver directory has been created: {PATHS['drivers']}") - # Inside os.path.exists(DRIVERS) condition to avoid moving drivers from current repo everytime autolab is started + # Inside os.path.exists(PATHS['drivers']) condition to avoid moving drivers from current repo everytime autolab is started if os.path.exists(DRIVER_LEGACY['official']): - shutil.move(DRIVER_LEGACY['official'], DRIVERS) - os.rename(os.path.join(DRIVERS, os.path.basename(DRIVER_LEGACY['official'])), + shutil.move(DRIVER_LEGACY['official'], PATHS['drivers']) + os.rename(os.path.join(PATHS['drivers'], os.path.basename(DRIVER_LEGACY['official'])), DRIVER_SOURCES['official']) print(f"Old official drivers directory has been moved from: {DRIVER_LEGACY['official']} to: {DRIVER_SOURCES['official']}") install_drivers() # Ask if want to download official drivers if os.path.exists(DRIVER_LEGACY["local"]): - shutil.move(DRIVER_LEGACY['local'], DRIVERS) - os.rename(os.path.join(DRIVERS, os.path.basename(DRIVER_LEGACY['local'])), + shutil.move(DRIVER_LEGACY['local'], PATHS['drivers']) + os.rename(os.path.join(PATHS['drivers'], os.path.basename(DRIVER_LEGACY['local'])), DRIVER_SOURCES['local']) print(f"Old local drivers directory has been moved from: {DRIVER_LEGACY['local']} to: {DRIVER_SOURCES['local']}") diff --git a/autolab/core/web.py b/autolab/core/web.py index 79141603..7c049c8d 100644 --- a/autolab/core/web.py +++ b/autolab/core/web.py @@ -10,6 +10,7 @@ import os import inspect +from .utilities import open_file project_url = 'https://github.com/autolab-project/autolab' drivers_url = 'https://github.com/autolab-project/autolab-drivers' @@ -26,7 +27,7 @@ def doc(online: bool = "default"): Can open online or offline documentation by using True or False.""" if online == "default": - if has_internet(): webbrowser.open(doc_url) + if has_internet(False): webbrowser.open(doc_url) else: print("No internet connection found. Open local pdf documentation instead") doc_offline() @@ -37,11 +38,11 @@ def doc(online: bool = "default"): def doc_offline(): dirname = os.path.dirname(os.path.abspath(inspect.stack()[0][1])) filename = os.path.join(dirname, "../autolab.pdf") - if os.path.exists(filename): os.startfile(filename) + if os.path.exists(filename): open_file(filename) else: print("No local pdf documentation found at {filename}") -def has_internet() -> bool: +def has_internet(_print=True) -> bool: """ https://stackoverflow.com/questions/20913411/test-if-an-internet-connection-is-present-in-python#20913928 """ try: # see if we can resolve the host name -- tells us if there is @@ -53,5 +54,5 @@ def has_internet() -> bool: return True except Exception: pass # we ignore any errors, returning False - print("No internet connection found") + if _print: print("No internet connection found") return False diff --git a/autolab/version.txt b/autolab/version.txt index 7fb48b5e..cd5ac039 100644 --- a/autolab/version.txt +++ b/autolab/version.txt @@ -1 +1 @@ -2.0rc1 +2.0 diff --git a/docs/conf.py b/docs/conf.py index 3ab86ad5..663a9b70 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,8 +30,11 @@ # -- Project information ----------------------------------------------------- project = 'Autolab' -copyright = '2024, Quentin Chateiller & Bruno Garbin & Jonathan Peltier & Mathieu Jeannin, (C2N-CNRS)' -author = 'Quentin Chateiller & Bruno Garbin & Jonathan Peltier & Mathieu Jeannin' +copyright = ( + "2019-2020 Quentin Chateiller and Bruno Garbin (C2N-CNRS), " + "2021-2024 Jonathan Peltier and Mathieu Jeannin (C2N-CNRS)" +) +author = 'Q. Chateiller, B. Garbin, J. Peltier and M. Jeannin' # The full version, including alpha/beta/rc tags release = version diff --git a/docs/example_config.conf b/docs/example_config.conf index 01a445b9..205f636e 100644 --- a/docs/example_config.conf +++ b/docs/example_config.conf @@ -1,16 +1,20 @@ { "autolab": { - "version": "1.2.1", - "timestamp": "2023-12-12 22:49:28.540876" + "version": "2.0", + "timestamp": "2024-07-16 13:58:04.710505" }, "recipe_1": { + "name": "recipe_1", + "active": "True", "parameter": { - "name": "parameter_buffer", - "address": "system.parameter_buffer", - "nbpts": "101", - "start_value": "0", - "end_value": "10", - "log": "0" + "parameter_1": { + "name": "parameter_buffer", + "address": "system.parameter_buffer", + "nbpts": "101", + "start_value": "0.0", + "end_value": "10.0", + "log": "0" + } }, "recipe": { "1_name": "set_amplitude", diff --git a/docs/example_config2.conf b/docs/example_config2.conf index d2a55eba..4cdd67e4 100644 --- a/docs/example_config2.conf +++ b/docs/example_config2.conf @@ -1,9 +1,11 @@ { "autolab": { - "version": "1.2.2", - "timestamp": "2024-01-24 23:39:44.250166" + "version": "2.0", + "timestamp": "2024-07-16 13:58:12.991910" }, "recipe_1": { + "name": "recipe_1", + "active": "True", "parameter": { "parameter_1": { "name": "parameter", @@ -21,17 +23,18 @@ "2_name": "array_1D", "2_steptype": "measure", "2_address": "mydummy.array_1D" - }, - "active": "True" + } }, "recipe_2": { + "name": "recipe_2", + "active": "True", "parameter": { "parameter_1": { "name": "parameter", "address": "None", "nbpts": "6", - "start_value": "0", - "end_value": "10", + "start_value": "0.0", + "end_value": "10.0", "log": "0" } }, @@ -42,7 +45,6 @@ "2_name": "phase", "2_steptype": "measure", "2_address": "mydummy.phase" - }, - "active": "True" + } } } \ No newline at end of file diff --git a/docs/example_config3.conf b/docs/example_config3.conf new file mode 100644 index 00000000..fe25864d --- /dev/null +++ b/docs/example_config3.conf @@ -0,0 +1,49 @@ +{ + "autolab": { + "version": "2.0", + "timestamp": "2024-07-16 14:40:23.354948" + }, + "recipe_1": { + "name": "recipe", + "active": "True", + "parameter": {}, + "recipe": { + "1_name": "amplitude", + "1_steptype": "set", + "1_address": "mydummy.amplitude", + "1_value": "0", + "2_name": "wait", + "2_steptype": "action", + "2_address": "system.wait", + "2_value": "1.5", + "3_name": "amplitude_1", + "3_steptype": "set", + "3_address": "mydummy.amplitude", + "3_value": "10", + "4_name": "wait_1", + "4_steptype": "action", + "4_address": "system.wait", + "4_value": "1.5", + "5_name": "amplitude_2", + "5_steptype": "set", + "5_address": "mydummy.amplitude", + "5_value": "5", + "6_name": "wait_2", + "6_steptype": "action", + "6_address": "system.wait", + "6_value": "2", + "7_name": "amplitude_3", + "7_steptype": "set", + "7_address": "mydummy.amplitude", + "7_value": "0", + "8_name": "wait_3", + "8_steptype": "action", + "8_address": "system.wait", + "8_value": "2", + "9_name": "amplitude_4", + "9_steptype": "set", + "9_address": "mydummy.amplitude", + "9_value": "10" + } + } +} \ No newline at end of file diff --git a/docs/gui/about.png b/docs/gui/about.png new file mode 100644 index 00000000..3020d8d2 Binary files /dev/null and b/docs/gui/about.png differ diff --git a/docs/gui/add_device.png b/docs/gui/add_device.png new file mode 100644 index 00000000..7ee64514 Binary files /dev/null and b/docs/gui/add_device.png differ diff --git a/docs/gui/autocompletion.png b/docs/gui/autocompletion.png new file mode 100644 index 00000000..55b616ca Binary files /dev/null and b/docs/gui/autocompletion.png differ diff --git a/docs/gui/control_center.rst b/docs/gui/control_center.rst index d86b5119..572c3e93 100644 --- a/docs/gui/control_center.rst +++ b/docs/gui/control_center.rst @@ -3,20 +3,23 @@ Control panel ============= -The Autolab GUI Control Panel provides an easy way to control your instruments. +The Control Panel provides an easy way to control your instruments. From it, you can visualize and set the value of its *Variables*, and execute its *Action* through graphical widgets. -.. image:: control_panel.png +.. figure:: control_panel.png + :figclass: align-center + + Control panel Devices tree ------------ -By default, the name of each local configuration in represented in a tree widget. +By default, the name of each local configuration is represented in a tree widget. Click on one of them to load the associated **Device**. Then, the corresponding *Element* hierarchy appears. -Right-click to bring up the close option. +Right-click to bring up the close options. -The help of a given **Element** (see :ref:`highlevel`) can be displayed though a tooltip by passing the mouse over it (if provided in the driver files). +The help of a given **Element** (see :ref:`highlevel`) can be displayed through a tooltip by passing the mouse over it (if provided in the driver files). Actions ####### @@ -33,15 +36,16 @@ The value of a *Variable* can be set or read if its type is numerical (integer, If the *Variable* is readable (read function provided in the driver), a **Read** button is available on its line. When clicking on this button, the *Variable*'s value is read and displayed in a line edit widget (integer / float values) or in a checkbox (boolean). -If the *Variable* is writable (write function provided in the driver), its value can be edited and sent to the instrument (return pressed for interger / float values, check box checked or unchecked for boolean values). -If the *Variable* is also readable, a **Read** operation will be executed automatically after that. +If the *Variable* is writable (write function provided in the driver), its value can be edited and sent to the instrument (return pressed for integer/float values, check box checked or unchecked for boolean values). +If the *Variable* is readable, a **Read** operation will be executed automatically after that. To read and save the value of a *Variable*, right click on its line and select **Read and save as...**. You will be prompted to select the path of the output file. The colored displayed at the end of a line corresponds to the state of the displayed value: - * The orange color means that the currently displayed value is not necessary the current value of the **Variable** in the instrument. The user should click the **Read** button to update the value in the interface. + * The orange color means that the currently displayed value is not necessarily the current value of the **Variable** in the instrument. The user should click the **Read** button to update the value in the interface. + * The yellow color indicates that the currently displayed value is the last value written to the instrument, but it has not been read back to verify. * The green color means that the currently displayed value is up to date (except if the user modified its value directly on the instrument. In that case, click the **Read** button to update the value in the interface). Monitoring @@ -54,20 +58,88 @@ Please visit the section :ref:`monitoring`. Slider ------ -A readable and numerical *Variable* can be controled by a slider for convinient setting. +A readable and numerical *Variable* can be controled by a slider for convenient setting. To open the slider of this *Variable*, right click on it and select **Create a slider**. -.. image:: slider.png + +.. figure:: slider.png + :figclass: align-center + + Slider + Scanning -------- -You can open the scanning interface with the associated button 'Open scanner' in the menu bar of the control panel window. +You can open the scanning panel with the associated **Scanner** button under the **Panels** sub-menu of the control panel menu bar. To configure a scan, please visit the section :ref:`scanning`. Plotting -------- -You can open the plotting interface with the associated button 'Open plotter' in the menu bar of the control panel window. -See section :ref:`plotting`. +You can open the plotting panel with the associated **Plotter** button under the **Panels** sub-menu of the control panel menu bar. +See section :ref:`plotting` for more details. + + +Other features +-------------- + +Logger +###### + +A logger can be added to the control center using the variable ``logger = True`` in the section [control_center] of ``autolab_config.ini``. +It monitors every print functions coming from autolab GUI or drivers to keep track of bugs/errors. +It is inside a pyqtgraph docker, allowing to detached it from the control panel and place it somewhere visible. + +Console +####### + +A Python console can be added to the control center using the variable ``console = True`` in the section [control_center] of ``autolab_config.ini``. +It allows to inspect autolab or drivers while using the GUI for debugging purposes. + + +Executing Python codes in GUI +############################# + +A function for executing python code directly in the GUI can be used to change a variable based on other device variables or purely mathematical equations. + +To use this function both in the control panel and in a scan recipe, use the special ``$eval:`` tag before defining your code in the corresponding edit box. +This name was chosen in reference to the python `eval` function used to perform the operation and also to be complex enough not to be used by mistake, thereby preventing unexpected results. +The eval function only has access to all instantiated devices and to the pandas and numpy packages. + +.. code-block:: python + + >>> # Usefull to set the value of a parameter in a recipe step + >>> $eval:system.parameter_buffer() + + >>> # Useful to define a step according to a measured data + >>> $eval:laser.wavelength() + + >>> # Useful to define a step according to an analyzed value + >>> $eval:plotter.bandwitdh.x_left() + >>> $eval:np.max(mydummy.array_1D()) + + >>> # Usefull to define a filename that changes during an analysis + >>> $eval:f"data_wavelength={laser.wavelength()}.txt" + + >>> # Usefull to add a dataframe to a device variable (for example to add data using the action `plotter.data.add_data`) + >>> $eval:mydummy.array_1D() + +It can also be useful in a scan for example to set the central frequency of a spectral analyzer according to the frequency of a signal generator. Here is an example to realize this measurement using ``$eval:``. + +.. figure:: recipe_eval_example.png + :figclass: align-center + + Recipe using eval example + + +Autocompletion +############### + +To simplify the usage of codes in GUI, an autocompletion feature is accesible by pressing **Tab** after writing ``$eval:`` in any text widget. + +.. figure:: autocompletion.png + :figclass: align-center + + Autocompletion, console and logger example diff --git a/docs/gui/control_panel.png b/docs/gui/control_panel.png index 2cf3fc45..4af722a2 100644 Binary files a/docs/gui/control_panel.png and b/docs/gui/control_panel.png differ diff --git a/docs/gui/driver_installer.png b/docs/gui/driver_installer.png new file mode 100644 index 00000000..5a67f0e7 Binary files /dev/null and b/docs/gui/driver_installer.png differ diff --git a/docs/gui/extra.rst b/docs/gui/extra.rst index 99e1f449..f6ab8632 100644 --- a/docs/gui/extra.rst +++ b/docs/gui/extra.rst @@ -3,54 +3,9 @@ Experimental features ===================== -Executing Python codes in GUI -############################# - -A function for executing python code directly in the GUI can be used to change a variable based on other device variables or purely mathematical equations. - -To use this function both in the control panel and in a scan recipe, use the special ``$eval:`` tag before defining your code in the corresponding edit box. -This name was chosen in reference to the python function eval used to perform the operation and also to be complex enough not to be used by mistake and produce an unexpected result. -The eval function only has access to all instantiated devices and to the pandas and numpy packages. - -.. code-block:: none - - >>> # Usefull to set the value of a parameter to a step of a recipe - >>> $eval:system.parameter_buffer() - - >>> # Useful to define a step according to a measured data - >>> $eval:laser.wavelength() - - >>> # Useful to define a step according to an analyzed value - >>> $eval:plotter.bandwitdh.x_left() - >>> $eval:np.max(mydummy.array_1D()) - - >>> # Usefull to define a filename which changes during an analysis - >>> $eval:f"data_wavelength={laser.wavelength()}.txt" - - >>> # Usefull to add a dataframe to a device variable (for example to add data using the action plotter.data.add_data) - >>> $eval:mydummy.array_1D() - -It can also be useful in a scan for example to set the central frequency of a spectral analyzer according to the frequency of a signal generator. Here is a example to realize this measurement using ``$eval:``. - -.. image:: recipe_eval_example.png - - -Logger -###### - -A logger can be added to the control center using the variable ``logger = True`` in the section [control_center] of ``autolab_config.ini``. -It monitor every print functions coming from autolab GUI or drivers to keep track of bugs/errors. -It is inside a pyqtgraph docker, allowing to detached it from the control panel and place it somewhere visible. - -Console -####### - -A Python console can be added to the control center using the variable ``console = True`` in the section [control_center] of ``autolab_config.ini``. -It allows to inspect autolab or drivers while using the GUI for debugging purposes. - Plot from driver ################ -When creating a plot from a driver inside the GUI usualy crashes Python because the created plot isn't connected to the GUI thread. +When creating a plot from a driver inside the GUI, Python usually crashes because the created plot isn't connected to the GUI thread. To avoid this issue, a driver can put gui=None as an argument and use the command gui.createWidget to ask the GUI to create the widget and send back the instance. This solution can be used to create and plot data in a custom widget while using the GUI. diff --git a/docs/gui/index.rst b/docs/gui/index.rst index 2a6129f8..51f7fd56 100644 --- a/docs/gui/index.rst +++ b/docs/gui/index.rst @@ -3,9 +3,14 @@ Graphical User Interface (GUI) ============================== -Autolab is provided with a user-friendly graphical interface based on the **Device** interface, that allows the user to interact even more easily with its instruments. It can be used only for local configurations (see :ref:`localconfig`). +Autolab is provided with a user-friendly graphical interface based on the **Device** interface, that allows the user to interact even more easily with its instruments. It can only be used for local configurations (see :ref:`localconfig`). -The GUI has four panels : a **Control Panel** that allows to see visually the architecture of a **Device**, and to interact with an instrument through the *Variables* and *Actions*. The **Monitoring Panel** allows the user to monitor a *Variable* in time. The **Scanning Panel** allows the user to configure the scan of a parameter and the execution of a custom recipe for each value of the parameter. The **Plotting Panel** allows the user to plot data. +The GUI has four panels: + + * a **Control Panel** that allows to see visually the architecture of a **Device**, and to interact with an instrument through the *Variables* and *Actions*. + * The **Monitoring Panel** allows the user to monitor a *Variable* over time. + * The **Scanning Panel** allows the user to configure the scan of a parameter and execute a custom recipe for each value of the parameter. + * The **Plotting Panel** allows the user to plot data. .. figure:: control_panel.png :figclass: align-center @@ -27,16 +32,16 @@ The GUI has four panels : a **Control Panel** that allows to see visually the ar Plotting panel -To start the GUI from a Python shell, call the function ``gui`` of the package: +To start the GUI from a Python shell, call the ``gui`` function of the package: .. code-block:: python >>> import autolab >>> autolab.gui() -To start the GUI from an OS shell, call: +To start the GUI from an OS shell, use: -.. code-block:: none +.. code-block:: bash >>> autolab gui @@ -48,4 +53,5 @@ To start the GUI from an OS shell, call: monitoring scanning plotting + miscellaneous extra diff --git a/docs/gui/miscellaneous.rst b/docs/gui/miscellaneous.rst new file mode 100644 index 00000000..e6030fca --- /dev/null +++ b/docs/gui/miscellaneous.rst @@ -0,0 +1,60 @@ +.. _miscellaneous: + +Miscellaneous +============= + +Preferences +----------- + +The preferences panel allows to change the main settings saved in the autolab_config.ini and plotter_config.ini files. +It is accessible in the **Settings** action of the control panel menubar, or in code with ``autolab.preferences()``. + +.. figure:: preferences.png + :figclass: align-center + + Preference panel + +Driver installer +---------------- + +The driver installer allows to select individual drivers or all drivers from the main driver github repository. +It is accessible in the **Settings** action of the control panel menubar, or in code with ``autolab.driver_installer()``. + +.. figure:: driver_installer.png + :figclass: align-center + + Driver installer + +About +----- + +The about window display the versions the autolab version in-used as well as the main necessary packages. +It is accessible in the **Help** action of the control panel menubar, or in code with ``autolab.about()``. + +.. figure:: about.png + :figclass: align-center + + About panel + +Add device +---------- + +The add device window allows to add a device to the device_config.ini file. +It is accessible by right clicking on the empty area of the control panel tree, or in code with ``autolab.add_device()``. + +.. figure:: add_device.png + :figclass: align-center + + Add device panel + +Variables menu +-------------- + +The variables menu allows to add, modify or monitor variables usable in the GUI. +When a scan recipe is executed, each measured step creates a variable usable by the recipe, allowing to set a value based on the previous measured step without interacting with the instrument twice. +It is accessible in the **Variables** action of both the control panel and scanner menubar, or in code with ``autolab.variables_menu()``. + +.. figure:: variables_menu.png + :figclass: align-center + + Variables menu diff --git a/docs/gui/monitoring.png b/docs/gui/monitoring.png index 87859f71..e50963f5 100644 Binary files a/docs/gui/monitoring.png and b/docs/gui/monitoring.png differ diff --git a/docs/gui/monitoring.rst b/docs/gui/monitoring.rst index a5e6fbec..cda627d6 100644 --- a/docs/gui/monitoring.rst +++ b/docs/gui/monitoring.rst @@ -3,9 +3,14 @@ Monitoring ========== -.. image:: monitoring.png +The Monitor allows you to monitor a *Variable* in time. -The Autolab GUI Monitoring allows you to monitor a *Variable* in time. To start a monitoring, right click on the desired *Variable* in the control panel, and click **Start monitoring**. This *Variable* has to be readable (read function provided in the driver) and numerical (integer, float value or 1 to 3D array). +.. figure:: monitoring.png + :figclass: align-center + + Monitoring panel + +To start a monitoring, right click on the desired *Variable* in the control panel, and click **Start monitoring**. This *Variable* has to be readable (read function provided in the driver) and numerical (integer, float value or 1 to 3D array). In the Monitoring window, you can set the **Window length** in seconds. Any points older than this value is removed. You can also set a **Delay** in seconds, which corresponds to a sleep delay between each measure. @@ -17,6 +22,13 @@ You can display a bar showing the **Min** or **Max** value reached since the beg The **Mean** option display the mean value of the currently displayed data (not from the beginning). +The **Pause on scan start** checkbox allows to pause a monitor during a scan to prevent multiple communication with an instrument (prevent bug and speed up execution). + +The **start on scan end** checkbox allows to start back the monitoring after a scan. + Thanks to the pyqtgraph package, it is possible to monitor images. -.. image:: monitoring_image.png +.. figure:: monitoring_image.png + :figclass: align-center + + Monitoring images diff --git a/docs/gui/monitoring_image.png b/docs/gui/monitoring_image.png index 93d712e8..9c462028 100644 Binary files a/docs/gui/monitoring_image.png and b/docs/gui/monitoring_image.png differ diff --git a/docs/gui/multiple_recipes.png b/docs/gui/multiple_recipes.png index 7c24672c..f3f883bc 100644 Binary files a/docs/gui/multiple_recipes.png and b/docs/gui/multiple_recipes.png differ diff --git a/docs/gui/plotting.png b/docs/gui/plotting.png index d6827dda..d1c16cec 100644 Binary files a/docs/gui/plotting.png and b/docs/gui/plotting.png differ diff --git a/docs/gui/plotting.rst b/docs/gui/plotting.rst index e2498689..fc46ec1d 100644 --- a/docs/gui/plotting.rst +++ b/docs/gui/plotting.rst @@ -3,11 +3,17 @@ Plotting ======== -.. image:: plotting.png +.. figure:: plotting.png + :figclass: align-center -.. caution:: + Plotting panel - The plotter still need some work, feed-back are more than welcome (January 2024). +.. note:: + + The plotter still needs some work, feed-back is more than welcome. + +The Plotter panel is accessible in the **Plotter** action of the **Panels** sub-menu of the control panel menubar, or by code with ``autolab.plotter()``. +Data can directly be plotted by passing them as argument ``autolab.plotter(data)``. Import data ----------- @@ -35,13 +41,13 @@ The **Plugin** tree can be used to connect any device to the plotter, either by [plugin] = -A plugin do not share the same instance as the original device in the controlcenter, meaning that variables of a device will not affect variables of a plugin and vis-versa. +A plugin do not share the same instance as the original device in the controlcenter, meaning that variables of a device will not affect variables of a plugin and vice versa. Because a new instance is created for each plugin, you can add as many plugin from the same device as you want. -If a device uses the the argument ``gui`` in its ``__init__`` method, it will be able to access the plotter instance to get its data ot to modify the plot itself. +If a device uses the the argument ``gui`` in its ``__init__`` method, it will be able to access the plotter instance to get its data or to modify the plot itself. -If a plugin has a method called ``refresh``, the plotter will call it with the argument ``data`` containing the plot data everytime the figure is updated, allowing for each plugin to get the lastest available data and do operations on it. +If a plugin has a method called ``refresh``, the plotter will call it with the argument ``data`` containing the plot data everytime the figure is updated, allowing for each plugin to get the latest available data and do operations on it. -The plugin ``plotter`` can be added to the Plotter, allowing to do basic analyzes on the plotted data. +The plugin ``plotter`` can be added to the Plotter, allowing to do basic analyses on the plotted data. Among them, getting the min, max values, but also computing the bandwidth around a local extremum. Note that this plugin can be used as a device to process data in the control panel or directly in a scan recipe. diff --git a/docs/gui/preferences.png b/docs/gui/preferences.png new file mode 100644 index 00000000..53e1e853 Binary files /dev/null and b/docs/gui/preferences.png differ diff --git a/docs/gui/scanning.png b/docs/gui/scanning.png index dd6d0315..288da5d6 100644 Binary files a/docs/gui/scanning.png and b/docs/gui/scanning.png differ diff --git a/docs/gui/scanning.rst b/docs/gui/scanning.rst index f20892b4..a1649374 100644 --- a/docs/gui/scanning.rst +++ b/docs/gui/scanning.rst @@ -3,16 +3,19 @@ Scanning ======== -The Autolab GUI Scanning interface allows the user to sweep parameters over a certain range of values, and execute for each of them a custom recipe. +The Scanner interface allows the user to sweep parameters over a certain range of values, and execute for each of them a custom recipe. -.. image:: scanning.png +.. figure:: scanning.png + :figclass: align-center + + Scanning panel Scan configuration ################## A scan can be composed of several recipes. Click on **Add recipe** at the bottom of the scanner to add an extra recipe. -A recipe represent a list of steps that are executed for each value of a or multiple parameter. +A recipe represents a list of steps that are executed for each value of one or multiple parameters. Parameters @@ -20,10 +23,10 @@ Parameters The first step to do is to configure a scan parameter. A parameter is a *Variable* which is writable (write function provided in the driver) and numerical (integer or float value). To set a *Variable* as scan parameter, right click on it on the control panel window, and select **Set as scan parameter**. -The user can change the name of the parameter with the line edit widget. This name will be used is the data files. -It it possible to add extra parameters to a recipe by right cliking on the top of a recipe and selecting **Add Parameter** +The user can change the name of the parameter using the line edit widget. This name will be used in the data files. +It is possible to add extra parameters to a recipe by right-clicking on the top of a recipe and selecting **Add Parameter** This feature allows to realize 2D scan or ND-scan. -A parameter can be removed by right cliking on its frame and selecting **Remove **. +A parameter can be removed by right-clicking on its frame and selecting **Remove **. A parameter is optional, a recipe is executed once if no parameter is given. Parameter range @@ -31,7 +34,7 @@ Parameter range The second step is to configure the range of the values that will be applied to the parameter during the scan. The user can set the start value, the end value, the mean value, the range width, the number of points of the scan or the step between two values. -The user can also space the points following a log scale by selecting the **Log** option. +The user can also space the points following a logarithmic scale by selecting the **Log** option. It is also possible to use a custom array for the parameter using the **Custom** option. Steps @@ -47,14 +50,14 @@ Each recipe step must have a unique name. To change the name of a recipe step, r Recipe steps can be dragged and dropped to modify their relative order inside a recipe, to move them between multiple recipes, or to add them from the control panel. They can also be removed from the recipe using the right click menu **Remove**. -Right clicking on a recipe gives several options: **Disable**, **Rename**, **Remove**, **Add Parameter**, **Move up** and **Move down**. +Right-clicking on a recipe gives several options: **Disable**, **Rename**, **Remove**, **Add Parameter**, **Move up** and **Move down**. -All changes made to the scan configuration are kept in a history allowing changes to be undone or restored using buttons **Undo** and **Redo**. These buttons are accessible using the **Edit** button in the menu bar of the scanner window. +All changes made to the scan configuration are kept in a history, allowing changes to be undone or restored using the **Undo** and **Redo** buttons. These buttons are accessible using the **Edit** button in the menu bar of the scanner window. Store the configuration ----------------------- -Once the configuration of a scan is finished, the user can save it locally in a file for future use, by opening the menu **Configuration** and selecting **Export current configuration**. The user will be prompted for a file path in which the current scan configuration (parameter, parameter range, recipe) will be saved. +Once the configuration of a scan is finished, the user can save it locally in a file for future use by opening the **Configuration** menu and selecting **Export current configuration**. The user will be prompted for a file path in which the current scan configuration (parameter, parameter range, recipe) will be saved. To load a previously exported scan configuration, open the menu **Configuration** and select **Import configuration**. The user will be prompted for the path of the configuration file. Use the **Append** option to append the selected configuration as an extra recipe to the existing scan. @@ -63,11 +66,10 @@ Alternatively, recently opened configuration files can be accessed via the **Imp Scan execution ############## - * **Start** button: start / stop the scan. + * **Start** button: start the scan. * **Pause** button: pause / resume the scan. + * **Stop** button: stop the scan. * **Continuous scan** check box: if checked, start automatically a new scan when the previous one is finished. The state of this check box can be changed at any time. - * **Clear data** button: delete any previous datapoint recorded. - * **Save** button: save the data of the last scan. The user will be prompted for a folder path, that will be used to save the data and a screenshot of the figure. .. note:: @@ -83,14 +85,29 @@ Figure The user can interact with the figure at any time (during a scan or not). -After a first loop of a recipe has been processed, the user can select the *Variable* displayed in x and y axis of the figure. +After the first loop of a recipe has been processed, the user can select the *Variable* displayed in x and y axes of the figure. -The user can display the previous scan results using the combobox above the scanner figure containing the scan name. +A data filtering option is available below the figure to select the desired data, allowing for example to plot a slice of a 2D scan. -If the user has created several recipes in a scan, it is possible to display its results using the combobox above the scanner figure contaning the recipe name. +A 2D plot option allows to display scan data as a colormap with x, y as axies and z as values, usuful to represent ND-scan. -It is possible to display arrays and images using the combobox above the scanner figure containing the dataframe name or 'Scan' for the main scan result. +Scan data can be clear or saved with the buttons bellow the figure. -A data filtering option is available below the figure to select the desired data, allowing for example to plot a slice of a 2D scan. + * **Clear all** button: delete any previous datapoint recorded. + * **Save all** button: save all the data of all the executed scans. The user will be prompted for a folder path, that will be used to save the data of all the scans. + * **Save** button: save the data of the selected scan. The user will be prompted for a folder path, that will be used to save the data of the scan. + +The user can display the previous scan results using the combobox below the scanner figure containing the scan name (scan1, scan2, ...). + +If the user has created several recipes in a scan, a combobox below the scanner figure contaning the recipe names (recipe, recipe_1, ...) allows to change the displayed recipe results. + +A combobox below the scanner figure containing the dataframe name or 'Scan' for the main scan result allows to display arrays and images. + +The button **Scan data** display the scan data in a table. + +The button **Send to plotter** send the scan data of the selected recipe to the :ref:`plotting`. + +.. figure:: multiple_recipes.png + :figclass: align-center -.. image:: multiple_recipes.png + Multiple recipe example diff --git a/docs/gui/slider.png b/docs/gui/slider.png index fbc80291..b8e57073 100644 Binary files a/docs/gui/slider.png and b/docs/gui/slider.png differ diff --git a/docs/gui/variables_menu.png b/docs/gui/variables_menu.png new file mode 100644 index 00000000..743cdbba Binary files /dev/null and b/docs/gui/variables_menu.png differ diff --git a/docs/help_report.rst b/docs/help_report.rst index 65203edc..a8665fac 100644 --- a/docs/help_report.rst +++ b/docs/help_report.rst @@ -1,10 +1,10 @@ -Doc / Reports / Stats ------------------------------------------ +Doc / Reports +------------- Documentation ============= -You can open directly this documentation from Python by calling the function ``doc`` of the package: +You can directly open this documentation from Python by calling the ``doc`` function of the package: .. code-block:: python @@ -19,10 +19,10 @@ You can open directly this documentation from Python by calling the function ``d Bugs & suggestions reports ========================== -If you encounter some problems or bugs, or if you have any suggestion to improve this package, or one of its driver, please open an Issue on the GitHub page of this project +If you encounter any problems or bugs, or if you have any suggestion to improve this package, or one of its drivers, please open an Issue on the GitHub page of this project https://github.com/autolab-project/autolab/issues/new -You can also directly call the function ``report`` of the package, which will open this page in your web browser: +You can also directly call the ``report`` function of the package, which will open this page in your web browser: .. code-block:: python diff --git a/docs/high_level.rst b/docs/high_level.rst index eab3e4a8..9024b573 100644 --- a/docs/high_level.rst +++ b/docs/high_level.rst @@ -6,15 +6,15 @@ Devices (High-level interface) What is a Device? ----------------- -The high-level interface of Autolab is an abstraction layer of its low-level interface, which allows to communicate easily and safely with laboratory instruments without knowing the structure of its associated **Driver**. +The high-level interface of Autolab is an abstraction layer of its low-level interface, which allows easy and safe communication with laboratory instruments without knowing the structure of their associated **Driver**. In this approach, an instrument is fully described with a hierarchy of three particular **Elements**: the **Modules**, the **Variables** and the **Actions**. * A **Module** is an **Element** that consists in a group of **Variables**, **Actions**, and sub-**Modules**. The top-level **Module** of an instrument is called a **Device**. -* A **Variable** is an **Element** that refers to a physical quantity, whose the value can be either set and/or read from an instrument (wavelength of an optical source, position of a linear stage, optical power measured with a power meter, spectrum measured with a spectrometer...). Depending on the nature of the physical quantity, it may have a unit. +* A **Variable** is an **Element** that refers to a physical quantity, whose value can be either set and/or read from an instrument (wavelength of an optical source, position of a linear stage, optical power measured with a power meter, spectrum measured with a spectrometer...). Depending on the nature of the physical quantity, it may have a unit. -* An **Action** is an **Element** that refers to a particular operation that can be performed by an instrument. (homing of a linear stage, the zeroing of a power meter, the acquisition of a spectrum with a spectrometer...). An **Action** may have a parameter. +* An **Action** is an **Element** that refers to a particular operation that can be performed by an instrument. (homing of a linear stage, zeroing of a power meter, acquisition of a spectrum with a spectrometer, etc.). An **Action** may have a parameter. The **Device** of a simple instrument is usually represented by only one **Module**, and a few **Variables** and **Actions** attached to it. @@ -24,7 +24,7 @@ The **Device** of a simple instrument is usually represented by only one **Modul |-- Wavelength (Variable) |-- Output state (Variable) -Some instruments are a bit more complex, in the sense that they can host several different modules. Their representation in this interface generally consists in one top level **Module** (the frame) and several others sub-**Modules** containing the **Variables** and **Actions** of each associated modules. +Some instruments are a bit more complex, in the sense that they can host several different modules. Their representation in this interface generally consists of one top level **Module** (the frame) and several others sub-**Modules** containing the **Variables** and **Actions** of each associated module. .. code-block:: python @@ -37,12 +37,12 @@ Some instruments are a bit more complex, in the sense that they can host several |-- Position (Variable) |-- Homing (Action) -This hierarchy of **Elements** is implemented for each instrument in its drivers files, and is thus ready to use. +This hierarchy of **Elements** is implemented for each instrument in its driver files, and is thus ready to use. Load and close a Device ----------------------- -The procedure to load a **Device** is almost the same as for the **Driver**, but with the function ``get_device``. You need to provide the nickname of a driver defined in the ``devices_config.ini`` (see :ref:`localconfig`). +The procedure to load a **Device** is almost the same as for the **Driver**, but with the ``get_device`` function. You need to provide the nickname of a driver defined in the ``devices_config.ini`` (see :ref:`localconfig`). .. code-block:: python @@ -50,13 +50,13 @@ The procedure to load a **Device** is almost the same as for the **Driver**, but .. note:: - You can overwrite temporarily some of the parameters values of a configuration by simply providing them as keywords arguments in the ``get_device`` function: + You can temporarily overwrite some of the parameters values of a configuration by simply providing them as keywords arguments in the ``get_device`` function: .. code-block:: python >>> laserSource = autolab.get_device('my_tunics', address='GPIB::9::INSTR') -To close properly the connection to the instrument, simply call its the function ``close`` of the **Device**. This object will not be usable anymore. +To properly close the connection to the instrument, simply call the ``close`` function of the **Device**. This object will no longer be usable. .. code-block:: python @@ -71,7 +71,7 @@ To close the connection to all instruments (devices, not drivers) at once, you c Navigation and help in a Device ------------------------------- -The navigation in the hierarchy of **Elements** of a given **Device** is based on relative attributes. For instance, to access the **Variable** ``wavelength`` of the **Module** (**Device**) ``my_tunics``, simply execute the following command: +Navigation in the hierarchy of **Elements** of a given **Device** is based on relative attributes. For instance, to access the **Variable** ``wavelength`` of the **Module** (**Device**) ``my_tunics``, simply execute the following command: .. code-block:: python @@ -84,7 +84,7 @@ In the case of a more complex **Device**, for instance a power meter named ``my_ >>> powerMeter = autolab.get_device('my_power_meter') >>> powerMeter.channel1.power -Every **Element** in Autolab is provided with a function ``help`` that can be called to obtain some information about it, but also to know which further **Elements** can be accessed through it, in the case of a **Module**. For a **Variable**, it will display its read and/or write functions (from the driver), its python type, and its unit if provided in the driver. For an **Action**, il will display the associated function in the driver, and its parameter (python type and unit) if it has one. You can also ``print()`` the object to display this help. +Every **Element** in Autolab is provided with a ``help`` function that can be called to obtain some information about it, but also to know which further **Elements** can be accessed through it, in the case of a **Module**. For a **Variable**, it will display its read and/or write functions (from the driver), its Python type, and its unit if provided in the driver. For an **Action**, il will display the associated function in the driver, and its parameter (Python type and unit) if it has one. You can also ``print()`` the object to display this help. .. code-block:: python @@ -113,7 +113,7 @@ If a **Variable** is writable (write function provided in the driver), its curre >>> lightSource.wavelength(1549) >>> lightSource.output(True) -To save locally the value of a readable **Variable**, use its function `save` with the path of the desired output directory (default filename), or file: +To save the value of a readable **Variable** locally, use its `save` function with the path of the desired output directory (default filename), or file: .. code-block:: python @@ -134,7 +134,7 @@ You can execute an **Action** simply by calling its attribute: Script example -------------- -With all these commands, you can now create your own Python script. Here is an example of a script that sweep the wavelength of a light source, and measure the power of a power meter: +With all these commands, you can now create your own Python script. Here is an example of a script that sweeps the wavelength of a light source, and measures the power of a power meter: .. code-block:: python diff --git a/docs/index.rst b/docs/index.rst index 5ebcbd41..33af178b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -8,7 +8,7 @@ Welcome to Autolab's documentation! **"Forget your instruments, focus on your experiment!"** -Autolab is a Python package dedicated to control remotely any laboratory instruments and automate scientific experiments in the most user-friendly way. This package provides a set of standardized drivers for about 50 instruments (for now) which are ready to use, and is open to inputs from the community (new drivers or upgrades of existing ones). The configuration required to communicate with a given instrument (connection type, address, ...) can be saved locally to avoid providing it each time. Autolab can also be used either through a Python shell, an OS shell, or a graphical interface. +Autolab is a Python package dedicated to remotely controlling any laboratory instruments and automating scientific experiments in the most user-friendly way. This package provides a set of standardized drivers for about 50 instruments (for now) which are ready to use, and is open to inputs from the community (new drivers or upgrades of existing ones). The configuration required to communicate with a given instrument (connection type, address, ...) can be saved locally to avoid providing it each time. Autolab can also be used either through a Python shell, an OS shell, or a graphical interface. .. figure:: scheme.png :figclass: align-center @@ -33,7 +33,7 @@ In this package, the interaction with a scientific instrument can be done throug >>> stage = autolab.get_driver('newport_XPS', connection='SOCKET') >>> stage.go_home() - * The :ref:`highlevel`, are an abstraction layer of the low-level interface that provide a simple and straightforward way to communicate with an instrument, through a hierarchy of Modules, Variables and Actions objects. + * The :ref:`highlevel`, is an abstraction layer of the low-level interface that provides a simple and straightforward way to communicate with an instrument, through a hierarchy of Modules, Variables and Actions objects. .. code-block:: python @@ -52,12 +52,12 @@ In this package, the interaction with a scientific instrument can be done throug >>> stage = autolab.get_device('my_stage') # Create the Device 'my_stage' >>> stage.home() # Execute the Action 'home' - The user can also interact even more easily with this high-level interface through a user-friendly :ref:`gui` which contains three panels: A Control Panel (graphical equivalent of the high-level interface), a Monitor (to monitor the value of a Variable in time) and a Scanner (to scan a Parameter and execute a custom Recipe). + The user can also interact even more easily with this high-level interface through a user-friendly :ref:`gui` which contains three panels: a Control Panel (graphical equivalent of the high-level interface), a Monitor (to monitor the value of a Variable in time) and a Scanner (to scan a Parameter and execute a custom Recipe). .. figure:: gui/scanning.png :figclass: align-center -All the Autolab's features are also available through an :ref:`shell_scripts`. interface (Windows and Linux) that can be used to perform for instance a quick single-shot operation without opening explicitely a Python shell. +All of Autolab's features are also available through an :ref:`shell_scripts` interface (Windows and Linux) that can be used to perform for instance a quick single-shot operation without explicitly opening a Python shell. .. code-block:: none @@ -65,9 +65,9 @@ All the Autolab's features are also available through an :ref:`shell_scripts`. i >>> autolab device -D my_tunics -e wavelength -v 1551 .. note:: - **Useful links**: + **Useful Links**: - * `Slides of our Autolab seminar (March 2020) `_ + * `Slides from our Autolab seminar (March 2020) `_ * Github project: `github.com/autolab-project/autolab `_ * PyPi project: `pypi.org/project/autolab/ `_ * Online documentation: `autolab.readthedocs.io/ `_ @@ -85,6 +85,7 @@ Table of contents: gui/index shell/index help_report + release_notes about Last edit: |today| for the version |release| diff --git a/docs/installation.rst b/docs/installation.rst index acea8654..50e8ba5f 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -4,11 +4,11 @@ Installation Python ------ -This package is working on Python version 3.6+. +This package works on Python version 3.6+. -* On Windows, we recommend to install Python through the distribution Anaconda: https://www.anaconda.com/ -* On older versions of Windows (before Windows 7), we recommend to install Python manually: https://www.python.org/ -* On Linux, we recommend to install Python through the apt-get command. +* On Windows, we recommend installing Python through the distribution Anaconda: https://www.anaconda.com/ +* On older versions of Windows (before Windows 7), we recommend installing Python manually: https://www.python.org/ +* On Linux, we recommend installing Python through the `apt-get` command. Additional required packages (installed automatically with Autolab): @@ -25,14 +25,14 @@ Additional required packages (installed automatically with Autolab): Autolab package --------------- -This project is hosted in the global python repository PyPi at the following address : https://pypi.org/project/autolab/. -To install the Autolab python package on your computer, we then advice you to use the Python package manager ``pip`` in a Python environnement: +This project is hosted in the global Python repository PyPi at the following address: https://pypi.org/project/autolab/. +To install the Autolab python package on your computer, we then advise you to use the Python package manager ``pip`` in a Python environnement: .. code-block:: none pip install autolab -If the package is already installed, you can check the current version installed and upgrade it to the last official version with the following commands: +If the package is already installed, you can check the current version installed and upgrade it to the latest official version with the following commands: .. code-block:: none @@ -49,7 +49,7 @@ Import the Autolab package in a Python shell to check that the installation is c Packages for the GUI -------------------- -The GUI requires several packages to work. But depending if you are using Anaconda or not, the installation is different: +The GUI requires several packages to work, but depending on whether you are using Anaconda or not, the installation is different: With Anaconda: @@ -67,7 +67,7 @@ Without: pip install qtpy pip install pyqt5 -Note that thanks to qtpy, you can install a different qt backend instead of pyqt5 among pyqt6, pyside2 and pyside6 +Note that thanks to qtpy, you can install a different Qt backend instead of pyqt5, such as pyqt6, pyside2, or pyside6 Development version ------------------- diff --git a/docs/local_config.rst b/docs/local_config.rst index 963b0398..174783f1 100644 --- a/docs/local_config.rst +++ b/docs/local_config.rst @@ -3,16 +3,16 @@ Local configuration =================== -To avoid having to provide each time the full configuration of an instrument (connection type, address, port, slots, ...) to load a **Device**, Autolab proposes to store it locally for further use. +To avoid having to provide the full configuration of an instrument (connection type, address, port, slots, etc.) each time to load a **Device**, Autolab proposes storing it locally for further use. More precisely, this configuration is stored in a local configuration file named ``devices_config.ini``, which is located in the local directory of Autolab. Both this directory and this file are created automatically in your home directory the first time you use the package (the following messages will be displayed, indicating their exact paths). -.. code-block:: python +.. code-block:: none The local directory of AUTOLAB has been created: C:\Users\\autolab. It contains the configuration files devices_config.ini, autolab_config.ini and plotter.ini. It also contains the 'driver' directory with 'official' and 'local' sub-directories. - + .. warning :: Do not move or rename the local directory nor the configuration file. @@ -23,13 +23,13 @@ A device configuration is composed of several parameters: * The name of the associated Autolab **driver**. * All the connection parameters (connection, address, port, slots, ...) -To see the list of the available devices configurations, call the function ``list_devices``. +To see the list of the available device configurations, call the ``list_devices`` function. .. code-block:: python >>> autolab.list_devices() -To know what parameters have to be provided for a particular **Device**, use the function `config_help` with the name of corresponding driver. +To know what parameters have to be provided for a particular **Device**, use the `config_help` function with the name of the corresponding driver. .. code-block:: python @@ -52,7 +52,7 @@ This file is structured in blocks, each of them containing the configuration of slot1 = slot1_name = -To see a concrete example of the block you have to append in the configuration file for a given driver, call the function ``config_help`` with the name of the driver. You can then directly copy and paste this exemple into the configuration file, and customize the value of the parameters to suit those of your instrument. Here is an example for the Yenista Tunics light source: +To see a concrete example of the block you have to append in the configuration file for a given driver, call the ``config_help`` function with the name of the driver. You can then directly copy and paste this exemple into the configuration file, and customize the value of the parameters to suit those of your instrument. Here is an example for the Yenista Tunics light source: .. code-block:: none diff --git a/docs/low_level/create_driver.rst b/docs/low_level/create_driver.rst index 275ebe31..c78e510d 100644 --- a/docs/low_level/create_driver.rst +++ b/docs/low_level/create_driver.rst @@ -3,7 +3,7 @@ Write your own Driver ===================== -The goal of this tutorial is to present the general structure of the drivers of this package, in order for you to create simply your own drivers, and make them available to the community within this collaborative project. We notably provide a fairly understandable driver structure that can handle the highest degree of instruments complexity (including: single and multi-channels function generators, oscilloscopes, Electrical/Optical frames with associated interchangeable submodules, etc.). This provides reliable ways to add other types of connection to your driver (e.g. GPIB to Ethenet) or other functions (e.g. get_amplitude, set_frequency, etc.). +The goal of this tutorial is to present the general structure of the drivers of this package, in order for you to simply create your own drivers and make them available to the community within this collaborative project. We notably provide a fairly understandable driver structure that can handle the highest degree of instruments complexity (including: single and multi-channels function generators, oscilloscopes, olectrical/optical frames with associated interchangeable submodules, etc.). This provides reliable ways to add other types of connection to your driver (e.g. GPIB to Ethenet) or other functions (e.g. get_amplitude, set_frequency, etc.). .. note:: @@ -22,7 +22,7 @@ Getting started: create a new driver Each driver name should be unique: do not define new drivers (in your local folders) with a name that already exists in the main package. -In the local_drivers directory, as in the main package, each instrument has/should have its own directory organized and named as follow. The name of this folder take the form *\_\*. The driver associated to this instrument is a python script taking the same name as the folder: *\_\.py*. A second python script, allowing the parser to work properly, should be named *\_\_utilities.py* (`find a minimal template here `_). Additional python scripts may be present in this folder (devices's modules, etc.). Please see the existing drivers of the autolab package for extensive examples. +In the local_drivers directory, as in the main package, each instrument has/should have its own directory organized and named as follow. The name of this folder takes the form *\_\*. The driver associated to this instrument is a python script taking the same name as the folder: *\_\.py*. A second python script, allowing the parser to work properly, should be named *\_\_utilities.py* (`find a minimal template here `_). Additional python scripts may be present in this folder (devices's modules, etc.). Please see the existing drivers of the autolab package for extensive examples. **For addition to the main package**: Once you tested your driver and it is ready to be used by others, you can send the appropriate directory to the contacts (:ref:`about`). @@ -38,7 +38,7 @@ Driver structure (*\_\.py* file) The Driver is organized in several `python class `_ with a structure as follow. The numbers represent the way sections appear from the top to the bottom of an actual driver file. We chose to present the sections in a different way: -1 - import modules (optionnal) +1 - import modules (optional) ############################### To import possible additional modules, e.g.: @@ -53,10 +53,10 @@ The Driver is organized in several `python class `_ : + Examples of VISA addresses can be find online `here `_ : .. code-block:: python @@ -172,11 +172,11 @@ The Driver is organized in several `python class _\.py* but the class **Driver_CONNECTION** (including the class Driver and any optionnal class **Module_MODEL**), in order for many features of the package to work properly. It simply consists in a list of predefined elements that will indicate to the package the structure of the driver and predefined variable and actions. -There are three possible elements in the function ``get_driver_model``: *Module*, *Variable* and *Action*. +The ``get_driver_model`` function should be present in each of the classes of the *\_\.py* but the class **Driver_CONNECTION** (including the class Driver and any optional class **Module_MODEL**), in order for many features of the package to work properly. It simply consists in a list of predefined elements that will indicate to the package the structure of the driver and predefined variable and actions. +There are three possible elements in the ``get_driver_model`` function: *Module*, *Variable* and *Action*. Shared by the three elements (*Module*, *Variable*, *Action*): - 'name': nickname for your element (argument type: string) - 'element': element type, exclusively in: 'module', 'variable', 'action' (argument type: string) - - 'help': quick help, optionnal (argument type: string) + - 'help': quick help, optional (argument type: string) *Module*: - - 'object' : attribute of the class (argument type: Instance) + - 'object': attribute of the class (argument type: Instance) *Variable*: - 'read': class attribute (argument type: function) - 'write': class attribute (argument type: function) - 'type': python type, exclusively in: int, float, bool, str, bytes, tuple, np.ndarray, pd.DataFrame - - 'unit': unit of the variable, optionnal (argument type: string) - - 'read_init': bool to tell :ref:`control_panel` to read variable on instantiation, optionnal + - 'unit': unit of the variable, optional (argument type: string) + - 'read_init': bool to tell :ref:`control_panel` to read variable on instantiation, optional .. caution:: Either 'read' or 'write' key, or both of them, must be provided. @@ -450,7 +450,7 @@ Shared by the three elements (*Module*, *Variable*, *Action*): *Action*: - 'do': class attribute (argument type: function) - 'param_type': python type, exclusively in: int, float, bool, str, bytes, tuple, np.ndarray, pd.DataFrame, optional - - 'param_unit': unit of the variable, optionnal (argument type: string. Use special param_unit 'open-file' to open a open file dialog, 'save-file' to open a save file dialog and 'user-input' to open an input dialog) + - 'param_unit': unit of the variable, optional (argument type: string. Use special param_unit 'open-file' to open a open file dialog, 'save-file' to open a save file dialog and 'user-input' to open an input dialog) diff --git a/docs/low_level/index.rst b/docs/low_level/index.rst index 6ba44550..dd980b03 100644 --- a/docs/low_level/index.rst +++ b/docs/low_level/index.rst @@ -6,11 +6,11 @@ Drivers (Low-level interface) In Autolab, a **Driver** refers to a Python class dedicated to communicate with one particular instrument. This class contains functions that perform particular operations, and may also contain subclasses in case some modules or channels are present in the instrument. Autolab comes with a set of about 50 different **Drivers**, which are ready to use. -As of version 1.2, drivers are now in a seperate GitHub repository located at `github.com/autolab-project/autolab-drivers `_. -When installing autolab, the user is asked if they wants to install all drivers from this repository. +As of version 1.2, drivers are now in a separate GitHub repository located at `github.com/autolab-project/autolab-drivers `_. +When installing autolab, the user is asked if they want to install all drivers from this repository. The first part of this section explains how to configure and open a **Driver**, and how to use it to communicate with your instrument. -The, we present the guidelines to follow for the creation of new driver files, to contribute to the Autolab Python package. +Then, we present the guidelines to follow for the creation of new driver files, to contribute to the Autolab Python package. Table of contents: diff --git a/docs/low_level/open_and_use.rst b/docs/low_level/open_and_use.rst index b50c0d6b..085df557 100644 --- a/docs/low_level/open_and_use.rst +++ b/docs/low_level/open_and_use.rst @@ -7,7 +7,7 @@ The low-level interface provides a raw access to the drivers implemented in Auto .. attention:: - The Autolab drivers may contains internal functions, that are not dedicated to be called by the user, and some functions requires particular types of inputs. **The authors declines any responsibility for the consequences of an incorrect use of the drivers**. To avoid any problems, make sure you have a real understanding of what you are doing, or prefer the use of the :ref:`highlevel`. + Autolab drivers may contain internal functions, that are not dedicated to be called by the user, and some functions requires particular types of inputs. **The authors decline any responsibility for the consequences of an incorrect use of the drivers**. To avoid any problems, make sure you have a real understanding of what you are doing, or prefer the use of the :ref:`highlevel`. To see the list of available drivers in Autolab, call the ``list_drivers`` function. @@ -25,7 +25,7 @@ Load and close a Driver -The instantiation of a *Driver* object is done through the function ``get_driver`` of Autolab, and requires a particular configuration: +The instantiation of a *Driver* object is done using the ``get_driver`` function of Autolab, and requires a particular configuration: * The name of the driver: one of the name appearing in the ``list_drivers`` function (ex: 'yenista_TUNICS'). * The connection parameters as keywords arguments: the connection type to use to communicate with the instrument ('VISA', 'TELNET', ...), the address, the port, the slots, ... @@ -34,13 +34,13 @@ The instantiation of a *Driver* object is done through the function ``get_driver >>> laserSource = autolab.get_driver('yenista_TUNICS', 'VISA', address='GPIB0::12::INSTR') -To know what is the required configuration to interact with a given instrument, call the function ``config_help`` with the name of the driver. +To know what is the required configuration to interact with a given instrument, call the ``config_help`` function with the name of the driver. .. code-block:: python >>> autolab.config_help('yenista_TUNICS') -To close properly the connection to the instrument, simply call its the function ``close`` of the **Driver**. +To close properly the connection to the instrument, simply call the ``close`` function of the **Driver**. .. code-block:: python @@ -57,7 +57,7 @@ You are now ready to use the functions implemented in the **Driver**: >>> laserSource.get_wavelength() 1550 -You can get the list of the available functions by calling the function ``autolab.explore_driver`` with the instance of your **Driver**. Once again, note that some of these functions are note supposed to be used directly, some of them may be internal functions. +You can get the list of the available functions by calling the ``autolab.explore_driver`` function with the instance of your **Driver**. Once again, note that some of these functions are not supposed to be used directly, some of them may be internal functions. >>> autolab.explore_driver(laserSource) diff --git a/docs/release_notes.rst b/docs/release_notes.rst new file mode 100644 index 00000000..eb046be1 --- /dev/null +++ b/docs/release_notes.rst @@ -0,0 +1,114 @@ +Release notes +============= + +2.0 +### + +Autolab 2.0 released in 2024 is the first major release since 2020. + +General Features +---------------- + +- Configuration Enhancements: + + - Enhanced configuration options for driver management in autolab_config.ini, including extra paths and URLs for driver downloads. + - Added install_driver() to download drivers. + - Improved handling of temporary folders and data saving options. + +- Driver Management: + + - Moved drivers to a dedicated GitHub repository: https://github.com/autolab-project/autolab-drivers. + - Drivers are now located in the local "/autolab/drivers/official" folder instead of the main package. + - Added the ability to download drivers from GitHub using the GUI, allowing selective driver installation. + +- Documentation: + + - Added documentation for new features and changes. + +GUI Enhancements +---------------- + +- General Improvements: + + - Switched from matplotlib to pyqtgraph for better performance and compatibility. + - Enhanced plotting capabilities in the monitor and scanner, including support for 1D and 2D arrays and images. + - Added $eval: special tag to execute Python code in the GUI to perform custom operations. + - Added autocompletion for variables using tabulation. + - Added sliders to variables to tune values. + +- Control Panel: + + - Added the ability to display and set arrays and dataframes in the control panel. + - Added possibility to use variable with type bytes and action that have parameters with type bool, bytes, tuple, array or dataframe. + - Added yellow indicator for written but not read elements. + - Introduced a checkbox option to optionally display arrays and dataframes in the control panel. + - Added sub-menus for selecting recipes and parameters. + - Improved device connection management with options to modify or cancel connections. + - Added right-click options for modifying device connections. + +- Scanner: + + - Implemented multi-parameter and multi-recipe scanning, allowing for more complex scan configurations. + - Enhanced recipe management with right-click options for enabling/disabling, renaming, and deleting. + - Enabled plotting of scan data as an image, useful for 2D scans. + - Added support for custom arrays and parameters in scans. + - Enabled use of a default scan parameter not linked to any device. + - Added data display filtering option. + - Added scan config history with the last 10 configurations. + - Added variables to be used in the scan, allowing on-the-fly analysis inside a recipe. + - Changed the scan configuration file format from ConfigParser to json to handle new scan features. + - Add shortcut for copy paste, undo redo, delete in scanner for recipe steps. + +- Plotter: + + - Implementation of a plotter to open previous scan data, connect to instrument variables and perform data analysis. + +- Usability Improvements: + + - Enabled drag-and-drop functionality in the GUI. + - Added icons and various UI tweaks for better usability. + - Enabled opening configuration files from the GUI. + +- Standalone GUI Utilities: + + - Added autolab.about() for autolab information. + - Added autolab.slider(variable) to change a variable value. + - Added autolab.variables_menu() to control variables, monitor or use slider. + - Added autolab.add_device() for adding devices to the config file. + - Added autolab.monitor(variable) for monitoring variables. + - Added autolab.plotter() to open the plotter directly. + +Device and Variable Management +------------------------------ + +- Variable and Parameter Handling: + + - Added new action units ('user-input', 'open-file', 'save-file') to open dialog boxes. + - Added 'read_init' argument to variable allowing to read a value on device instantiation in the control panel. + - Added new type 'tuple' to create a combobox in the control panel. + +Miscellaneous Improvements +-------------------------- + +- Code Quality and Compatibility: + + - Numerous bug fixes to ensure stability and usability across different modules and functionalities. + - Compatibility from Python 3.6 up to 3.12. + - Switched from PyQt5 to qtpy to enable extensive compatibility (Qt5, Qt6, PySide2, PySide6). + - Extensive code cleanup, PEP8 compliance, and added type hints. + +- Logger and Console Outputs: + + - Added an optional logger in the control center to display console outputs. + - Added an optional console in the control center for debug/dev purposes. + +- Miscellaneous: + + - Added an "About" window showing versions, authors, license, and project URLs. + - Implemented various fixes for thread handling and error prevention. + - Add dark theme option for GUI. + +1.1.12 +###### + +Last version developed by the original authors. diff --git a/docs/scheme.png b/docs/scheme.png index 1dcaeecb..0fbd61e3 100644 Binary files a/docs/scheme.png and b/docs/scheme.png differ diff --git a/docs/shell/connection.rst b/docs/shell/connection.rst index bb47d923..f3adae58 100644 --- a/docs/shell/connection.rst +++ b/docs/shell/connection.rst @@ -15,7 +15,7 @@ Three helps are configured (device or driver may be used equally in the lines be >>> autolab driver -h - It including arguments and options formatting, definition of the available options and associated help and informations to retrieve the list of available drivers and local configurations (command: autolab infos). + It includes arguments and options formatting, definition of the available options, associated help, and information to retrieve the list of available drivers and local configurations (command: autolab infos). 2) Basic help about the particular name driver/device you provided: @@ -25,7 +25,7 @@ Three helps are configured (device or driver may be used equally in the lines be It includes the category of the driver/device (e.g. Function generator, Oscilloscope, etc.), a list of the implemented connections (-C option), personnalized usage example (automatically generated from the driver.py file), and examples to use and set up a local configuration using command lines (see :ref:`localconfig` for more informations about local configurations). - 3) Full help message **about the driver/device**: + 3) Full help message **for the driver/device**: .. code-block:: none @@ -40,7 +40,7 @@ Three helps are configured (device or driver may be used equally in the lines be It includes the hierarchy of the device and all the defined *Modules*, *Variables* and *Actions* (see :ref:`get_driver_model` and :ref:`os_device` for more informations on the definition and usage respectively). - Note that this help requires the instantiation of your instrument to be done, in other words it requires valid arguments for options -D, -C and -A (that you can get for previous helps) and a working physical link. + Note that this help requires the instantiation of your instrument to be done, in other words it requires valid arguments for options -D, -C and -A (that you can get from previous helps) and a working physical link. .. _name_shell_connection: @@ -56,7 +56,7 @@ A typical command line structure is: >>> autolab driver -D -C -A
(optional) >>> autolab device -D (optional) -**To set up the connection** for the first time, we recommand to follow the different help states (see :ref:`name_shell_help`), that usually guide you through filling the arguments corresponding to the above options. To use one of Autolab's driver to drive an instrument you need to provide its name. This is done with the option -D. -D option accepts a driver_name for a driver (e.g. agilent_33220A, etc) and a config_name for a device (nickname as defined in your device_config.ini, e.g. my_agilent). A full list of the available driver names and config names may be found using the command ``autolab infos``. Due to Autolab's drivers structure you also need to provide a -C option for the connection type (corresponding to a class to use for the communication, see :ref:`create_driver` for more informations) when instantiating your device. The available connection types (arguments for -C option) are driver dependent (you need to provide a valid -D option) and may be access with a second stage help (see :ref:`name_shell_help`). +**To set up the connection** for the first time, we recommend following the different help states (see :ref:`name_shell_help`), that usually guide you through filling the arguments corresponding to the above options. To use one of Autolab's driver to drive an instrument you need to provide its name. This is done with the option -D. -D option accepts a driver_name for a driver (e.g. agilent_33220A, etc) and a config_name for a device (nickname as defined in your device_config.ini, e.g. my_agilent). A full list of the available driver names and config names may be found using the command ``autolab infos``. Due to Autolab's drivers structure you also need to provide a -C option for the connection type (corresponding to a class to use for the communication, see :ref:`create_driver` for more informations) when instantiating your device. The available connection types (arguments for -C option) are driver dependent (you need to provide a valid -D option) and may be accessed with a second stage help (see :ref:`name_shell_help`). Lately you will need to provide additional options/arguments to set up the communication. One of the most common is the address for which we cannot help much. At this stage you need to make sure of the instrument address/set the address (on the physical instrument) and format it the way that the connection type is expecting it (e.g. for an ethernet connection with address 192.168.0.1 using VISA connection type: ``TCPIP::192.168.0.1::INSTR``). You will find in the second stage help automatically generated example of a minimal command line (as defined in the driver) that should be able to instantiate your instrument (providing you modify arguments to fit your conditions). **Other arguments** may be necessary for the driver to work properly. In particular, additional connection argument may be passed through the option -O, such as the port number (for SOCKET connection type), the gpib board index (for GPIB connection) or the path to the dll library (for DLL connection type). diff --git a/docs/shell/device.rst b/docs/shell/device.rst index f947c0ca..d8d1b0f9 100644 --- a/docs/shell/device.rst +++ b/docs/shell/device.rst @@ -38,7 +38,7 @@ The available operations are listed below: >>> autolab device -D myLinearStage -e goHome - * **To display the help** of any **Element**, provide its address with the option ``-h`` or ``--help`` : + * **To display the help** of any **Element**, provide its address with the option ``-h`` or ``--help``: .. code-block:: none diff --git a/docs/shell/driver.rst b/docs/shell/driver.rst index cd45bd55..19e5d45a 100644 --- a/docs/shell/driver.rst +++ b/docs/shell/driver.rst @@ -4,7 +4,7 @@ Command driver ============== -See :ref:`name_shell_connection` for more informations about the connection. Once your driver is instantiated you will be able to perform **pre-configured operations** (see :ref:`name_driver_utilities.py` for how to configure operations) as well as **raw operations** (-m option). We will discuss both of them here as well as a quick (bash) **scripting example**. +See :ref:`name_shell_connection` for more information about the connection. Once your driver is instantiated you will be able to perform **pre-configured operations** (see :ref:`name_driver_utilities.py` for how to configure operations) as well as **raw operations** (-m option). We will discuss both of them here as well as a quick (bash) **scripting example**. In the rest of this sections we will assume that you have a driver (not device) named instrument that needs a connection named CONN. @@ -17,7 +17,7 @@ You may access an extensive driver help, that will particularly **list the pre-d >>> autolab driver -D instrument -C CONN -h -It includes the list of the implemented connections, the list of the available additional modules (classes **Channel**, **Trace**, **Module_MODEL**, etc.; see :ref:`create_driver`), the list of all the methods that are instantiated with the driver (for direct use with the command: autolab driver; see :ref:`os_driver`), and an extensive help for the usage of the pre-defined options. For instance if an option -a has been defined in the file driver_utilities.py (see :ref:`name_driver_utilities.py`), one may use it to perform the associated action, say to modify the amplitude, this way: +It includes the list of the implemented connections, the list of the available additional modules (classes **Channel**, **Trace**, **Module_MODEL**, etc.; see :ref:`create_driver`), the list of all the methods that are instantiated with the driver (for direct use with the command: autolab driver; see :ref:`os_driver`), and an extensive help for the usage of the pre-defined options. For instance, if an option -a has been defined in the driver_utilities.py file (see :ref:`name_driver_utilities.py`), one may use it to perform the associated action, such as to modify the amplitude, this way: .. code-block:: none @@ -35,7 +35,7 @@ In addition, if the instrument has several channels, an channel option is most l No space must be present within an argument or option (e.g. do not write ``- c`` or ``-c 4, 6``). -Furthermore, several operations may be perform in a single and compact script line. One can modify the amplitude of channel 4 and 6 to 2 Volts and the frequencies (of the same channel) to 50 Hz using: +Furthermore, several operations may be performed in a single and compact script line. One can modify the amplitude of channel 4 and 6 to 2 Volts and the frequencies (of the same channel) to 50 Hz using: .. code-block:: none @@ -54,7 +54,7 @@ Furthermore, several operations may be perform in a single and compact script li Raw operations (-m option) ########################## -Independently of the user definition of options in the file driver_utilities.py, you may access any methods that are instantiated with the driver using the -m option. +Regardless of the user's definition of options in the driver_utilities.py file, you may access any methods that are instantiated with the driver using the -m option. .. important:: @@ -74,7 +74,7 @@ This allow you to simply copy and paste the method you want to use from the list >>> autolab driver -D instrument -C CONN -m get_amplitude() >>> autolab driver -D instrument -C CONN -m set_amplitude(value) -One may also call several methods separated with a space after -m option: +One may also call several methods separated by a space after the -m option: .. code-block:: none @@ -89,7 +89,7 @@ Script example ############## -One may stack in a single file several script line in order to perform custom measurement (modify several control parameters, etc.). This is a bash counterpart to the python scripting example provided there :ref:`name_pythonscript_example`. +One may stack several script lines in a single file in order to perform custom measurements (modify several control parameters, etc.). This is a bash counterpart to the Python scripting example provided there :ref:`name_pythonscript_example`. .. code-block:: none diff --git a/docs/shell/index.rst b/docs/shell/index.rst index 84f40385..a2ee0454 100644 --- a/docs/shell/index.rst +++ b/docs/shell/index.rst @@ -3,7 +3,7 @@ OS shell ======== -Most of the Autolab functions can also be used directly from a **Windows** or **Linux** terminal without opening explicitely a Python shell. +Most of the Autolab functions can also be used directly from a **Windows** or **Linux** terminal without opening explicitly a Python shell. Just execute the command ``autolab`` or ``autolab -h`` or ``autolab --help`` in your terminal to see the available subcommands. @@ -13,15 +13,15 @@ Just execute the command ``autolab`` or ``autolab -h`` or ``autolab --help`` in C:\Users\qchat> autolab -h Hostname:/home/User$ autolab --help -The subcommands are : +The subcommands are: -* ``autolab gui`` : a shortcut of the python function autolab.gui() to start the graphical interface of Autolab. -* ``autolab install_drivers`` : a shortcut of the python function autolab.install_drivers() to install drivers from GitHub -* ``autolab driver`` : a shortcut of the python interface Driver (see :ref:`os_driver`) -* ``autolab device`` : a shortcut of the python interface Device (see :ref:`os_device`) -* ``autolab doc`` : a shortcut of the python function autolab.doc() to open the present online documentation. -* ``autolab report`` : a shortcut of the python function autolab.report() to open the present online documentation. -* ``autolab infos`` : a shortcut of the python function autolab.infos() to list the drivers and the local configurations available on your system. +* ``autolab gui``: a shortcut of the python function autolab.gui() to start the graphical interface of Autolab. +* ``autolab install_drivers``: a shortcut of the python function autolab.install_drivers() to install drivers from GitHub +* ``autolab driver``: a shortcut of the python interface Driver (see :ref:`os_driver`) +* ``autolab device``: a shortcut of the python interface Device (see :ref:`os_device`) +* ``autolab doc``: a shortcut of the python function autolab.doc() to open the present online documentation. +* ``autolab report``: a shortcut of the python function autolab.report() to open the present online documentation. +* ``autolab infos``: a shortcut of the python function autolab.infos() to list the drivers and the local configurations available on your system. Table of contents: diff --git a/setup.py b/setup.py index aa9f0016..e7387b59 100644 --- a/setup.py +++ b/setup.py @@ -18,8 +18,10 @@ setup( name = 'autolab', version = version, # Ideally should be same as your GitHub release tag varsion - author = 'Quentin Chateiller & Bruno Garbin & Jonathan Peltier & Mathieu Jeannin', + author = 'Quentin Chateiller & Bruno Garbin', author_email = 'autolab-project@googlegroups.com', + maintainer = 'Jonathan Peltier & Mathieu Jeannin', + maintainer_email = 'autolab-project@googlegroups.com', license = "GPL-3.0 license", description = 'Python package for scientific experiments interfacing and automation', long_description = long_description,