diff --git a/README.md b/README.md index 74345517..70a564f1 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ +[![PyPi](https://img.shields.io/pypi/v/autolab)](https://pypi.org/project/autolab/) +[![Documentation Status](https://readthedocs.org/projects/autolab/badge/?version=latest)](https://autolab.readthedocs.io/en/latest/?badge=latest) + # Autolab __Python package for scientific experiments automation__ The purpose of this package it to provide easy and efficient tools to deal with your scientific instruments, and to run automated experiments with them, by command line instructions or through a graphical user interface (GUI). 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. +Project continued by Jonathan Peltier, for the C2N-CNRS Minaphot team and Mathieu Jeannin, for the C2N-CNRS Odin team. Project hosted at https://github.com/autolab-project/autolab diff --git a/autolab/__init__.py b/autolab/__init__.py index f3f35323..88835ea5 100644 --- a/autolab/__init__.py +++ b/autolab/__init__.py @@ -1,13 +1,11 @@ # -*- coding: utf-8 -*- """ -Created on Fri May 17 15:04:04 2019 - Python package for scientific experiments automation The purpose of this package it to provide easy and efficient tools to deal with your scientific instruments, and to run automated experiments with them, by command line instructions or through a graphical user interface (GUI). 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. +Project continued by Jonathan Peltier, for the C2N-CNRS Minaphot team and Mathieu Jeannin, for the C2N-CNRS Odin team. Project hosted at https://github.com/autolab-project/autolab @@ -31,7 +29,7 @@ # Load user config from .core import config as _config -first = _config.initialize_local_directory() +FIRST = _config.initialize_local_directory() _config.check_autolab_config() _config.check_plotter_config() _config.set_temp_folder() @@ -56,7 +54,7 @@ from .core.server import Server as server # GUI -from .core.gui import start as gui +from .core.gui import gui, plotter, monitor, slider, add_device, about, variables_menu # Repository from .core.repository import install_drivers @@ -68,10 +66,10 @@ from .core._create_shortcut import create_shortcut -if first: +if FIRST: # Ask if create shortcut create_shortcut(ask=True) -del first +del FIRST # Loading the drivers informations on startup _drivers.update_drivers_paths() diff --git a/autolab/_entry_script.py b/autolab/_entry_script.py index a65ce804..590cfd4e 100644 --- a/autolab/_entry_script.py +++ b/autolab/_entry_script.py @@ -21,6 +21,8 @@ def print_help(): print() print('Commands:') print(' gui Start the Graphical User Interface') + print(' plotter Start the Plotter') + print(' add_device Start add device menu') print(' install_drivers Install drivers from GitHub') print(' driver Driver interface') print(' device Device interface') @@ -48,20 +50,19 @@ def main(): args = [f'autolab {command}'] + args[2: ] # first is 'autolab' and second is command sys.argv = args - if command == 'doc': # Open help on read the docs - autolab.doc() - elif command == 'report': # Open github report issue webpage - autolab.report() - elif command == 'gui': # GUI - autolab.gui() - elif command == 'infos': - autolab.infos() - elif command == 'install_drivers': - autolab.install_drivers() - elif command == 'driver': + # Removed bellow and similar because getattr will get every standard command (only difference is now it raises error if gives too much arguments) + # if command == 'gui': + # autolab.gui() + if command == 'driver': driver_parser(args) elif command == 'device': device_parser(args) + elif command in dir(autolab): # Execute autolab.command if exists + attr = getattr(autolab, command) + if hasattr(attr, '__call__'): + attr(*args[1: ]) + else: + print(attr) else: print(f"Command {command} not known. Autolab doesn't have Super Cow Power... yet ^^") @@ -163,14 +164,14 @@ def driver_parser(args_list: List[str]): # Instantiation of driver.py and driver_utilities.py global driver_instance - assert 'connection' in config.keys(), f"Must provide a connection for the driver using -C connection with connection being for this driver among {autolab._drivers.get_connection_names(autolab._drivers.load_driver_lib(driver_name))}" + assert 'connection' in config, f"Must provide a connection for the driver using -C connection with connection being for this driver among {autolab._drivers.get_connection_names(autolab._drivers.load_driver_lib(driver_name))}" driver_instance = autolab.get_driver(driver_name, **config) if driver_name in autolab._config.list_all_devices_configs(): # Load config object config = dict(autolab._config.get_device_config(driver_name)) # Check if driver provided - assert 'driver' in config.keys(), f"Driver name not found in driver config '{driver_name}'" + assert 'driver' in config, f"Driver name not found in driver config '{driver_name}'" driver_name = config['driver'] driver_utilities = autolab._drivers.load_driver_utilities_lib(driver_name + '_utilities') diff --git a/autolab/autolab.pdf b/autolab/autolab.pdf index bceee4a1..d966177b 100644 Binary files a/autolab/autolab.pdf and b/autolab/autolab.pdf differ diff --git a/autolab/core/config.py b/autolab/core/config.py index 09cc8f8f..bc453cee 100644 --- a/autolab/core/config.py +++ b/autolab/core/config.py @@ -6,6 +6,7 @@ """ import os +import tempfile import configparser from typing import List from . import paths @@ -19,7 +20,7 @@ def initialize_local_directory() -> bool: """ This function creates the default autolab local directory. Returns True if create default autolab folder (first autolab use) """ - first = False + FIRST = False _print = True # LOCAL DIRECTORY if not os.path.exists(paths.USER_FOLDER): @@ -30,7 +31,7 @@ def initialize_local_directory() -> bool: "It also contains the 'driver' directory with 'official' and 'local' sub-directories." ) _print = False - first = True + FIRST = True # DEVICES CONFIGURATION FILE if not os.path.exists(paths.DEVICES_CONFIG): @@ -62,7 +63,7 @@ def initialize_local_directory() -> bool: save_config('plotter', configparser.ConfigParser()) if _print: print(f'The configuration file plotter_config.ini has been created: {paths.PLOTTER_CONFIG}') - return first + return FIRST def save_config(config_name, config): @@ -137,15 +138,14 @@ def check_autolab_config(): # 'plotter': {'precision': 10}, } - for section_key in autolab_dict.keys(): - dic = autolab_dict[section_key] + for section_key, section_dic in autolab_dict.items(): if section_key in autolab_config.sections(): conf = dict(autolab_config[section_key]) - for key in dic.keys(): - if key not in conf.keys(): - conf[key] = str(dic[key]) + for key, dic in section_dic.items(): + if key not in conf: + conf[key] = str(dic) else: - conf = dic + conf = section_dic autolab_config[section_key] = conf @@ -220,7 +220,6 @@ def set_temp_folder() -> str: temp_folder = get_directories_config()["temp_folder"] if temp_folder == 'default': - import tempfile # Try to get TEMP, if not get tempfile temp_folder = os.environ.get('TEMP', tempfile.gettempdir()) @@ -271,15 +270,14 @@ def check_plotter_config(): 'device': {'address': 'dummy.array_1D'}, } - for section_key in plotter_dict.keys(): - dic = plotter_dict[section_key] + for section_key, section_dic in plotter_dict.items(): if section_key in plotter_config.sections(): conf = dict(plotter_config[section_key]) - for key in dic.keys(): - if key not in conf.keys(): - conf[key] = str(dic[key]) + for key, dic in section_dic.items(): + if key not in conf: + conf[key] = str(dic) else: - conf = dic + conf = section_dic plotter_config[section_key] = conf diff --git a/autolab/core/devices.py b/autolab/core/devices.py index bd8f2751..41a64343 100644 --- a/autolab/core/devices.py +++ b/autolab/core/devices.py @@ -5,11 +5,11 @@ @author: quentin.chateiller """ -from typing import List +from typing import List, Union from . import drivers from . import config -from .elements import Module +from .elements import Module, Element # Storage of the devices DEVICES = {} @@ -27,13 +27,25 @@ def __init__(self, device_name: str, instance, device_config: dict): self.device_config = device_config # hidden from completion self.driver_path = drivers.get_driver_path(device_config["driver"]) - Module.__init__(self, None, {'name': device_name, 'object': instance, - 'help': f'Device {device_name} at {self.driver_path}'}) + super().__init__(None, {'name': device_name, 'object': instance, + 'help': f'Device {device_name} at {self.driver_path}'}) def close(self): """ This function close the connection of the current physical device """ + # Remove read and write signals from gui + try: + # condition avoid reopenning connection if use close twice + if self.name in DEVICES: + for struc in self.get_structure(): + element = get_element_by_address(struc[0]) + if struc[1] == 'variable': + element._read_signal = None + element._write_signal = None + except: pass + try: self.instance.close() except: pass + del DEVICES[self.name] def __dir__(self): @@ -46,11 +58,16 @@ def __dir__(self): # DEVICE GET FUNCTION # ============================================================================= -def get_element_by_address(address: str) -> Device: - """ Returns the Element located at the provided address """ +def get_element_by_address(address: str) -> Union[Element, None]: + """ Returns the Element located at the provided address if exists """ address = address.split('.') try: - element = get_device(address[0]) + device_name = address[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 addressPart in address[1: ]: element = getattr(element, addressPart.replace(" ", "")) return element @@ -70,12 +87,12 @@ def get_final_device_config(device_name: str, **kwargs) -> dict: device_config[key] = value # And the argument connection has to be provided - assert 'driver' in device_config.keys(), f"Missing driver name for device '{device_name}'" + assert 'driver' in device_config, f"Missing driver name for device '{device_name}'" if device_config['driver'] == 'autolab_server': device_config['connection'] = 'USELESS_ENTRY' - assert 'connection' in device_config.keys(), f"Missing connection type for device '{device_name}'" + assert 'connection' in device_config, f"Missing connection type for device '{device_name}'" return device_config @@ -103,7 +120,7 @@ def get_device(device_name: str, **kwargs) -> Device: def list_loaded_devices() -> List[str]: ''' Returns the list of the loaded devices ''' - return list(DEVICES.keys()) + return list(DEVICES) def list_devices() -> List[str]: @@ -124,7 +141,7 @@ def get_devices_status() -> dict: # CLOSE DEVICES # ============================================================================= -def close(device: Device = "all"): +def close(device: Union[str, Device] = "all"): """ Close a device by providing its name or its instance. Use 'all' to close all openned devices. """ if str(device) == "all": diff --git a/autolab/core/drivers.py b/autolab/core/drivers.py index 4dc8b6b7..351495d4 100644 --- a/autolab/core/drivers.py +++ b/autolab/core/drivers.py @@ -54,14 +54,12 @@ def load_lib(lib_path: str) -> ModuleType: # Load the module spec = importlib.util.spec_from_file_location(lib_name, lib_path) lib = importlib.util.module_from_spec(spec) - try: - spec.loader.exec_module(lib) - except Exception as e: - print(f"Can't load {lib}: {e}", file=sys.stderr) # Come back to previous working directory os.chdir(curr_dir) + spec.loader.exec_module(lib) + return lib @@ -110,14 +108,14 @@ def list_drivers() -> List[str]: ''' Returns the list of available drivers ''' # To be sure that the list is up to date update_drivers_paths() - return sorted(list(DRIVERS_PATHS.keys())) + return sorted(list(DRIVERS_PATHS)) # ============================================================================= # DRIVERS INSPECTION # ============================================================================= -def get_module_names(driver_lib: ModuleType) -> str: +def get_module_names(driver_lib: ModuleType) -> List[str]: ''' Returns the list of the driver's Module(s) name(s) (classes Module_XXX) ''' return [name.split('_')[1] for name, obj in inspect.getmembers(driver_lib, inspect.isclass) @@ -125,7 +123,7 @@ def get_module_names(driver_lib: ModuleType) -> str: and name.startswith('Module_')] -def get_connection_names(driver_lib: ModuleType) -> str: +def get_connection_names(driver_lib: ModuleType) -> List[str]: ''' Returns the list of the driver's connection types (classes Driver_XXX) ''' return [name.split('_')[1] for name, obj in inspect.getmembers(driver_lib, inspect.isclass) @@ -139,14 +137,17 @@ def get_driver_category(driver_name: str) -> str: driver_utilities_path = os.path.join( os.path.dirname(get_driver_path(driver_name)), f'{driver_name}{filename}.py') - category = 'Other' + category = 'Unknown' if os.path.exists(driver_utilities_path): - driver_utilities = load_lib(driver_utilities_path) - - if hasattr(driver_utilities, 'category'): - category = driver_utilities.category - break + try: + driver_utilities = load_lib(driver_utilities_path) + except Exception as e: + print(f"Can't load {driver_name}: {e}", file=sys.stderr) + else: + if hasattr(driver_utilities, 'category'): + category = driver_utilities.category + break return category @@ -160,11 +161,181 @@ def get_driver_class(driver_lib: ModuleType) -> Type: def get_connection_class(driver_lib: ModuleType, connection: str) -> Type: ''' Returns the class Driver_XXX of the provided driver library and connection type ''' + if connection in get_connection_names(driver_lib): + return getattr(driver_lib, f'Driver_{connection}') + + 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') + 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)}" - return getattr(driver_lib, f'Driver_{connection}') -def get_module_class(driver_lib: ModuleType, module_name: str): +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') + + if connection == 'DEFAULT': + class Driver_DEFAULT(Driver): + def __init__(self): + Driver.__init__(self) + + return Driver_DEFAULT + + if connection == 'VISA': + class Driver_VISA(Driver): + def __init__(self, address='GPIB0::2::INSTR', **kwargs): + import pyvisa as visa + + self.TIMEOUT = 15000 # ms + + rm = visa.ResourceManager() + self.controller = rm.open_resource(address) + self.controller.timeout = self.TIMEOUT + + Driver.__init__(self) + + def close(self): + try: self.controller.close() + except: pass + + def query(self, command): + result = self.controller.query(command) + result = result.strip('\n') + return result + + def write(self, command): + self.controller.write(command) + + def read(self): + return self.controller.read() + + return Driver_VISA + + if connection == 'GPIB': + class Driver_GPIB(Driver): + def __init__(self, address=23, board_index=0, **kwargs): + import Gpib + + self.inst = Gpib.Gpib(int(board_index), int(address)) + Driver.__init__(self) + + def query(self, query): + self.write(query) + return self.read() + + def write(self, query): + self.inst.write(query) + + def read(self, length=1000000000): + return self.inst.read().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) + pass + + return Driver_USB + + 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 + + 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 + + Driver.__init__(self) + + 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 + + return Driver_USB + + if connection == 'SOCKET': + class Driver_SOCKET(Driver): + + def __init__(self, address='192.168.0.8', **kwargs): + + import socket + + self.ADDRESS = address + self.PORT = 5005 + self.BUFFER_SIZE = 40000 + + self.controller = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.controller.connect((self.ADDRESS, int(self.PORT))) + + Driver.__init__(self) + + def write(self, command): + self.controller.send(command.encode()) + self.controller.recv(self.BUFFER_SIZE) + + def query(self, command): + self.controller.send(command.encode()) + data = self.controller.recv(self.BUFFER_SIZE) + return data.decode() + + def close(self): + try: self.controller.close() + except: pass + self.controller = None + + return Driver_SOCKET + + if connection == 'TEST': + class Controller: pass + class Driver_TEST(Driver): + def __init__(self, *args, **kwargs): + try: + Driver.__init__(self) + except: + Driver.__init__(self, *args, **kwargs) + + self.controller = Controller() + self.controller.timeout = 5000 + + def write(self, value): + pass + def read(self): + return '1' + def read_raw(self): + return b'1' + def query(self, value): + self.write(value) + return self.read() + + return Driver_TEST + + return None + + +def get_module_class(driver_lib: ModuleType, module_name: str) -> Type: ''' Returns the class Module_XXX of the provided driver library and module_name''' assert module_name in get_module_names(driver_lib) return getattr(driver_lib, f'Module_{module_name}') @@ -181,7 +352,7 @@ def explore_driver(instance: Type, _print: bool = True) -> str: if _print: print(s) return None - else: return s + return s def get_instance_methods(instance: Type) -> Type: @@ -189,21 +360,19 @@ def get_instance_methods(instance: Type) -> Type: methods = [] # LEVEL 1 - for name, obj in inspect.getmembers(instance, inspect.ismethod): + for name, _ in inspect.getmembers(instance, inspect.ismethod): if name != '__init__': attr = getattr(instance, name) - args = list(inspect.signature(attr).parameters.keys()) + args = list(inspect.signature(attr).parameters) methods.append([name, args]) # LEVEL 2 - instance_vars = vars(instance) - for key in instance_vars.keys(): - try: # explicit to avoid visa and inspect.getmembers issue - name_obj = inspect.getmembers(instance_vars[key], inspect.ismethod) - if name_obj != '__init__' and name_obj and name != '__init__': - for name, obj in name_obj: + for key, val in vars(instance).items(): + 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) - args = list(inspect.signature(attr).parameters.keys()) + args = list(inspect.signature(attr).parameters) methods.append([f'{key}.{name}', args]) except: pass @@ -224,8 +393,8 @@ def get_class_args(clas: Type) -> dict: def get_driver_path(driver_name: str) -> str: ''' Returns the config associated with driver_name ''' - assert type(driver_name) is str, "drive_name must be a string." - assert driver_name in DRIVERS_PATHS.keys(), f'Driver {driver_name} not found.' + assert isinstance(driver_name, str), "drive_name must be a string." + assert driver_name in DRIVERS_PATHS, f'Driver {driver_name} not found.' return DRIVERS_PATHS[driver_name]['path'] @@ -255,5 +424,6 @@ def load_drivers_paths() -> dict: def update_drivers_paths(): + ''' Update list of available driver ''' global DRIVERS_PATHS DRIVERS_PATHS = load_drivers_paths() diff --git a/autolab/core/elements.py b/autolab/core/elements.py index b9461a74..b9973f25 100644 --- a/autolab/core/elements.py +++ b/autolab/core/elements.py @@ -10,6 +10,9 @@ import inspect from typing import Type, Tuple, List, Any +import numpy as np +import pandas as pd + from . import paths from .utilities import emphasize, clean_string, SUPPORTED_EXTENSION @@ -28,50 +31,48 @@ def address(self) -> str: """ if self._parent is not None: return self._parent.address() + '.' + self.name - else: return self.name + return self.name class Variable(Element): def __init__(self, parent: Type, config: dict): - Element.__init__(self, parent, 'variable', config['name']) - - import numpy as np - import pandas as pd + super().__init__(parent, 'variable', config['name']) # Type - assert 'type' in config.keys(), f"Variable {self.address()}: Missing variable type" + 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'] # Read and write function - assert 'read' in config.keys() or 'write' in config.keys(), f"Variable {self.address()} configuration: no 'read' nor 'write' functions provided" + assert 'read' in config or 'write' in config, f"Variable {self.address()} configuration: no 'read' nor 'write' functions provided" # Read function self.read_function = None self.read_init = False - if config['type'] in [tuple]: assert 'read' in config.keys(), f"Variable {self.address()} configuration: Must provide a read function" - if 'read' in config.keys(): + 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'] - if 'read_init' in config.keys(): - assert type(config['read_init']) is bool, f"Variable {self.address()} configuration: read_init parameter must be a boolean" + if 'read_init' in config: + assert isinstance(config['read_init'], bool), f"Variable {self.address()} configuration: read_init parameter must be a boolean" self.read_init = bool(config['read_init']) + # Write function self.write_function = None - if 'write' in config.keys(): + if 'write' in config: assert inspect.ismethod(config['write']), f"Variable {self.address()} configuration: Write parameter must be a function" self.write_function = config['write'] # Unit self.unit = None - if 'unit' in config.keys(): + if 'unit' in config: assert isinstance(config['unit'], str), f"Variable {self.address()} configuration: Unit parameter must be a string" self.unit = config['unit'] # Help - if 'help' in config.keys(): + if 'help' in config: assert isinstance(config['help'], str), f"Variable {self.address()} configuration: Info parameter must be a string" self._help = config['help'] @@ -88,9 +89,6 @@ 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 """ - import pandas as pd - import numpy as np - assert self.readable, f"The variable {self.name} is not configured to be measurable" if os.path.isdir(path): @@ -103,8 +101,12 @@ def save(self, path: str, value: Any = None): elif self.type == bytes: with open(path, 'wb') as f: f.write(value) elif self.type == np.ndarray: - value = pd.DataFrame(value) # faster and handle better different dtype than np.savetxt - value.to_csv(path, index=False, header=None) + try: + value = pd.DataFrame(value) # faster and handle better different dtype than np.savetxt + value.to_csv(path, index=False, header=None) + except: + # Avoid error if strange ndim, 0 or (1,2,3) ... was occuring in GUI scan when doing $eval:[1] instead of $eval:np.array([1]). Now GUI forces array to ndim=1 + print(f"Warning, can't save {value}") elif self.type == pd.DataFrame: value.to_csv(path, index=False) else: @@ -114,7 +116,7 @@ def help(self): """ This function prints informations for the user about the current variable """ print(self) - def __str__(self): + def __str__(self) -> str: """ This function returns informations for the user about the current variable """ display = '\n' + emphasize(f'Variable {self.name}') + '\n' if self._help is not None: display += f'Help: {self._help}\n' @@ -136,7 +138,7 @@ def __str__(self): return display - def __call__(self, value: Any = None): + def __call__(self, value: Any = None) -> Any: """ Measure or set the value of the variable """ # GET FUNCTION if value is None: @@ -146,43 +148,40 @@ def __call__(self, value: Any = None): return answer # SET FUNCTION + assert self.writable, f"The variable {self.name} is not writable" + + if isinstance(value, np.ndarray): + value = np.array(value, ndmin=1) # ndim=1 to avoid having float if 0D else: - assert self.writable, f"The variable {self.name} is not writable" - import numpy as np - 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.write_function(value) - if self._write_signal is not None: self._write_signal.emit_write() + value = self.type(value) + self.write_function(value) + if self._write_signal is not None: self._write_signal.emit_write() + return None class Action(Element): def __init__(self, parent: Type, config: dict): - Element.__init__(self, parent, 'action', config['name']) - - import pandas as pd - import numpy as np + super().__init__(parent, 'action', config['name']) # Do function - assert 'do' in config.keys(), f"Action {self.address()}: Missing 'do' function" + assert 'do' in config, f"Action {self.address()}: Missing 'do' function" assert inspect.ismethod(config['do']), f"Action {self.address()} configuration: Do parameter must be a function" self.function = config['do'] # Argument self.type = None self.unit = None - if 'param_type' in config.keys(): + if 'param_type' in config: assert config['param_type'] in [int, float, bool, str, bytes, tuple, np.ndarray, pd.DataFrame], f"Action {self.address()} configuration: Argument type not supported in autolab" self.type = config['param_type'] - if 'param_unit' in config.keys(): + if 'param_unit' in config: assert isinstance(config['param_unit'], str), f"Action {self.address()} configuration: Argument unit parameter must be a string" self.unit = config['param_unit'] # Help - if 'help' in config.keys(): + if 'help' in config: assert isinstance(config['help'], str), f"Action {self.address()} configuration: Info parameter must be a string" self._help = config['help'] @@ -192,7 +191,7 @@ def help(self): """ This function prints informations for the user about the current variable """ print(self) - def __str__(self): + def __str__(self) -> str: """ This function returns informations for the user about the current variable """ display = '\n' + emphasize(f'Action {self.name}') + '\n' if self._help is not None: display+=f'Help: {self._help}\n' @@ -204,12 +203,12 @@ def __str__(self): display += f'Parameter: YES (type: {self.type.__name__})' if self.unit is not None: display += f'(unit: {self.unit})' display += '\n' - else : + else: display += 'Parameter: NO\n' return display - def __call__(self, value: Any = None): + 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" @@ -225,16 +224,16 @@ def __call__(self, value: Any = None): self.unit = 'open-file' from qtpy import QtWidgets - app = QtWidgets.QApplication(sys.argv) # Needed if started outside of GUI + _ = QtWidgets.QApplication(sys.argv) # Needed if started outside of GUI if self.unit == 'open-file': filename, _ = QtWidgets.QFileDialog.getOpenFileName( - caption="Open file", + caption=f"Open file - {self.name}", directory=paths.USER_LAST_CUSTOM_FOLDER, filter=SUPPORTED_EXTENSION) elif self.unit == 'save-file': filename, _ = QtWidgets.QFileDialog.getSaveFileName( - caption="Save file", + caption=f"Save file - {self.name}", directory=paths.USER_LAST_CUSTOM_FOLDER, filter=SUPPORTED_EXTENSION) @@ -243,7 +242,19 @@ def __call__(self, value: Any = None): paths.USER_LAST_CUSTOM_FOLDER = path self.function(filename) else: - print("Filename prompt cancelled") + print(f"Action '{self.name}' cancel filename selection") + + elif self.unit == "user-input": + + from qtpy import QtWidgets + _ = 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", + QtWidgets.QLineEdit.Normal) + + if response != '': + self.function(response) else: assert value is not None, f"The action {self.name} requires an argument" else: @@ -255,7 +266,7 @@ class Module(Element): def __init__(self, parent: Type, config: dict): - Element.__init__(self, parent, 'module', config['name']) + super().__init__(parent, 'module', config['name']) self._mod = {} self._var = {} @@ -263,11 +274,11 @@ def __init__(self, parent: Type, config: dict): self._read_init_list = [] # Object - instance - assert 'object' in config.keys(), f"Module {self.name}: missing module object" + assert 'object' in config, f"Module {self.name}: missing module object" self.instance = config['object'] # Help - if 'help' in config.keys(): + if 'help' in config: assert isinstance(config['help'], str), f"Module {self.address()} configuration: Help parameter must be a string" self._help = config['help'] @@ -281,13 +292,13 @@ def __init__(self, parent: Type, config: dict): assert isinstance(config_line, dict), f"Module {self.name} configuration: 'get_driver_model' output must be a list of dictionnaries" # Name check - assert 'name' in config_line.keys(), f"Module {self.name} configuration: missing 'name' key in one dictionnary" + 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" name = clean_string(config_line['name']) assert name != '', f"Module {self.name}: elements names cannot be empty" # Element type check - assert 'element' in config_line.keys(), f"Module {self.name}, Element {name} configuration: missing 'element' key in the dictionnary" + 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" 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'" @@ -316,7 +327,7 @@ def get_module(self, name: str) -> Type: # -> Module def list_modules(self) -> List[str]: """ Returns a list with the names of all existing submodules """ - return list(self._mod.keys()) + return list(self._mod) def get_variable(self, name: str) -> Variable: """ Returns the variable with the given name """ @@ -325,7 +336,7 @@ def get_variable(self, name: str) -> Variable: def list_variables(self) -> List[str]: """ Returns a list with the names of all existing variables attached to this module """ - return list(self._var.keys()) + return list(self._var) def get_action(self, name) -> Action: """ Returns the action with the given name """ @@ -334,19 +345,19 @@ def get_action(self, name) -> Action: def list_actions(self) -> List[str]: """ Returns a list with the names of all existing actions attached to this module """ - return list(self._act.keys()) + return list(self._act) def get_names(self) -> List[str]: """ Returns the list of the names of all the elements of this module """ return self.list_modules() + self.list_variables() + self.list_actions() - def __getattr__(self, attr) -> Element: + def __getattr__(self, attr: str) -> Element: if attr in self.list_variables(): return self.get_variable(attr) - elif attr in self.list_actions(): return self.get_action(attr) - elif attr in self.list_modules(): return self.get_module(attr) - else: raise AttributeError(f"'{attr}' not found in module '{self.name}'") + 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}'") - def get_structure(self): + 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 [['address1', 'variable'], ['address2', 'action'],...] """ structure = [] @@ -354,9 +365,9 @@ def get_structure(self): for mod in self.list_modules(): structure += self.get_module(mod).get_structure() for var in self.list_variables(): - structure.append([self.get_variable(var).address(), 'variable']) + structure.append((self.get_variable(var).address(), 'variable')) for act in self.list_actions(): - structure.append([self.get_action(act).address(), 'action']) + structure.append((self.get_action(act).address(), 'action')) return structure @@ -366,16 +377,16 @@ def sub_hierarchy(self, level: int = 0) -> List[Tuple[str, str, int]]: ''' h = [] - from .devices import Device - if isinstance(self, Device): h.append([self.name, 'Device/Module', level]) - else: h.append([self.name, 'Module', level]) + from .devices import Device # import here to avoid ImportError circular import + if isinstance(self, Device): h.append((self.name, 'Device/Module', level)) + else: h.append((self.name, 'Module', level)) for mod in self.list_modules(): h += self.get_module(mod).sub_hierarchy(level+1) for var in self.list_variables(): - h.append([var, 'Variable', level+1]) + h.append((var, 'Variable', level+1)) for act in self.list_actions(): - h.append([act, 'Action', level+1]) + h.append((act, 'Action', level+1)) return h diff --git a/autolab/core/gui/GUI_utilities.py b/autolab/core/gui/GUI_utilities.py index 04bc3679..4b2360a1 100644 --- a/autolab/core/gui/GUI_utilities.py +++ b/autolab/core/gui/GUI_utilities.py @@ -5,12 +5,34 @@ @author: jonathan """ +from typing import Tuple +import os +import sys -from qtpy import QtWidgets +import numpy as np +from qtpy import QtWidgets, QtCore, QtGui +import pyqtgraph as pg from ..config import get_GUI_config +# Fixes pyqtgraph/issues/3018 for pg<=0.13.7 (before pyqtgraph/pull/3070) +from pyqtgraph.graphicsItems.PlotDataItem import PlotDataItem + +if hasattr(PlotDataItem, '_fourierTransform'): + + _fourierTransform_bugged = PlotDataItem._fourierTransform + + def _fourierTransform_fixed(self, x, y): + if len(x) == 1: return np.array([0]), abs(y) + return _fourierTransform_bugged(self, x, y) + + PlotDataItem._fourierTransform = _fourierTransform_fixed + + +ONCE = False + + def get_font_size() -> int: GUI_config = get_GUI_config() if GUI_config['font_size'] != 'default': @@ -34,3 +56,273 @@ def setLineEditBackground(obj, state: str, font_size: int = None): obj.setStyleSheet( "QLineEdit:enabled {background-color: %s; font-size: %ipt}" % ( color, font_size)) + + +CHECK_ONCE = True + + +def qt_object_exists(QtObject) -> bool: + """ Return True if object exists (not deleted). + Check if use pyqt5, pyqt6, pyside2 or pyside6 to use correct implementation + """ + global CHECK_ONCE + QT_API = os.environ.get("QT_API") + + if not CHECK_ONCE: return True + try: + if QT_API in ("pyqt5", "pyqt6"): + import sip + return not sip.isdeleted(QtObject) + if QT_API == "pyside2": + import shiboken2 + return shiboken2.isValid(QtObject) + if QT_API =="pyside6": + import shiboken6 + return shiboken6.isValid(QtObject) + raise ModuleNotFoundError(f"QT_API '{QT_API}' unknown") + except ModuleNotFoundError as e: + print(f"Warning: {e}. Skip check if Qt Object not deleted.") + CHECK_ONCE = False + return True + + +class MyGraphicsLayoutWidget(pg.GraphicsLayoutWidget): + # OPTIMIZE: could merge with myImageView to only have one class handling both lines and images + + def __init__(self): + super().__init__() + + self.img_active = False + + # for plotting 1D + ax = self.addPlot() + self.ax = ax + + ax.setLabel("bottom", ' ', **{'color':0.4, 'font-size': '12pt'}) + ax.setLabel("left", ' ', **{'color':0.4, 'font-size': '12pt'}) + + # Set your custom font for both axes + my_font = QtGui.QFont('Arial', 12) + my_font_tick = QtGui.QFont('Arial', 10) + ax.getAxis("bottom").label.setFont(my_font) + ax.getAxis("left").label.setFont(my_font) + 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)) + + ## Text label for the data coordinates of the mouse pointer + dataLabel = pg.LabelItem(color='k', parent=ax.getAxis('bottom')) + dataLabel.anchor(itemPos=(1,1), parentPos=(1,1), offset=(0,0)) + + def mouseMoved(point): + """ This function marks the position of the cursor in data coordinates""" + vb = ax.getViewBox() + mousePoint = vb.mapSceneToView(point) + l = f'x = {mousePoint.x():g}, y = {mousePoint.y():g}' + dataLabel.setText(l) + + # data reader signal connection + ax.scene().sigMouseMoved.connect(mouseMoved) + + def activate_img(self): + """ Enable image feature """ + global ONCE + # (py 3.6 -> pg 0.11.1, py 3.7 -> 0.12.4, py 3.8 -> 0.13.3, py 3.9 -> 0.13.7 (latest)) + # Disabled 2D plot if don't have pyqtgraph > 0.11 + pgv = pg.__version__.split('.') + if int(pgv[0]) == 0 and int(pgv[1]) < 12: + ONCE = True + print("Can't use 2D plot for scan, need pyqtgraph >= 0.13.2", file=sys.stderr) + # OPTIMIZE: could use ImageView instead? + return None + + if not self.img_active: + self.img_active = True + + # for plotting 2D + img = pg.PColorMeshItem() + self.ax.addItem(img) + self.img = img + + # for plotting 2D colorbar + if hasattr(self.ax, 'addColorBar') and hasattr(img, 'setLevels'): + self.colorbar = self.ax.addColorBar(img, colorMap='viridis') # pg 0.12.4 + else: + if hasattr(pg, 'ColorBarItem'): + self.colorbar = pg.ColorBarItem(colorMap='viridis') # pg 0.12.2 + else: + self.colorbar = pg.HistogramLUTItem() # pg 0.11.0 (disabled) + self.addItem(self.colorbar) + + if not ONCE: + ONCE = True + print('Skip colorbar update, need pyqtgraph >= 0.13.2', file=sys.stderr) + + self.colorbar.hide() + + def update_img(self, x, y, z): + """ Update pcolormesh image """ + global ONCE + z_no_nan = z[~np.isnan(z)] + z[np.isnan(z)] = z_no_nan.min()-1e99 # OPTIMIZE: nan gives error, would prefer not to display empty values + + # Expand x and y arrays to define edges of the grid + if len(x) == 1: + x = np.append(x, x[-1]+1e-99) # OPTIMIZE: first line too small and not visible if autoscale disabled, could use next x value instead but figure should not be aware of scan + else: + x = np.append(x, x[-1] + (x[-1] - x[-2])) + + if len(y) == 1: + y = np.append(y, y[-1]+1e-99) + else: + y = np.append(y, y[-1] + (y[-1] - y[-2])) + + xv, yv = np.meshgrid(y, x) + + img = pg.PColorMeshItem() + img.edgecolors = None + img.setData(xv, yv, z.T) + # OPTIMIZE: Changing log scale doesn't display correct axes + if hasattr(img, 'setLevels'): # pg 0.13.2 introduces setLevels in PColorMeshItem (py 3.8) + self.colorbar.setImageItem(img) + else: + if not ONCE: + ONCE = True + print('Skip colorbar update, need pyqtgraph >= 0.13.2', file=sys.stderr) + + if isinstance(self.colorbar, pg.HistogramLUTItem): # old + self.colorbar.setLevels(z_no_nan.min(), z_no_nan.max()) + else: # new + self.colorbar.setLevels((z_no_nan.min(), z_no_nan.max())) + + # remove previous img and add new one (can't just refresh -> error if setData with nan and diff shape) + self.ax.removeItem(self.img) + self.img = img + self.ax.addItem(self.img) + + +def pyqtgraph_fig_ax() -> Tuple[MyGraphicsLayoutWidget, pg.PlotItem]: + """ Return a formated fig and ax pyqtgraph for a basic plot """ + fig = MyGraphicsLayoutWidget() + return fig, fig.ax + + +class myImageView(pg.ImageView): + ''' Wrap of pg.ImageView with additionnal functionalities ''' + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + # update tick background on gradient change + self.ui.histogram.gradient.sigGradientChanged.connect(self.update_ticks) + + self.figLineROI, self.axLineROI = pyqtgraph_fig_ax() + self.figLineROI.hide() + self.plot = self.axLineROI.plot([], [], pen='k') + + self.lineROI = pg.LineSegmentROI([[0, 100], [100, 100]], pen='r') + self.lineROI.sigRegionChanged.connect(self.updateLineROI) + self.lineROI.hide() + + self.addItem(self.lineROI) + + # update slice when change frame number in scanner + self.timeLine.sigPositionChanged.connect(self.updateLineROI) + + slice_pushButton = QtWidgets.QPushButton('Slice') + slice_pushButton.state = False + slice_pushButton.setMinimumSize(0, 23) + slice_pushButton.setMaximumSize(75, 23) + slice_pushButton.clicked.connect(self.slice_pushButtonClicked) + self.slice_pushButton = slice_pushButton + + horizontalLayoutButton = QtWidgets.QHBoxLayout() + horizontalLayoutButton.setSpacing(0) + horizontalLayoutButton.setContentsMargins(0,0,0,0) + horizontalLayoutButton.addStretch() + horizontalLayoutButton.addWidget(self.slice_pushButton) + + widgetButton = QtWidgets.QWidget() + widgetButton.setLayout(horizontalLayoutButton) + + verticalLayoutImageButton = QtWidgets.QVBoxLayout() + verticalLayoutImageButton.setSpacing(0) + verticalLayoutImageButton.setContentsMargins(0,0,0,0) + verticalLayoutImageButton.addWidget(self) + verticalLayoutImageButton.addWidget(widgetButton) + + widgetImageButton = QtWidgets.QWidget() + widgetImageButton.setLayout(verticalLayoutImageButton) + + splitter = QtWidgets.QSplitter() + splitter.setOrientation(QtCore.Qt.Vertical) + splitter.addWidget(widgetImageButton) + splitter.addWidget(self.figLineROI) + splitter.setSizes([500,500]) + + verticalLayoutMain = QtWidgets.QVBoxLayout() + verticalLayoutMain.setSpacing(0) + verticalLayoutMain.setContentsMargins(0,0,0,0) + verticalLayoutMain.addWidget(splitter) + + centralWidget = QtWidgets.QWidget() + centralWidget.setLayout(verticalLayoutMain) + self.centralWidget = centralWidget + + def update_ticks(self): + for tick in self.ui.histogram.gradient.ticks: + tick.pen = pg.mkPen(pg.getConfigOption("foreground")) + tick.currentPen = tick.pen + tick.hoverPen = pg.mkPen(200, 120, 0) + + def slice_pushButtonClicked(self): + self.slice_pushButton.state = not self.slice_pushButton.state + self.display_line() + + def display_line(self): + if self.slice_pushButton.state: + self.figLineROI.show() + self.lineROI.show() + self.updateLineROI() + else: + self.figLineROI.hide() + self.lineROI.hide() + + def show(self): + self.centralWidget.show() + + def hide(self): + self.centralWidget.hide() + + def roiChanged(self): + pg.ImageView.roiChanged(self) + for c in self.roiCurves: + c.setPen(pg.getConfigOption("foreground")) + + def setImage(self, *args, **kwargs): + pg.ImageView.setImage(self, *args, **kwargs) + self.updateLineROI() + + def updateLineROI(self): + if self.slice_pushButton.state: + img = self.image if self.image.ndim == 2 else self.image[self.currentIndex] + img = np.array([img]) + + x,y = (0, 1) if self.imageItem.axisOrder == 'col-major' else (1, 0) + d2 = self.lineROI.getArrayRegion(img, self.imageItem, axes=(x+1, y+1)) + self.plot.setData(d2[0]) + + def close(self): + self.figLineROI.deleteLater() + super().close() + + +def pyqtgraph_image() -> Tuple[myImageView, QtWidgets.QWidget]: + """ Return a formated ImageView and pyqtgraph widget for image plotting """ + imageView = myImageView() + return imageView, imageView.centralWidget diff --git a/autolab/core/gui/__init__.py b/autolab/core/gui/__init__.py index 60e26b5f..6dd8afaa 100644 --- a/autolab/core/gui/__init__.py +++ b/autolab/core/gui/__init__.py @@ -20,8 +20,61 @@ # t.start() -def start(): +def _create_item(var): + from .variables import Variable + from ..elements import Variable as Variable_og + + class temp: + gui = None + + item = temp() + if isinstance(var, (Variable, Variable_og)): + item.variable = var + else: + item.variable = Variable('Variable', var) + return item + + +def gui(): """ Open the Autolab GUI """ + _start('gui') + + +def plotter(): + """ Open the Autolab Plotter """ + _start('plotter') + + +def monitor(var): + """ Open the Autolab Monitor for variable var """ + item = _create_item(var) + _start('monitor', item=item) + + +def slider(var): + """ Open a slider for variable var """ + item = _create_item(var) + _start('slider', item=item) + + +def add_device(): + """ Open the utility to add a device """ + _start('add_device') + + +def about(): + """ Open the about window """ + _start('about') + + +def variables_menu(): + """ Open the variables menu """ + _start('variables_menu') + + +def _start(gui: str, **kwargs): + """ Open the Autolab GUI if gui='gui', the Plotter if gui='plotter' + or the Monitor if gui='monitor' """ import os from ..config import get_GUI_config @@ -66,9 +119,34 @@ def start(): font.setPointSize(int(GUI_config['font_size'])) app.setFont(font) - from .controlcenter.main import ControlCenter - gui = ControlCenter() - gui.initialize() + if gui == 'gui': + from .controlcenter.main import ControlCenter + gui = ControlCenter() + gui.initialize() + elif gui == 'plotter': + from .plotting.main import Plotter + gui = Plotter(None) + elif gui == 'monitor': + from .monitoring.main import Monitor + item = kwargs.get('item') + gui = Monitor(item) + elif gui == 'slider': + from .slider import Slider + item = kwargs.get('item') + gui = Slider(item.variable, item) + elif gui == 'add_device': + from .controlcenter.main import addDeviceWindow + gui = addDeviceWindow() + elif gui == 'about': + from .controlcenter.main import AboutWindow + gui = AboutWindow() + elif gui == 'variables_menu': + from .variables import VariablesMenu + gui = VariablesMenu() + else: + raise ValueError("gui accept either 'main', 'plotter', 'monitor'," \ + "'slider, add_device', 'about' or 'variables_menu'" \ + f". Given {gui}") gui.show() app.exec() diff --git a/autolab/core/gui/controlcenter/main.py b/autolab/core/gui/controlcenter/main.py index 510d2b17..72aac684 100644 --- a/autolab/core/gui/controlcenter/main.py +++ b/autolab/core/gui/controlcenter/main.py @@ -7,22 +7,30 @@ """ +import platform import sys import queue import time import uuid from typing import Any, Type +import numpy as np +import pandas as pd +import qtpy from qtpy import QtCore, QtWidgets, QtGui -from qtpy.QtWidgets import QApplication +import pyqtgraph as pg from .thread import ThreadManager from .treewidgets import TreeWidgetItemModule from ..scanning.main import Scanner from ..plotting.main import Plotter from ..variables import VARIABLES +from ..GUI_utilities import get_font_size from ..icons import icons -from ... import devices, web, paths, config, utilities +from ... import devices, drivers, web, paths, config, utilities +from ...repository import _install_drivers_custom +from ...web import project_url, drivers_url, doc_url +from .... import __version__ class OutputWrapper(QtCore.QObject): @@ -67,7 +75,7 @@ class ControlCenter(QtWidgets.QMainWindow): def __init__(self): # Set up the user interface. - QtWidgets.QMainWindow.__init__(self) + super().__init__() # Window configuration self.setWindowTitle("AUTOLAB - Control Panel") @@ -83,7 +91,7 @@ class MyQTreeWidget(QtWidgets.QTreeWidget): def __init__(self, gui, parent=None): self.gui = gui - QtWidgets.QTreeWidget.__init__(self, parent) + super().__init__(parent) def startDrag(self, event): @@ -130,6 +138,8 @@ def startDrag(self, event): # Scanner / Monitors self.scanner = None self.plotter = None + self.about = None + self.addDevice = None self.monitors = {} self.sliders = {} self.threadDeviceDict = {} @@ -163,6 +173,16 @@ def startDrag(self, event): devicesConfig.triggered.connect(self.openDevicesConfig) devicesConfig.setStatusTip("Open the devices configuration file") + addDeviceAction = settingsMenu.addAction('Add device') + addDeviceAction.setIcon(QtGui.QIcon(icons['add'])) + addDeviceAction.triggered.connect(lambda: self.openAddDevice()) + addDeviceAction.setStatusTip("Open the utility to add a device") + + downloadDriverAction = settingsMenu.addAction('Download drivers') + downloadDriverAction.setIcon(QtGui.QIcon(icons['add'])) + downloadDriverAction.triggered.connect(self.downloadDriver) + downloadDriverAction.setStatusTip("Open the utility to download drivers") + refreshAction = settingsMenu.addAction('Refresh devices') refreshAction.triggered.connect(self.initialize) refreshAction.setStatusTip('Reload devices setting') @@ -179,14 +199,21 @@ def startDrag(self, event): helpAction = helpMenu.addAction('Documentation') helpAction.setIcon(QtGui.QIcon(icons['readthedocs'])) - helpAction.triggered.connect(lambda : web.doc('default')) + helpAction.triggered.connect(lambda: web.doc('default')) helpAction.setStatusTip('Open the documentation on Read The Docs website') helpActionOffline = helpMenu.addAction('Documentation (Offline)') helpActionOffline.setIcon(QtGui.QIcon(icons['pdf'])) - helpActionOffline.triggered.connect(lambda : web.doc(False)) + helpActionOffline.triggered.connect(lambda: web.doc(False)) helpActionOffline.setStatusTip('Open the pdf documentation form local file') + helpMenu.addSeparator() + + aboutAction = helpMenu.addAction('About Autolab') + aboutAction.setIcon(QtGui.QIcon(icons['autolab'])) + aboutAction.triggered.connect(self.openAbout) + aboutAction.setStatusTip('Information about Autolab') + # Timer for device instantiation self.timerDevice = QtCore.QTimer(self) self.timerDevice.setInterval(50) # ms @@ -194,7 +221,7 @@ def startDrag(self, event): # queue and timer to add/remove plot from driver self.queue_driver = queue.Queue() - self.dict_widget = dict() + self.dict_widget = {} self.timerQueue = QtCore.QTimer(self) self.timerQueue.setInterval(int(50)) # ms self.timerQueue.timeout.connect(self._queueDriverHandler) @@ -232,8 +259,6 @@ def startDrag(self, event): if console_active: from pyqtgraph.console import ConsoleWidget - import numpy as np - import pandas as pd import autolab # OPTIMIZE: not good to import autolab? namespace = {'np': np, 'pd': pd, 'autolab': autolab} text = """ Packages imported: autolab, numpy as np, pandas as pd.\n""" @@ -258,12 +283,11 @@ def createWidget(self, widget: Type, *args, **kwargs): widget_created = self.dict_widget.get(unique_name) if widget_created: return widget_created - else: - time.sleep(0.01) - if (time.time() - start) > 1: - print(f"Warning: Importation of {widget} too long, skip it", - file=sys.stderr) - return None + time.sleep(0.01) + if (time.time() - start) > 1: + print(f"Warning: Importation of {widget} too long, skip it", + file=sys.stderr) + return None def removeWidget(self, widget: Type): """ Function used by a driver to remove a widget record from GUI """ @@ -287,7 +311,7 @@ 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: d.pop(widget_name) @@ -303,9 +327,8 @@ def timerAction(self): threadItemDictTemp = self.threadItemDict.copy() threadDeviceDictTemp = self.threadDeviceDict.copy() - for item_id in threadDeviceDictTemp.keys(): + for item_id, module in threadDeviceDictTemp.items(): item = threadItemDictTemp[item_id] - module = threadDeviceDictTemp[item_id] self.associate(item, module) item.setExpanded(True) @@ -323,13 +346,18 @@ def initialize(self): """ This function will create the first items in the tree, but will associate only the ones already loaded in autolab """ self.tree.clear() - for devName in devices.list_devices(): - item = TreeWidgetItemModule(self.tree, devName, self) + try: + list_devices = devices.list_devices() + except Exception as e: + self.setStatus(f'Error {e}', 10000, False) + else: + for devName in list_devices: + item = TreeWidgetItemModule(self.tree, devName, self) - for i in range(5): - item.setBackground(i, QtGui.QColor('#9EB7F5')) # blue + for i in range(5): + item.setBackground(i, QtGui.QColor('#9EB7F5')) # blue - if devName in devices.list_loaded_devices(): self.itemClicked(item) + if devName in devices.list_loaded_devices(): self.itemClicked(item) 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 """ @@ -344,16 +372,35 @@ def rightClick(self, position: QtCore.QPoint): """ Function called when a right click has been detected in the tree """ item = self.tree.itemAt(position) if hasattr(item, 'menu'): item.menu(position) + elif item is None: self.addDeviceMenu(position) + + def addDeviceMenu(self, position: QtCore.QPoint): + """ Open menu to ask if want to add new device """ + menu = QtWidgets.QMenu() + addDeviceChoice = menu.addAction('Add device') + addDeviceChoice.setIcon(QtGui.QIcon(icons['add'])) + + choice = menu.exec_(self.tree.viewport().mapToGlobal(position)) + + if choice == addDeviceChoice: + self.openAddDevice() def itemClicked(self, item: QtWidgets.QTreeWidgetItem): """ 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 not item.loaded - and id(item) not in self.threadItemDict.keys()): + 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.timerDevice.start() + def itemCanceled(self, item): + """ Cancel the device openning. Can be used to avoid GUI blocking for devices with infinite loading issue """ + if id(item) in self.threadManager.threads_conn: + tid = self.threadManager.threads_conn[id(item)] + self.threadManager.threads[tid].endSignal.emit(f'Cancel loading device {item.name}') + self.threadManager.threads[tid].terminate() + def itemPressed(self, item: QtWidgets.QTreeWidgetItem): """ Function called when a click (not released) has been detected in the tree. Store last dragged variable in tree so scanner can know it when it is dropped there. @@ -381,7 +428,7 @@ def associate(self, item: QtWidgets.QTreeWidgetItem, module: devices.Device): 10000, False) def openScanner(self): - """ This function open the scanner associated to this variable. """ + """ This function open the scanner. """ # If the scanner is not already running, create one if self.scanner is None: self.scanner = Scanner(self) @@ -395,7 +442,7 @@ def openScanner(self): self.scanner.activateWindow() def openPlotter(self): - """ This function open the plotter associated to this variable. """ + """ This function open the plotter. """ # If the plotter is not already running, create one if self.plotter is None: self.plotter = Plotter(self) @@ -410,15 +457,58 @@ def openPlotter(self): self.plotter.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) self.plotter.activateWindow() - def openAutolabConfig(self): + def openAbout(self): + """ This function open the about window. """ + # If the about window is not already running, create one + if self.about is None: + self.about = AboutWindow(self) + self.about.show() + self.about.activateWindow() + # If the about window is already running, just make as the front window + else: + self.about.setWindowState( + self.about.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) + self.about.activateWindow() + + def openAddDevice(self, item: QtWidgets.QTreeWidgetItem = None): + """ This function open the add device window. """ + # If the add device window is not already running, create one + if self.addDevice is None: + self.addDevice = addDeviceWindow(self) + self.addDevice.show() + self.addDevice.activateWindow() + # If the add device window is already running, just make as the front window + else: + self.addDevice.setWindowState( + self.addDevice.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) + self.addDevice.activateWindow() + + # Modify existing device + if item is not None: + name = item.name + try: + conf = devices.get_final_device_config(item.name) + except Exception as e: + self.setStatus(str(e), 10000, False) + else: + self.addDevice.modify(name, conf) + + def downloadDriver(self): + """ This function open the download driver window. """ + _install_drivers_custom(parent=self) + + @staticmethod + def openAutolabConfig(): """ Open the Autolab configuration file """ utilities.openFile(paths.AUTOLAB_CONFIG) - def openDevicesConfig(self): + @staticmethod + def openDevicesConfig(): """ Open the devices configuration file """ utilities.openFile(paths.DEVICES_CONFIG) - def openPlotterConfig(self): + @staticmethod + def openPlotterConfig(): """ Open the plotter configuration file """ utilities.openFile(paths.PLOTTER_CONFIG) @@ -463,6 +553,14 @@ def clearPlotter(self): 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: @@ -470,23 +568,27 @@ def closeEvent(self, event): if self.plotter is not None: self.plotter.figureManager.fig.deleteLater() - for children in self.plotter.findChildren( - QtWidgets.QWidget, options=QtCore.Qt.FindDirectChildrenOnly): + 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() - sliders = list(self.sliders.values()) - for slider in sliders: + for slider in list(self.sliders.values()): slider.close() devices.close() # close all devices - QApplication.quit() # close the control center interface + QtWidgets.QApplication.quit() # close the control center interface if hasattr(self, 'stdout'): sys.stdout = self.stdout._stream @@ -496,7 +598,6 @@ def closeEvent(self, event): if hasattr(self, '_console_dock'): self._console_dock.deleteLater() try: - import pyqtgraph as pg # 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) @@ -507,10 +608,499 @@ def closeEvent(self, event): self.timerDevice.stop() self.timerQueue.stop() - for children in self.findChildren( - QtWidgets.QWidget, options=QtCore.Qt.FindDirectChildrenOnly): + 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) + + 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 diff --git a/autolab/core/gui/controlcenter/slider.py b/autolab/core/gui/controlcenter/slider.py deleted file mode 100644 index 9ee10d48..00000000 --- a/autolab/core/gui/controlcenter/slider.py +++ /dev/null @@ -1,255 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Created on Mon Apr 17 23:23:51 2023 - -@author: jonathan -""" -from typing import Any - -import numpy as np -from qtpy import QtCore, QtWidgets, QtGui - -from ..icons import icons -from ..GUI_utilities import get_font_size, setLineEditBackground -from ... import config - - -class Slider(QtWidgets.QMainWindow): - - def __init__(self, item: QtWidgets.QTreeWidgetItem): - """ https://stackoverflow.com/questions/61717896/pyqt5-qslider-is-off-by-one-depending-on-which-direction-the-slider-is-moved """ - QtWidgets.QMainWindow.__init__(self) - self.item = item - self.resize(self.minimumSizeHint()) - self.setWindowTitle(self.item.variable.address()) - self.setWindowIcon(QtGui.QIcon(icons['slider'])) - - # Load configuration - control_center_config = config.get_control_center_config() - self.precision = int(control_center_config['precision']) - - self._font_size = get_font_size() + 1 - - # Slider - self.slider_instantaneous = True - self.true_min = self.item.variable.type(0) - self.true_max = self.item.variable.type(10) - self.true_step = self.item.variable.type(1) - - centralWidget = QtWidgets.QWidget() - layoutWindow = QtWidgets.QVBoxLayout() - layoutTopValue = QtWidgets.QHBoxLayout() - layoutSlider = QtWidgets.QHBoxLayout() - layoutBottomValues = QtWidgets.QHBoxLayout() - - centralWidget.setLayout(layoutWindow) - layoutWindow.addLayout(layoutTopValue) - layoutWindow.addLayout(layoutSlider) - layoutWindow.addLayout(layoutBottomValues) - - self.instantCheckBox = QtWidgets.QCheckBox() - self.instantCheckBox.setToolTip("True: Changes instantaneously the value.\nFalse: Changes the value when click released.") - self.instantCheckBox.setCheckState(QtCore.Qt.Checked) - self.instantCheckBox.stateChanged.connect(self.instantChanged) - - layoutTopValue.addWidget(QtWidgets.QLabel("Instant")) - layoutTopValue.addWidget(self.instantCheckBox) - - self.valueWidget = QtWidgets.QLineEdit() - self.valueWidget.setAlignment(QtCore.Qt.AlignCenter) - self.valueWidget.setReadOnly(True) - self.valueWidget.setText(f'{self.true_min}') - setLineEditBackground(self.valueWidget, 'edited', self._font_size) - - layoutTopValue.addStretch() - layoutTopValue.addWidget(QtWidgets.QLabel("Value")) - layoutTopValue.addWidget(self.valueWidget) - layoutTopValue.addStretch() - layoutTopValue.addSpacing(40) - - self.sliderWidget = QtWidgets.QSlider(QtCore.Qt.Horizontal) - self.sliderWidget.setValue(0) - self.sliderWidget.setTickPosition(QtWidgets.QSlider.TicksBelow) - self.sliderWidget.valueChanged.connect(self.valueChanged) - self.sliderWidget.sliderReleased.connect(self.sliderReleased) - self.sliderWidget.setStyle(ProxyStyle()) - - button_minus = QtWidgets.QToolButton() - button_minus.setArrowType(QtCore.Qt.LeftArrow) - button_minus.clicked.connect(self.minusClicked) - - button_plus = QtWidgets.QToolButton() - button_plus.setArrowType(QtCore.Qt.RightArrow) - button_plus.clicked.connect(self.plusClicked) - - layoutSlider.addWidget(button_minus) - layoutSlider.addWidget(self.sliderWidget) - layoutSlider.addWidget(button_plus) - - self.minWidget = QtWidgets.QLineEdit() - self.minWidget.setAlignment(QtCore.Qt.AlignLeft) - self.minWidget.returnPressed.connect(self.minWidgetValueChanged) - self.minWidget.textEdited.connect(lambda: setLineEditBackground( - self.minWidget, 'edited', self._font_size)) - - layoutBottomValues.addWidget(QtWidgets.QLabel("Min")) - layoutBottomValues.addWidget(self.minWidget) - layoutBottomValues.addSpacing(10) - layoutBottomValues.addStretch() - - self.stepWidget = QtWidgets.QLineEdit() - self.stepWidget.setAlignment(QtCore.Qt.AlignCenter) - self.stepWidget.returnPressed.connect(self.stepWidgetValueChanged) - self.stepWidget.textEdited.connect(lambda: setLineEditBackground( - self.stepWidget, 'edited', self._font_size)) - - layoutBottomValues.addWidget(QtWidgets.QLabel("Step")) - layoutBottomValues.addWidget(self.stepWidget) - layoutBottomValues.addStretch() - layoutBottomValues.addSpacing(10) - - self.maxWidget = QtWidgets.QLineEdit() - self.maxWidget.setAlignment(QtCore.Qt.AlignRight) - self.maxWidget.returnPressed.connect(self.maxWidgetValueChanged) - self.maxWidget.textEdited.connect(lambda: setLineEditBackground( - self.maxWidget, 'edited', self._font_size)) - - layoutBottomValues.addWidget(QtWidgets.QLabel("Max")) - layoutBottomValues.addWidget(self.maxWidget) - - self.setCentralWidget(centralWidget) - - self.updateStep() - - self.resize(self.minimumSizeHint()) - self.show() - - def updateStep(self): - - slider_points = 1 + int( - np.floor((self.true_max - self.true_min) / self.true_step)) - self.true_max = self.item.variable.type( - self.true_step*(slider_points - 1) + self.true_min) - - self.minWidget.setText(f'{self.true_min}') - setLineEditBackground(self.minWidget, 'synced', self._font_size) - self.maxWidget.setText(f'{self.true_max}') - setLineEditBackground(self.maxWidget, 'synced', self._font_size) - self.stepWidget.setText(f'{self.true_step}') - setLineEditBackground(self.stepWidget, 'synced', self._font_size) - - temp = self.slider_instantaneous - self.slider_instantaneous = False - self.sliderWidget.setMinimum(0) - self.sliderWidget.setSingleStep(1) - self.sliderWidget.setTickInterval(1) - self.sliderWidget.setMaximum(slider_points - 1) - self.slider_instantaneous = temp - - def updateTrueValue(self, old_true_value: Any): - - new_cursor_step = round( - (old_true_value - self.true_min) / self.true_step) - slider_points = 1 + int( - np.floor((self.true_max - self.true_min) / self.true_step)) - if new_cursor_step > (slider_points - 1): - new_cursor_step = slider_points - 1 - elif new_cursor_step < 0: - new_cursor_step = 0 - - temp = self.slider_instantaneous - self.slider_instantaneous = False - self.sliderWidget.setSliderPosition(new_cursor_step) - self.slider_instantaneous = temp - - true_value = self.item.variable.type( - new_cursor_step*self.true_step + self.true_min) - self.valueWidget.setText(f'{true_value:.{self.precision}g}') - setLineEditBackground(self.valueWidget, 'edited', self._font_size) - - def stepWidgetValueChanged(self): - - old_true_value = self.item.variable.type(self.valueWidget.text()) - try: - true_step = self.item.variable.type(self.stepWidget.text()) - assert true_step != 0, "Can't have step=0" - self.true_step = true_step - except Exception as e: - self.item.gui.setStatus(f"Variable {self.item.variable.name}: {e}", - 10000, False) - return None - self.updateStep() - self.updateTrueValue(old_true_value) - - def minWidgetValueChanged(self): - - old_true_value = self.item.variable.type(self.valueWidget.text()) - try: - self.true_min = self.item.variable.type(self.minWidget.text()) - except Exception as e: - self.item.gui.setStatus(f"Variable {self.item.variable.name}: {e}", - 10000, False) - return None - self.updateStep() - self.updateTrueValue(old_true_value) - - def maxWidgetValueChanged(self): - - old_true_value = self.item.variable.type(self.valueWidget.text()) - try: - self.true_max = self.item.variable.type(self.maxWidget.text()) - except Exception as e: - self.item.gui.setStatus(f"Variable {self.item.variable.name}: {e}", - 10000, False) - return None - self.updateStep() - self.updateTrueValue(old_true_value) - - def sliderReleased(self): - """ Do something when the cursor is released """ - value = self.sliderWidget.value() - true_value = self.item.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) - self.item.gui.threadManager.start( - self.item, 'write', value=true_value) - self.updateStep() - - def valueChanged(self, value: Any): - """ Do something with the slider value when the cursor is moved """ - true_value = self.item.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) - self.item.gui.threadManager.start( - self.item, 'write', value=true_value) - else: - setLineEditBackground(self.valueWidget,'edited', self._font_size) - # self.updateStep() # Don't use it here, infinite loop leading to crash if set min > max - - def instantChanged(self, value): - self.slider_instantaneous = self.instantCheckBox.isChecked() - - def minusClicked(self): - self.sliderWidget.setSliderPosition(self.sliderWidget.value()-1) - if not self.slider_instantaneous: self.sliderReleased() - - def plusClicked(self): - self.sliderWidget.setSliderPosition(self.sliderWidget.value()+1) - if not self.slider_instantaneous: self.sliderReleased() - - def closeEvent(self, event): - """ This function does some steps before the window is really killed """ - self.item.clearSlider() - - - -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 == self.SH_Slider_AbsoluteSetButtons: - res |= QtCore.Qt.LeftButton - return res diff --git a/autolab/core/gui/controlcenter/thread.py b/autolab/core/gui/controlcenter/thread.py index e187ea1a..3fdffb93 100644 --- a/autolab/core/gui/controlcenter/thread.py +++ b/autolab/core/gui/controlcenter/thread.py @@ -5,13 +5,14 @@ @author: qchat """ +import sys import inspect from typing import Any from qtpy import QtCore, QtWidgets +from ..GUI_utilities import qt_object_exists from ... import devices from ... import drivers -from ...utilities import qt_object_exists class ThreadManager: @@ -20,6 +21,7 @@ class ThreadManager: def __init__(self, gui: QtWidgets.QMainWindow): self.gui = gui self.threads = {} + self.threads_conn = {} def start(self, item: QtWidgets.QTreeWidgetItem, intType: str, value = None): """ This function is called when a new thread is requested, @@ -53,24 +55,41 @@ def start(self, item: QtWidgets.QTreeWidgetItem, intType: str, value = None): self.gui.setStatus(status) # Thread configuration + if intType == 'load': + assert id(item) not in self.threads_conn thread = InteractionThread(item, intType, value) tid = id(thread) self.threads[tid] = thread + if intType == 'load': + self.threads_conn[id(item)] = tid + thread.endSignal.connect( - lambda error, x=tid : self.threadFinished(x, error)) - thread.finished.connect(lambda x=tid : self.delete(x)) + 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: 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) + It updates the status bar of the GUI in consequence and enabled back the corresponding item """ + if error: + if qt_object_exists(self.gui.statusBar): + self.gui.setStatus(str(error), 10000, False) + else: + print(str(error), file=sys.stderr) + + if tid in self.threads_conn.values(): + item_id = list(self.threads_conn)[list(self.threads_conn.values()).index(tid)] + if item_id in self.gui.threadItemDict: + self.gui.threadItemDict.pop(item_id) + else: + if qt_object_exists(self.gui.statusBar): + self.gui.clearStatus() item = self.threads[tid].item - item.setDisabled(False) + if qt_object_exists(item): + item.setDisabled(False) if hasattr(item, "execButton"): if qt_object_exists(item.execButton): @@ -85,6 +104,9 @@ def threadFinished(self, tid: int, error: Exception): 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 """ + if self.threads[tid].intType == 'load': + item_id = list(self.threads_conn)[list(self.threads_conn.values()).index(tid)] + self.threads_conn.pop(item_id) self.threads.pop(tid) @@ -93,7 +115,7 @@ class InteractionThread(QtCore.QThread): endSignal = QtCore.Signal(object) def __init__(self, item: QtWidgets.QTreeWidgetItem, intType: str, value: Any): - QtCore.QThread.__init__(self) + super().__init__() self.item = item self.intType = intType self.value = value @@ -142,8 +164,6 @@ def run(self): except Exception as e: error = 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)) + error = f'An error occured when loading device {self.item.name}: {str(e)}' self.endSignal.emit(error) diff --git a/autolab/core/gui/controlcenter/treewidgets.py b/autolab/core/gui/controlcenter/treewidgets.py index 40ff3f5d..0f90c271 100644 --- a/autolab/core/gui/controlcenter/treewidgets.py +++ b/autolab/core/gui/controlcenter/treewidgets.py @@ -7,21 +7,170 @@ import os -from typing import Any +from typing import Any, Union import pandas as pd import numpy as np from qtpy import QtCore, QtWidgets, QtGui -from .slider import Slider +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 (qt_object_exists, SUPPORTED_EXTENSION, +from ...utilities import (SUPPORTED_EXTENSION, str_to_array, array_to_str, - dataframe_to_str, str_to_dataframe) + 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 class TreeWidgetItemModule(QtWidgets.QTreeWidgetItem): @@ -33,12 +182,12 @@ def __init__(self, itemParent, name, gui): self.module = None self.loaded = False self.gui = gui - self.is_not_submodule = type(gui.tree) is type(itemParent) + self.is_not_submodule = isinstance(gui.tree, type(itemParent)) if self.is_not_submodule: - QtWidgets.QTreeWidgetItem.__init__(self, itemParent, [name, 'Device']) + super().__init__(itemParent, [name, 'Device']) else: - QtWidgets.QTreeWidgetItem.__init__(self, itemParent, [name, 'Module']) + super().__init__(itemParent, [name, 'Module']) self.setTextAlignment(1, QtCore.Qt.AlignHCenter) @@ -73,20 +222,39 @@ def load(self, module): def menu(self, position: QtCore.QPoint): """ This function provides the menu when the user right click on an item """ - if self.is_not_submodule and self.loaded: - menu = QtWidgets.QMenu() - disconnectDevice = menu.addAction(f"Disconnect {self.name}") - disconnectDevice.setIcon(QtGui.QIcon(icons['disconnect'])) + if self.is_not_submodule: + if self.loaded: + menu = QtWidgets.QMenu() + disconnectDevice = menu.addAction(f"Disconnect {self.name}") + disconnectDevice.setIcon(QtGui.QIcon(icons['disconnect'])) - choice = menu.exec_(self.gui.tree.viewport().mapToGlobal(position)) + choice = menu.exec_(self.gui.tree.viewport().mapToGlobal(position)) + + if choice == disconnectDevice: + close(self.name) + + for i in range(self.childCount()): + self.removeChild(self.child(0)) - if choice == disconnectDevice: - close(self.name) + self.loaded = False + elif id(self) in self.gui.threadManager.threads_conn: + menu = QtWidgets.QMenu() + cancelDevice = menu.addAction('Cancel loading') + cancelDevice.setIcon(QtGui.QIcon(icons['disconnect'])) - for i in range(self.childCount()): - self.removeChild(self.child(0)) + choice = menu.exec_(self.gui.tree.viewport().mapToGlobal(position)) - self.loaded = False + if choice == cancelDevice: + self.gui.itemCanceled(self) + else: + menu = QtWidgets.QMenu() + modifyDeviceChoice = menu.addAction('Modify device') + modifyDeviceChoice.setIcon(QtGui.QIcon(icons['rename'])) + + choice = menu.exec_(self.gui.tree.viewport().mapToGlobal(position)) + + if choice == modifyDeviceChoice: + self.gui.openAddDevice(self) class TreeWidgetItemAction(QtWidgets.QTreeWidgetItem): @@ -98,7 +266,7 @@ def __init__(self, itemParent, action, gui): if action.unit is not None: displayName += f' ({action.unit})' - QtWidgets.QTreeWidgetItem.__init__(self, itemParent, [displayName, 'Action']) + super().__init__(itemParent, [displayName, 'Action']) self.setTextAlignment(1, QtCore.Qt.AlignHCenter) self.gui = gui @@ -140,7 +308,7 @@ def readGui(self) -> Any: if value == '': if self.action.unit in ('open-file', 'save-file', 'filename'): - if self.action.unit == "filename": # LEGACY (may be removed later) + 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) @@ -148,12 +316,12 @@ def readGui(self) -> Any: if self.action.unit == "open-file": filename, _ = QtWidgets.QFileDialog.getOpenFileName( - self.gui, caption="Open file", + 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="Save file", + self.gui, caption=f"Save file - {self.action.name}", directory=paths.USER_LAST_CUSTOM_FOLDER, filter=SUPPORTED_EXTENSION) @@ -165,6 +333,17 @@ def readGui(self) -> Any: 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 + else: + self.gui.setStatus( + f"Action {self.action.name} cancel user input", + 10000) else: self.gui.setStatus( f"Action {self.action.name} requires a value for its parameter", @@ -173,9 +352,9 @@ def readGui(self) -> Any: try: value = variables.eval_variable(value) if self.action.type in [np.ndarray]: - if type(value) is str: value = str_to_array(value) + if isinstance(value, str): value = str_to_array(value) elif self.action.type in [pd.DataFrame]: - if type(value) is str: value = str_to_dataframe(value) + if isinstance(value, str): value = str_to_dataframe(value) else: value = self.action.type(value) return value @@ -197,11 +376,13 @@ def execute(self): def menu(self, position: QtCore.QPoint): """ This function provides the menu when the user right click on an item """ if not self.isDisabled(): - menu = QtWidgets.QMenu() - scanRecipe = menu.addAction("Do in scan recipe") - scanRecipe.setIcon(QtGui.QIcon(icons['action'])) + menu = CustomMenu(self.gui) + + scanRecipe = menu.addAnyAction('Do in scan recipe', 'action') choice = menu.exec_(self.gui.tree.viewport().mapToGlobal(position)) + if choice is None: choice = menu.selected_action + if choice == scanRecipe: recipe_name = self.gui.getRecipeName() self.gui.addStepToScanRecipe(recipe_name, 'action', self.action) @@ -210,14 +391,13 @@ def menu(self, position: QtCore.QPoint): class TreeWidgetItemVariable(QtWidgets.QTreeWidgetItem): """ This class represents a variable in an item of the tree """ - def __init__(self, itemParent, variable , gui): + def __init__(self, itemParent, variable, gui): - self.displayName = f'{variable.name}' + displayName = f'{variable.name}' if variable.unit is not None: - self.displayName += f' ({variable.unit})' + displayName += f' ({variable.unit})' - QtWidgets.QTreeWidgetItem.__init__( - self, itemParent, [self.displayName, 'Variable']) + super().__init__(itemParent, [displayName, 'Variable']) self.setTextAlignment(1, QtCore.Qt.AlignHCenter) self.gui = gui @@ -290,10 +470,10 @@ class MyQCheckBox(QtWidgets.QCheckBox): def __init__(self, parent): self.parent = parent - QtWidgets.QCheckBox.__init__(self) + super().__init__() def mouseReleaseEvent(self, event): - super(MyQCheckBox, self).mouseReleaseEvent(event) + super().mouseReleaseEvent(event) self.parent.valueEdited() self.parent.write() @@ -318,22 +498,22 @@ def mouseReleaseEvent(self, event): class MyQComboBox(QtWidgets.QComboBox): def __init__(self): - QtWidgets.QComboBox.__init__(self) + super().__init__() self.readonly = False self.wheel = True self.key = True def mousePressEvent(self, event): if not self.readonly: - QtWidgets.QComboBox.mousePressEvent(self, event) + super().mousePressEvent(event) def keyPressEvent(self, event): if not self.readonly and self.key: - QtWidgets.QComboBox.keyPressEvent(self, event) + super().keyPressEvent(event) def wheelEvent(self, event): if not self.readonly and self.wheel: - QtWidgets.QComboBox.wheelEvent(self, event) + super().wheelEvent(event) if self.variable.writable: self.valueWidget = MyQComboBox() @@ -376,8 +556,8 @@ def writeGui(self, value): elif self.variable.type in [bool]: self.valueWidget.setChecked(value) elif self.variable.type in [tuple]: - AllItems = [self.valueWidget.itemText(i) for i in range(self.valueWidget.count())] - if value[0] != AllItems: + 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]) @@ -407,9 +587,10 @@ def readGui(self): try: value = variables.eval_variable(value) if self.variable.type in [np.ndarray]: - if type(value) is str: value = str_to_array(value) + if isinstance(value, str): value = str_to_array(value) + else: value = create_array(value) elif self.variable.type in [pd.DataFrame]: - if type(value) is str: value = str_to_dataframe(value) + if isinstance(value, str): value = str_to_dataframe(value) else: value = self.variable.type(value) return value @@ -422,8 +603,8 @@ def readGui(self): value = self.valueWidget.isChecked() return value elif self.variable.type in [tuple]: - AllItems = [self.valueWidget.itemText(i) for i in range(self.valueWidget.count())] - value = (AllItems, self.valueWidget.currentIndex()) + items = [self.valueWidget.itemText(i) for i in range(self.valueWidget.count())] + value = (items, self.valueWidget.currentIndex()) return value def setValueKnownState(self, state: bool): @@ -462,36 +643,21 @@ def readButtonCheckEdited(self): def menu(self, position: QtCore.QPoint): """ This function provides the menu when the user right click on an item """ if not self.isDisabled(): - # TODO: could be used to select which recipe and parameter the step should go - # But, seems not ergonomic (not finish to implement it if want it) - # if self.gui.scanner is None: - # pass - # else: - # recipe_name = self.gui.getRecipeName() - # recipe_name_list = self.gui.scanner.configManager.recipeNameList() - # param_name = self.gui.getParameterName() - # param_name_list = self.gui.scanner.configManager.parameterNameList(recipe_name) - # print(recipe_name, recipe_name_list, - # param_name, param_name_list) - - menu = QtWidgets.QMenu() + menu = CustomMenu(self.gui) monitoringAction = menu.addAction("Start monitoring") monitoringAction.setIcon(QtGui.QIcon(icons['monitor'])) menu.addSeparator() sliderAction = menu.addAction("Create a slider") sliderAction.setIcon(QtGui.QIcon(icons['slider'])) menu.addSeparator() - # sub_menu = QtWidgets.QMenu("Set as parameter", menu) - # sub_menu.setIcon(QtGui.QIcon(icons['parameter'])) - # menu.addMenu(sub_menu) - # scanParameterAction = sub_menu.addAction(f"in {recipe_name}") - # scanParameterAction.setIcon(QtGui.QIcon(icons['recipe'])) - scanParameterAction = menu.addAction("Set as scan parameter") - scanParameterAction.setIcon(QtGui.QIcon(icons['parameter'])) - scanMeasureStepAction = menu.addAction("Measure in scan recipe") - scanMeasureStepAction.setIcon(QtGui.QIcon(icons['measure'])) - scanSetStepAction = menu.addAction("Set value in scan recipe") - scanSetStepAction.setIcon(QtGui.QIcon(icons['write'])) + + scanParameterAction = menu.addAnyAction( + 'Set as scan parameter', 'parameter', param_menu_active=True) + scanMeasureStepAction = menu.addAnyAction( + 'Measure in scan recipe', 'measure') + scanSetStepAction = menu.addAnyAction( + 'Set value in scan recipe', 'write') + menu.addSeparator() saveAction = menu.addAction("Read and save as...") saveAction.setIcon(QtGui.QIcon(icons['read-save'])) @@ -510,6 +676,8 @@ def menu(self, position: QtCore.QPoint): tuple] else False) # OPTIMIZE: forbid setting tuple to scanner 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() elif choice == scanParameterAction: @@ -561,7 +729,8 @@ 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) + 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)] diff --git a/autolab/core/gui/monitoring/data.py b/autolab/core/gui/monitoring/data.py index e05039f8..01eec36f 100644 --- a/autolab/core/gui/monitoring/data.py +++ b/autolab/core/gui/monitoring/data.py @@ -44,16 +44,29 @@ def save(self, filename: str): def addPoint(self, point: Tuple[Any, Any]): """ This function either replace list by array or add point to list depending on datapoint type """ - x, y = point - - if type(y) is np.ndarray: - if len(y.T.shape) == 1 or y.T.shape[0] == 2: - self._addArray(y.T) - else: - self._addImage(y) # Defined which axe is major using pg.setConfigOption('imageAxisOrder', 'row-major') in gui start-up so no need to .T data - elif type(y) is pd.DataFrame: - self._addArray(y.values.T) + y = point[1] + + if isinstance(y, (np.ndarray, pd.DataFrame)): + if self.gui.windowLength_lineEdit.isVisible(): + self.gui.figureManager.setLabel('x', 'x') + self.gui.windowLength_lineEdit.hide() + self.gui.windowLength_label.hide() + self.gui.dataDisplay.hide() + + if isinstance(y, np.ndarray): + if len(y.T.shape) in (0, 1) or y.T.shape[0] == 2: + self._addArray(y.T) + else: + self._addImage(y) # Defined which axe is major using pg.setConfigOption('imageAxisOrder', 'row-major') in gui start-up so no need to .T data + elif isinstance(y, pd.DataFrame): + self._addArray(y.values.T) else: + if not self.gui.windowLength_lineEdit.isVisible(): + self.gui.figureManager.setLabel('x', 'Time [s]') + self.gui.windowLength_lineEdit.show() + self.gui.windowLength_label.show() + self.gui.dataDisplay.show() + self._addPoint(point) def _addImage(self, image: np.ndarray): @@ -62,9 +75,12 @@ def _addImage(self, image: np.ndarray): self.ylist = image def _addArray(self, array: np.ndarray): - """ This function replace an dataset [x,y] x is time y is array""" - - if len(array.shape) == 1: + """ This function replace an dataset [x,y] x is time y is array """ + if len(array.shape) == 0: + y_array = array + self.xlist = np.array([0]) + self.ylist = np.array([y_array]) + elif len(array.shape) == 1: y_array = array # Replace data self.xlist = np.arange(len(y_array)) @@ -79,6 +95,8 @@ def _addArray(self, array: np.ndarray): def _addPoint(self, point: Tuple[float, float]): """ This function append a datapoint [x,y] in the lists of data """ + if not hasattr(self.xlist, 'append'): self.clear() # avoid error when switching from array to point + x, y = point # Append data @@ -86,7 +104,7 @@ def _addPoint(self, point: Tuple[float, float]): self.ylist.append(y) # Remove too old data (regarding the window length) - while max(self.xlist)-min(self.xlist) > self.windowLength: + while (max(self.xlist) - min(self.xlist)) > self.windowLength: self.xlist.pop(0) self.ylist.pop(0) diff --git a/autolab/core/gui/monitoring/figure.py b/autolab/core/gui/monitoring/figure.py index e3cf0daa..487b334e 100644 --- a/autolab/core/gui/monitoring/figure.py +++ b/autolab/core/gui/monitoring/figure.py @@ -12,8 +12,9 @@ import pyqtgraph.exporters from qtpy import QtWidgets +from ..GUI_utilities import pyqtgraph_fig_ax, pyqtgraph_image from ... import config -from ... import utilities +from ...utilities import boolean class FigureManager: @@ -25,12 +26,12 @@ def __init__(self, gui: QtWidgets.QMainWindow): # Import Autolab config monitor_config = config.get_monitor_config() self.precision = int(monitor_config['precision']) - self.do_save_figure = utilities.boolean(monitor_config['save_figure']) + self.do_save_figure = boolean(monitor_config['save_figure']) # Configure and initialize the figure in the GUI - self.fig, self.ax = utilities.pyqtgraph_fig_ax() + self.fig, self.ax = pyqtgraph_fig_ax() self.gui.graph.addWidget(self.fig) - self.figMap, widget = utilities.pyqtgraph_image() + self.figMap, widget = pyqtgraph_image() self.gui.graph.addWidget(widget) self.figMap.hide() @@ -49,17 +50,19 @@ def __init__(self, gui: QtWidgets.QMainWindow): # PLOT DATA ########################################################################### - def update(self, xlist: list, ylist: list): + def update(self, xlist: list, ylist: list) -> None: """ This function update the figure in the GUI """ if xlist is None: # image - self.fig.hide() - self.gui.min_checkBox.hide() - self.gui.mean_checkBox.hide() - self.gui.max_checkBox.hide() - self.figMap.show() + if self.fig.isVisible(): + self.fig.hide() + self.gui.min_checkBox.hide() + self.gui.mean_checkBox.hide() + self.gui.max_checkBox.hide() + self.figMap.show() self.figMap.setImage(ylist) return None - else: + + if not self.fig.isVisible(): self.fig.show() self.gui.min_checkBox.show() self.gui.mean_checkBox.show() @@ -67,7 +70,13 @@ def update(self, xlist: list, ylist: list): self.figMap.hide() # Data retrieval - self.plot.setData(xlist, ylist) + try: + self.plot.setData(xlist, ylist) + except Exception as e: + self.gui.setStatus(f'Error: {e}', 10000, False) + if not self.gui.monitorManager.isPaused(): + self.gui.pauseButtonClicked() + return None xlist, ylist = self.plot.getData() @@ -131,7 +140,7 @@ def save(self, filename: str): new_filename = raw_name+".png" if not self.fig.isHidden(): - exporter = pg.exporters.ImageExporter(self.fig.plotItem) + exporter = pg.exporters.ImageExporter(self.ax) exporter.export(new_filename) else: self.figMap.export(new_filename) diff --git a/autolab/core/gui/monitoring/main.py b/autolab/core/gui/monitoring/main.py index 76e4900b..77d19ccf 100644 --- a/autolab/core/gui/monitoring/main.py +++ b/autolab/core/gui/monitoring/main.py @@ -22,17 +22,17 @@ 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 # Configuration of the window - QtWidgets.QMainWindow.__init__(self) + 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.name}") + self.setWindowTitle(f"AUTOLAB - Monitor: Variable {self.variable.address()}") self.setWindowIcon(QtGui.QIcon(icons['monitor'])) # Queue self.queue = queue.Queue() @@ -41,20 +41,14 @@ def __init__(self, item: QtWidgets.QTreeWidgetItem): self.timer.timeout.connect(self.sync) # Window length - if self.variable.type in [int, float]: - self.xlabel = 'Time [s]' - self.windowLength_lineEdit.setText('10') - self.windowLength_lineEdit.returnPressed.connect(self.windowLengthChanged) - self.windowLength_lineEdit.textEdited.connect(lambda: setLineEditBackground( - self.windowLength_lineEdit, 'edited', self._font_size)) - setLineEditBackground( - self.windowLength_lineEdit, 'synced', self._font_size) - else: - self.xlabel = 'x' - self.windowLength_lineEdit.hide() - self.windowLength_label.hide() - self.dataDisplay.hide() - + self.windowLength_lineEdit.setText('10') + self.windowLength_lineEdit.returnPressed.connect(self.windowLengthChanged) + self.windowLength_lineEdit.textEdited.connect(lambda: setLineEditBackground( + self.windowLength_lineEdit, 'edited', self._font_size)) + setLineEditBackground( + self.windowLength_lineEdit, 'synced', self._font_size) + + self.xlabel = '' # defined in data according to data type self.ylabel = f'{self.variable.address()}' # OPTIMIZE: could depend on 1D or 2D if self.variable.unit is not None: @@ -185,15 +179,27 @@ def closeEvent(self, event): """ This function does some steps before the window is really killed """ self.monitorManager.close() self.timer.stop() - self.item.clearMonitor() + 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() - for children in self.findChildren( - QtWidgets.QWidget, options=QtCore.Qt.FindDirectChildrenOnly): + if self.gui is None: + 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 self.gui is None: + QtWidgets.QApplication.quit() # close the monitor app + def windowLengthChanged(self): """ This function start the update of the window length in the data manager when a changed has been detected """ diff --git a/autolab/core/gui/monitoring/monitor.py b/autolab/core/gui/monitoring/monitor.py index 12686e70..b95f3106 100644 --- a/autolab/core/gui/monitoring/monitor.py +++ b/autolab/core/gui/monitoring/monitor.py @@ -69,7 +69,7 @@ class MonitorThread(QtCore.QThread): def __init__(self, variable: Device, queue: Queue): - QtCore.QThread.__init__(self) + super().__init__() self.variable = variable self.queue = queue @@ -98,7 +98,7 @@ def run(self): value = self.variable() # Check type - if type(value) not in (np.ndarray, pd.DataFrame): # should not float(array) because if 0D convert to float and loose information on type + if not isinstance(value, (np.ndarray, pd.DataFrame)): # should not float(array) because if 0D convert to float and loose information on type try: value = float(value) except TypeError: diff --git a/autolab/core/gui/plotting/data.py b/autolab/core/gui/plotting/data.py index e178cb2e..f0205ec9 100644 --- a/autolab/core/gui/plotting/data.py +++ b/autolab/core/gui/plotting/data.py @@ -126,7 +126,7 @@ def setOverwriteData(self, value): self.overwriteData = bool(value) def getDatasetsNames(self): - names = list() + names = [] for dataset in self.datasets: names.append(dataset.name) return names diff --git a/autolab/core/gui/plotting/figure.py b/autolab/core/gui/plotting/figure.py index 564d498d..bab3ff27 100644 --- a/autolab/core/gui/plotting/figure.py +++ b/autolab/core/gui/plotting/figure.py @@ -9,7 +9,7 @@ import pyqtgraph as pg import pyqtgraph.exporters -from ... import utilities +from ..GUI_utilities import pyqtgraph_fig_ax class FigureManager: @@ -20,7 +20,7 @@ def __init__(self, gui): self.curves = [] # Configure and initialize the figure in the GUI - self.fig, self.ax = utilities.pyqtgraph_fig_ax() + self.fig, self.ax = pyqtgraph_fig_ax() self.gui.graph.addWidget(self.fig) # Number of traces @@ -179,5 +179,5 @@ def save(self,filename): raw_name, extension = os.path.splitext(filename) new_filename = raw_name+".png" - exporter = pg.exporters.ImageExporter(self.fig.plotItem) + exporter = pg.exporters.ImageExporter(self.ax) exporter.export(new_filename) diff --git a/autolab/core/gui/plotting/interface.ui b/autolab/core/gui/plotting/interface.ui index 08fd037a..28d7f16e 100644 --- a/autolab/core/gui/plotting/interface.ui +++ b/autolab/core/gui/plotting/interface.ui @@ -154,7 +154,7 @@ - + QFrame::StyledPanel diff --git a/autolab/core/gui/plotting/main.py b/autolab/core/gui/plotting/main.py index 457bb5f4..2259fa66 100644 --- a/autolab/core/gui/plotting/main.py +++ b/autolab/core/gui/plotting/main.py @@ -27,23 +27,23 @@ class MyQTreeWidget(QtWidgets.QTreeWidget): reorderSignal = QtCore.Signal(object) - def __init__(self,parent, plotter): + def __init__(self, parent, plotter): self.plotter = plotter - QtWidgets.QTreeWidget.__init__(self,parent) + 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 type(variable) == str: + if isinstance(variable, str): self.plotter.addPlugin(variable) self.setGraphicsEffect(None) def dragEnterEvent(self, event): if (event.source() is self) or ( - hasattr(event.source(), "last_drag") and type(event.source().last_drag) is str): + hasattr(event.source(), "last_drag") and isinstance(event.source().last_drag, str)): event.accept() shadow = QtWidgets.QGraphicsDropShadowEffect(blurRadius=25, xOffset=3, yOffset=3) self.setGraphicsEffect(shadow) @@ -56,20 +56,20 @@ def dragLeaveEvent(self, event): class Plotter(QtWidgets.QMainWindow): - def __init__(self,mainGui): + def __init__(self, mainGui): self.active = False self.mainGui = mainGui - self.all_plugin_list = list() - self.active_plugin_dict = dict() + self.all_plugin_list = [] + self.active_plugin_dict = {} self._font_size = get_font_size() + 1 # Configuration of the window - QtWidgets.QMainWindow.__init__(self) - ui_path = os.path.join(os.path.dirname(__file__),'interface.ui') - uic.loadUi(ui_path,self) - self.setWindowTitle("AUTOLAB Plotter") + 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'])) # Loading of the different centers @@ -103,36 +103,40 @@ def __init__(self,mainGui): self.nbTraces_lineEdit,'edited', self._font_size)) setLineEditBackground(self.nbTraces_lineEdit, 'synced', self._font_size) - getattr(self, 'variable_x_comboBox').currentIndexChanged.connect( + self.variable_x_comboBox.currentIndexChanged.connect( self.variableChanged) - getattr(self, 'variable_y_comboBox').currentIndexChanged.connect( + self.variable_y_comboBox.currentIndexChanged.connect( self.variableChanged) - 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) + 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.auto_plotDataButton.clicked.connect(self.autoRefreshChanged) self.overwriteDataButton.clicked.connect(self.overwriteDataChanged) - # 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) - self.setAcceptDrops(True) # timer to load plugin to tree @@ -142,7 +146,7 @@ def __init__(self,mainGui): # queue and timer to add/remove plot from plugin self.queue_driver = queue.Queue() - self.dict_widget = dict() + self.dict_widget = {} self.timerQueue = QtCore.QTimer(self) self.timerQueue.setInterval(int(50)) # ms self.timerQueue.timeout.connect(self._queueDriverHandler) @@ -161,11 +165,12 @@ def createWidget(self, widget: Type, *args, **kwargs): widget_created = self.dict_widget.get(unique_name) if widget_created: return widget_created - else: - time.sleep(0.01) - if (time.time() - start) > 1: - print(f"Warning: Importation of {widget} too long, skip it", file=sys.stderr) - return None + + time.sleep(0.01) + if (time.time() - start) > 1: + print(f"Warning: Importation of {widget} too long, skip it", + file=sys.stderr) + return None def removeWidget(self, widget: Type): """ Function used by a driver to remove a widget record from GUI """ @@ -184,8 +189,8 @@ def _queueDriverHandler(self): if action == 'create': widget = widget(*args, **kwargs) self.dict_widget[widget_name] = widget - try: self.figureManager.fig.addItem(widget) - except: pass + try: self.figureManager.ax.addItem(widget) + except Exception as e: self.setStatus(str(e), 10000, False) elif action == "remove": d = self.dict_widget if widget is not None: @@ -195,8 +200,8 @@ def _queueDriverHandler(self): widget = d.get(widget_name) if widget is not None: widget = d.pop(widget_name) - try: self.figureManager.fig.removeItem(widget) - except: pass + try: self.figureManager.ax.removeItem(widget) + except Exception as e: self.setStatus(str(e), 10000, False) def timerAction(self): """ This function checks if a module has been loaded and put to the queue. If so, associate item and module """ @@ -447,16 +452,21 @@ def nbTracesChanged(self): def closeEvent(self,event): """ This function does some steps before the window is closed (not killed) """ - self.timer.stop() + if hasattr(self, 'timer'): self.timer.stop() self.timerPlugin.stop() self.timerQueue.stop() - self.mainGui.clearPlotter() + if hasattr(self.mainGui, 'clearPlotter'): + self.mainGui.clearPlotter() + + super().closeEvent(event) + + if self.mainGui is None: + QtWidgets.QApplication.quit() # close the plotter app def close(self): """ This function does some steps before the window is killed """ - for children in self.findChildren( - QtWidgets.QWidget, options=QtCore.Qt.FindDirectChildrenOnly): + for children in self.findChildren(QtWidgets.QWidget): children.deleteLater() super().close() diff --git a/autolab/core/gui/plotting/thread.py b/autolab/core/gui/plotting/thread.py index 7fcfffae..93a0fed2 100644 --- a/autolab/core/gui/plotting/thread.py +++ b/autolab/core/gui/plotting/thread.py @@ -11,7 +11,7 @@ from ... import devices from ... import drivers -from ...utilities import qt_object_exists +from ..GUI_utilities import qt_object_exists class ThreadManager : @@ -106,15 +106,12 @@ class InteractionThread(QtCore.QThread): endSignal = QtCore.Signal(object) - - def __init__(self,item,intType,value): - QtCore.QThread.__init__(self) + def __init__(self, item, intType, value): + 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, diff --git a/autolab/core/gui/plotting/treewidgets.py b/autolab/core/gui/plotting/treewidgets.py index aee6019e..6e4dc453 100644 --- a/autolab/core/gui/plotting/treewidgets.py +++ b/autolab/core/gui/plotting/treewidgets.py @@ -14,51 +14,48 @@ from qtpy import QtCore, QtWidgets from .. import variables +from ..GUI_utilities import qt_object_exists from ... import paths, config -from ...utilities import qt_object_exists, SUPPORTED_EXTENSION +from ...utilities import SUPPORTED_EXTENSION class TreeWidgetItemModule(QtWidgets.QTreeWidgetItem): - """ This class represents a module in an item of the tree """ - def __init__(self,itemParent,name,nickname,gui): + def __init__(self, itemParent, name, nickname, gui): - QtWidgets.QTreeWidgetItem.__init__(self,itemParent,[nickname,'Module']) - self.setTextAlignment(1,QtCore.Qt.AlignHCenter) + 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 = type(gui.tree) is type(itemParent) - - def load(self,module): + self.is_not_submodule = isinstance(gui.tree, type(itemParent)) + def load(self, module): """ This function loads the entire module (submodules, variables, actions) """ - self.module = module # Submodules subModuleNames = self.module.list_modules() - for subModuleName in subModuleNames : + for subModuleName in subModuleNames: 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 : + for varName in varNames: variable = getattr(self.module,varName) TreeWidgetItemVariable(self, variable,self.gui) - # Actions actNames = self.module.list_actions() - for actName in actNames : + for actName in actNames: action = getattr(self.module,actName) - TreeWidgetItemAction(self, action,self.gui) + TreeWidgetItemAction(self, action, self.gui) # Change loaded status self.loaded = True @@ -66,17 +63,15 @@ def load(self,module): # Tooltip if self.module._help is not None: self.setToolTip(0, self.module._help) - def menu(self,position): - + def menu(self, position): """ This function provides the menu when the user right click on an item """ - if self.is_not_submodule and self.loaded: menu = QtWidgets.QMenu() disconnectDevice = menu.addAction(f"Disconnect {self.nickname}") choice = menu.exec_(self.gui.tree.viewport().mapToGlobal(position)) - if choice == disconnectDevice : + if choice == disconnectDevice: device = self.gui.active_plugin_dict[self.nickname] try: device.instance.close() # not device close because device.close will remove device from DEVICES list except: pass @@ -87,62 +82,58 @@ def menu(self,position): self.loaded = False - - - class TreeWidgetItemAction(QtWidgets.QTreeWidgetItem): - """ This class represents an action in an item of the tree """ - def __init__(self,itemParent,action,gui) : + def __init__(self, itemParent, action, gui): displayName = f'{action.name}' - if action.unit is not None : + if action.unit is not None: displayName += f' ({action.unit})' - QtWidgets.QTreeWidgetItem.__init__(self,itemParent,[displayName,'Action']) - self.setTextAlignment(1,QtCore.Qt.AlignHCenter) + super().__init__(itemParent, [displayName, 'Action']) + self.setTextAlignment(1, QtCore.Qt.AlignHCenter) self.gui = gui self.action = action - if self.action.has_parameter : - if self.action.type in [int,float,str,pd.DataFrame,np.ndarray] : + if self.action.has_parameter: + if self.action.type in [int, float, str, pd.DataFrame, np.ndarray]: self.executable = True self.has_value = True - else : + else: self.executable = False self.has_value = False - else : + else: self.executable = True self.has_value = False # Main - Column 2 : actionread button - if self.executable is True : + if self.executable: self.execButton = QtWidgets.QPushButton() self.execButton.setText("Execute") self.execButton.clicked.connect(self.execute) self.gui.tree.setItemWidget(self, 2, self.execButton) # Main - Column 3 : QlineEdit if the action has a parameter - if self.has_value : + 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) # Tooltip - if self.action._help is None : tooltip = 'No help available for this action' - else : tooltip = self.action._help + if self.action._help is None: tooltip = 'No help available for this action' + else: tooltip = self.action._help 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": # LEGACY (may be removed later) + 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) @@ -150,12 +141,12 @@ def readGui(self): if self.action.unit == "open-file": filename, _ = QtWidgets.QFileDialog.getOpenFileName( - self.gui, caption="Open file", + 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="Save file", + self.gui, caption=f"Save file - {self.action.name}", directory=paths.USER_LAST_CUSTOM_FOLDER, filter=SUPPORTED_EXTENSION) @@ -164,9 +155,24 @@ def readGui(self): paths.USER_LAST_CUSTOM_FOLDER = path return filename else: - self.gui.setStatus(f"Action {self.action.name} cancel filename selection", 10000) + 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 + else: + self.gui.setStatus( + f"Action {self.action.name} cancel user input", + 10000) else: - self.gui.setStatus(f"Action {self.action.name} requires a value for its parameter",10000, False) + self.gui.setStatus( + f"Action {self.action.name} requires a value for its parameter", + 10000, False) else: try: value = variables.eval_variable(value) @@ -176,32 +182,25 @@ def readGui(self): self.gui.setStatus(f"Action {self.action.name}: Impossible to convert {value} in type {self.action.type.__name__}",10000, False) def execute(self): - """ Start a new thread to execute the associated action """ - - if self.has_value : + 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 value is not None: self.gui.threadManager.start(self, 'execute', value=value) + else: + self.gui.threadManager.start(self, 'execute') class TreeWidgetItemVariable(QtWidgets.QTreeWidgetItem): - """ This class represents a variable in an item of the tree """ - def __init__(self,itemParent,variable,gui) : - + def __init__(self, itemParent, variable, gui): - self.displayName = f'{variable.name}' - if variable.unit is not None : - self.displayName += f' ({variable.unit})' + displayName = f'{variable.name}' + if variable.unit is not None: + displayName += f' ({variable.unit})' - QtWidgets.QTreeWidgetItem.__init__(self,itemParent,[self.displayName,'Variable']) - self.setTextAlignment(1,QtCore.Qt.AlignHCenter) + super().__init__(itemParent, [displayName, 'Variable']) + self.setTextAlignment(1, QtCore.Qt.AlignHCenter) self.gui = gui @@ -220,7 +219,7 @@ def __init__(self,itemParent,variable,gui) : 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]: self.readButton = QtWidgets.QPushButton() self.readButton.setText("Read") self.readButton.clicked.connect(self.read) @@ -229,18 +228,19 @@ 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,pd.DataFrame,np.ndarray]: + if self.variable.type in [int, float, str, pd.DataFrame, np.ndarray]: - if self.variable.writable : + if self.variable.writable: self.valueWidget = QtWidgets.QLineEdit() 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] : + elif self.variable.readable and self.variable.type in [int, float, str]: self.valueWidget = QtWidgets.QLineEdit() self.valueWidget.setReadOnly(True) - self.valueWidget.setStyleSheet("QLineEdit {border : 1px solid #a4a4a4; background-color : #f4f4f4}") + self.valueWidget.setStyleSheet( + "QLineEdit {border : 1px solid #a4a4a4; background-color : #f4f4f4}") self.valueWidget.setAlignment(QtCore.Qt.AlignCenter) else: self.valueWidget = QtWidgets.QLabel() @@ -249,16 +249,16 @@ def __init__(self,itemParent,variable,gui) : self.gui.tree.setItemWidget(self, 3, self.valueWidget) ## QCheckbox for boolean variables - elif self.variable.type in [bool] : + elif self.variable.type in [bool]: class MyQCheckBox(QtWidgets.QCheckBox): def __init__(self, parent): self.parent = parent - QtWidgets.QCheckBox.__init__(self) + super().__init__() def mouseReleaseEvent(self, event): - super(MyQCheckBox, self).mouseReleaseEvent(event) + super().mouseReleaseEvent(event) self.parent.valueEdited() self.parent.write() @@ -273,132 +273,110 @@ def mouseReleaseEvent(self, event): hbox.setContentsMargins(0,0,0,0) widget = QtWidgets.QWidget() widget.setLayout(hbox) - if self.variable.writable is False : # 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) # 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] : + 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) # Tooltip - if self.variable._help is None : tooltip = 'No help available for this variable' - else : tooltip = self.variable._help + if self.variable._help is None: tooltip = 'No help available for this variable' + else: tooltip = self.variable._help if hasattr(self.variable, "type"): variable_type = str(self.variable.type).split("'")[1] tooltip += f" ({variable_type})" - self.setToolTip(0,tooltip) - - - def writeGui(self,value): + self.setToolTip(0, tooltip) + 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 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 finihsed) # Update value - if self.variable.numerical : + if self.variable.numerical: self.valueWidget.setText(f'{value:.{self.precision}g}') # default is .6g - elif self.variable.type in [str] : + elif self.variable.type in [str]: self.valueWidget.setText(value) - elif self.variable.type in [bool] : + elif self.variable.type in [bool]: self.valueWidget.setChecked(value) # 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, 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, np.ndarray, pd.DataFrame]: value = self.valueWidget.text() - if value == '' : - self.gui.setStatus(f"Variable {self.variable.name} requires a value to be set",10000, False) - else : - try : + if value == '': + self.gui.setStatus( + f"Variable {self.variable.name} requires a value to be set", + 10000, False) + else: + try: value = variables.eval_variable(value) value = self.variable.type(value) return value - except : + except: self.gui.setStatus(f"Variable {self.variable.name}: Impossible to convert {value} in type {self.variable.type.__name__}",10000, False) - elif self.variable.type in [bool] : + elif self.variable.type in [bool]: value = self.valueWidget.isChecked() return value - def setValueKnownState(self,state): - + def setValueKnownState(self, state): """ Turn the color of the indicator depending of the known state of the value """ - - if state is True : self.indicator.setStyleSheet("background-color:#70db70") #green - else : self.indicator.setStyleSheet("background-color:#ff8c1a") #orange - - + if state: self.indicator.setStyleSheet("background-color:#70db70") # green + else: self.indicator.setStyleSheet("background-color:#ff8c1a") # orange def read(self): - """ Start a new thread to READ the associated variable """ - self.setValueKnownState(False) - self.gui.threadManager.start(self,'read') - - + 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 value is not None: + self.gui.threadManager.start(self, 'write', value=value) 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 """ - self.setValueKnownState(False) - - - def menu(self,position): - + def menu(self, position): """ This function provides the menu when the user right click on an item """ - if not self.isDisabled(): menu = QtWidgets.QMenu() - saveAction = menu.addAction("Read and save as...") - - saveAction.setEnabled(self.variable.readable) choice = menu.exec_(self.gui.tree.viewport().mapToGlobal(position)) - if choice == saveAction : + if choice == saveAction: self.saveValue() - 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'), - filter=SUPPORTED_EXTENSION)[0] + 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'), + filter=SUPPORTED_EXTENSION)[0] path = os.path.dirname(filename) - if path != '' : + if path != '': paths.USER_LAST_CUSTOM_FOLDER = path - try : - self.gui.setStatus(f"Saving value of {self.variable.name}...",5000) + try: + self.gui.setStatus( + f"Saving value of {self.variable.name}...", 5000) self.variable.save(filename) - self.gui.setStatus(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"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) # Signals can be emitted only from QObjects diff --git a/autolab/core/gui/scanning/config.py b/autolab/core/gui/scanning/config.py index c4f84fc2..bd2cf47f 100644 --- a/autolab/core/gui/scanning/config.py +++ b/autolab/core/gui/scanning/config.py @@ -185,19 +185,9 @@ def setRecipeOrder(self, keys: List[str]): """ Reorders recipes according to the list of recipe names 'keys' """ if not self.gui.scanManager.isStarted(): self.config = OrderedDict((key, self.config[key]) for key in keys) - self.resetRecipe() + self.gui._resetRecipe() self.addNewConfig() - def resetRecipe(self): - """ Resets recipe """ - self.gui._clearRecipe() # before everything to have access to recipe and del it - - for recipe_name in self.recipeNameList(): - self.gui._addRecipe(recipe_name) - for parameterManager in self.gui.recipeDict[recipe_name]['parameterManager'].values(): - parameterManager.refresh() - self.refreshRecipe(recipe_name) - def renameRecipe(self, existing_recipe_name: str, new_recipe_name: str): """ Renames recipe """ if not self.gui.scanManager.isStarted(): @@ -210,7 +200,7 @@ def renameRecipe(self, existing_recipe_name: str, new_recipe_name: str): old_config = self.config new_config = {} - for recipe_name in old_config.keys(): + for recipe_name in old_config: if recipe_name == existing_recipe_name: new_config[new_recipe_name] = old_config[recipe_name] else: @@ -220,7 +210,7 @@ def renameRecipe(self, existing_recipe_name: str, new_recipe_name: str): prev_index_recipe = self.gui.selectRecipe_comboBox.currentIndex() prev_index_param = self.gui.selectParameter_comboBox.currentIndex() - self.resetRecipe() + self.gui._resetRecipe() self.gui.selectRecipe_comboBox.setCurrentIndex(prev_index_recipe) self.gui._updateSelectParameter() self.gui.selectParameter_comboBox.setCurrentIndex(prev_index_param) @@ -239,7 +229,7 @@ def checkConfig(self): list_recipe_new = [recipe] has_sub_recipe = True - while has_sub_recipe: + while has_sub_recipe: # OBSOLETE has_sub_recipe = False recipe_list = list_recipe_new @@ -260,7 +250,7 @@ def checkConfig(self): # 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.keys() + 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( @@ -273,6 +263,7 @@ def lastRecipeName(self) -> str: """ Returns last recipe name """ return self.recipeNameList()[-1] if len(self.recipeNameList()) != 0 else "" + # set Param def _defaultParameterPars(self) -> dict: return {'name': 'parameter', 'address': 'None', @@ -280,7 +271,7 @@ def _defaultParameterPars(self) -> dict: 'start_value': 0, 'end_value': 0, 'log': False} - # set Param + def _addDefaultParameter(self, recipe_name: str): """ Adds a default parameter to the config""" parameter_name = self.getUniqueName(recipe_name, 'parameter') @@ -313,28 +304,17 @@ def removeParameter(self, recipe_name: str, param_name: str): self.addNewConfig() - def refreshParameterRange(self, recipe_name: str, - param_name: str, newName: str = None): - """ Updates parameterManager with new parameter name """ - recipeDictParam = self.gui.recipeDict[recipe_name]['parameterManager'] - - if newName is None: - recipeDictParam[param_name].refresh() - else: - if param_name in recipeDictParam: - recipeDictParam[newName] = recipeDictParam.pop(param_name) - recipeDictParam[newName].changeName(newName) - recipeDictParam[newName].refresh() - else: - print(f"Error: Can't refresh parameter '{param_name}', not found in recipeDictParam '{recipeDictParam}'") - - self.gui._updateSelectParameter() - def setParameter(self, recipe_name: str, param_name: str, element: devices.Device, 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(): + if recipe_name == "": + self.configHistory.active = False + self.addRecipe("recipe") + self.configHistory.active = True + recipe_name = self.lastRecipeName() + param_name = self.parameterNameList(recipe_name)[-1] if len(self.parameterList(recipe_name)) == 0: self.configHistory.active = False self.addParameter(recipe_name) @@ -347,7 +327,7 @@ def setParameter(self, recipe_name: str, param_name: str, if newName is None: newName = self.getUniqueName(recipe_name, element.name) param['name'] = newName - self.refreshParameterRange(recipe_name, param_name, newName) + self.gui._refreshParameterRange(recipe_name, param_name, newName) self.addNewConfig() def renameParameter(self, recipe_name: str, param_name: str, newName: str): @@ -362,7 +342,7 @@ def renameParameter(self, recipe_name: str, param_name: str, newName: str): else: newName = param_name - self.refreshParameterRange(recipe_name, param_name, newName) + self.gui._refreshParameterRange(recipe_name, param_name, newName) def setNbPts(self, recipe_name: str, param_name: str, value: int): """ Sets the number of points of a parameter """ @@ -379,7 +359,7 @@ def setNbPts(self, recipe_name: str, param_name: str, value: int): param['step'] = width / (value - 1) self.addNewConfig() - self.refreshParameterRange(recipe_name, param_name) + self.gui._refreshParameterRange(recipe_name, param_name) def setStep(self, recipe_name: str, param_name: str, value: float): """ Sets the step between points of a parameter """ @@ -398,7 +378,7 @@ def setStep(self, recipe_name: str, param_name: str, value: float): param['step'] = width / (param['nbpts'] - 1) self.addNewConfig() - self.refreshParameterRange(recipe_name, param_name) + self.gui._refreshParameterRange(recipe_name, param_name) def setRange(self, recipe_name: str, param_name: str, lim: Tuple[float, float]): @@ -423,7 +403,7 @@ def setRange(self, recipe_name: str, param_name: str, width / (self.getNbPts(recipe_name, param_name) - 1)) self.addNewConfig() - self.refreshParameterRange(recipe_name, param_name) + self.gui._refreshParameterRange(recipe_name, param_name) def setLog(self, recipe_name: str, param_name: str, state: bool): """ Sets the log state of a parameter """ @@ -433,7 +413,7 @@ def setLog(self, recipe_name: str, param_name: str, state: bool): if state != param['log']: param['log'] = state self.addNewConfig() - self.refreshParameterRange(recipe_name, param_name) + self.gui._refreshParameterRange(recipe_name, param_name) def setValues(self, recipe_name: str, param_name: str, values: List[float]): """ Sets custom values to a parameter """ @@ -444,7 +424,7 @@ def setValues(self, recipe_name: str, param_name: str, values: List[float]): param['values'] = values self.addNewConfig() - self.refreshParameterRange(recipe_name, param_name) + self.gui._refreshParameterRange(recipe_name, param_name) # set step def addRecipeStep(self, recipe_name: str, stepType: str, element, @@ -462,8 +442,8 @@ def addRecipeStep(self, recipe_name: str, stepType: str, element, else: name = self.getUniqueName(recipe_name, name) - step = {'stepType': stepType, 'element': element, - 'name': name, 'value': None} + step = {'name': name, 'element': element, + 'stepType': stepType, 'value': None} # Value if stepType == 'recipe': @@ -476,24 +456,22 @@ def addRecipeStep(self, recipe_name: str, stepType: str, element, if setValue: if value is None: if element.type in [int, float]: value = 0 - elif element.type in [ - str, np.ndarray, pd.DataFrame]: value = '' + elif element.type in [str]: value = '' + 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 step['value'] = value self.stepList(recipe_name).append(step) - self.refreshRecipe(recipe_name) + self.gui._refreshRecipe(recipe_name) self.addNewConfig() - def refreshRecipe(self, recipe_name: str): - self.gui.recipeDict[recipe_name]['recipeManager'].refresh() - def delRecipeStep(self, recipe_name: str, name: str): """ Removes a step from the scan recipe """ if not self.gui.scanManager.isStarted(): pos = self.getRecipeStepPosition(recipe_name, name) self.stepList(recipe_name).pop(pos) - self.refreshRecipe(recipe_name) + self.gui._refreshRecipe(recipe_name) self.addNewConfig() def renameRecipeStep(self, recipe_name: str, name: str, newName: str): @@ -503,7 +481,7 @@ def renameRecipeStep(self, recipe_name: str, name: str, newName: str): pos = self.getRecipeStepPosition(recipe_name, name) newName = self.getUniqueName(recipe_name, newName) self.stepList(recipe_name)[pos]['name'] = newName - self.refreshRecipe(recipe_name) + self.gui._refreshRecipe(recipe_name) self.addNewConfig() def setRecipeStepValue(self, recipe_name: str, name: str, value: Any): @@ -512,7 +490,7 @@ def setRecipeStepValue(self, recipe_name: str, name: str, value: Any): pos = self.getRecipeStepPosition(recipe_name, name) if value is not self.stepList(recipe_name)[pos]['value']: self.stepList(recipe_name)[pos]['value'] = value - self.refreshRecipe(recipe_name) + self.gui._refreshRecipe(recipe_name) self.addNewConfig() def setRecipeStepOrder(self, recipe_name: str, stepOrder: list): @@ -524,7 +502,7 @@ def setRecipeStepOrder(self, recipe_name: str, stepOrder: list): self.config[recipe_name]['recipe'] = [recipe[i] for i in newOrder] self.addNewConfig() - self.refreshRecipe(recipe_name) + self.gui._refreshRecipe(recipe_name) # CONFIG READING ########################################################################### @@ -532,10 +510,10 @@ def recipeNameList(self): """ Returns the list of recipe names """ return list(self.config.keys()) - def getLinkedRecipe(self) -> Dict[str, list]: + def getLinkedRecipe(self) -> Dict[str, list]: # OBSOLETE """ Returns a dict with recipe_name key and list of recipes linked to recipe_name recipe. Example: {'recipe_1': ['recipe_1', 'recipe_2', 'recipe_3', 'recipe_2'], 'recipe_3': ['recipe_3', 'recipe_2'], 'recipe_2': ['recipe_2']}""" - linkedRecipe = dict() + linkedRecipe = {} for recipe_name in self.recipeNameList(): recipe = self.config[recipe_name] @@ -569,37 +547,37 @@ def getLinkedRecipe(self) -> Dict[str, list]: return linkedRecipe - def getRecipeLink(self, recipe_name: str) -> List[str]: + def getRecipeLink(self, recipe_name: str) -> List[str]: # OBSOLETE """ Returns a list of unique recipe names for which recipes are linked to recipe_name Example: for 'recipe_1': ['recipe_1', 'recipe_2', 'recipe_3'] """ linkedRecipe = self.getLinkedRecipe() - uniqueLinkedRecipe = list() + uniqueLinkedRecipe = [] - for key in linkedRecipe.keys(): - if recipe_name in linkedRecipe[key]: - uniqueLinkedRecipe.append(linkedRecipe[key]) + for key, val in linkedRecipe.items(): + if recipe_name in val: + uniqueLinkedRecipe.append(val) return list(set(sum(uniqueLinkedRecipe, []))) - def getAllowedRecipe(self, recipe_name: str) -> List[str]: + def getAllowedRecipe(self, recipe_name: str) -> List[str]: # OBSOLETE """ Returns a list of recipe that can be added to recipe_name without risk of cycle or twice same recipe """ recipe_name_list = self.recipeNameList() linked_recipes = self.getLinkedRecipe() - for recipe_name_i in linked_recipes.keys(): + for recipe_name_i, recipe_i in linked_recipes.items(): # remove recipe that are in recipe_name if recipe_name_i in linked_recipes[recipe_name]: if recipe_name_i in recipe_name_list: recipe_name_list.remove(recipe_name_i) # remove recipe that contains recipe_name - if recipe_name in linked_recipes[recipe_name_i]: + if recipe_name in recipe_i: if recipe_name_i in recipe_name_list: recipe_name_list.remove(recipe_name_i) # remove all recipes that are in recipe_name_i - for recipe_name_j in linked_recipes[recipe_name_i]: + for recipe_name_j in recipe_i: if recipe_name_j in recipe_name_list: recipe_name_list.remove(recipe_name_j) @@ -720,13 +698,13 @@ def getParamDataFrame(self, recipe_name: str, param_name: str) -> pd.DataFrame: def getConfigVariables(self) -> List[Tuple[str, Any]]: """ Returns a (key, value) list of parameters and measured step """ - listVariable = list() + listVariable = [] listVariable.append(('ID', 1)) 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 values[0] + value = values if variables.has_eval(values) else float(values[0]) listVariable.append((param_name, value)) for step in self.stepList(recipe_name): if step['stepType'] == 'measure': @@ -803,16 +781,19 @@ def create_configPars(self) -> dict: np.ndarray, pd.DataFrame]): value = config_step['value'] - if config_step['element'].type in [np.ndarray]: - valueStr = array_to_str( - value, threshold=1000000, max_line_width=9000000) - elif config_step['element'].type in [pd.DataFrame]: - valueStr = dataframe_to_str(value, threshold=1000000) - elif config_step['element'].type in [int, float, str]: - try: - valueStr = f'{value:.{self.precision}g}' - except: - valueStr = f'{value}' + if variables.has_eval(value): + valueStr = value + else: + if config_step['element'].type in [np.ndarray]: + valueStr = array_to_str( + value, threshold=1000000, max_line_width=9000000) + elif config_step['element'].type in [pd.DataFrame]: + valueStr = dataframe_to_str(value, threshold=1000000) + elif config_step['element'].type in [int, float, str]: + try: + valueStr = f'{value:.{self.precision}g}' + except: + valueStr = f'{value}' pars_recipe_i['recipe'][f'{i+1}_value'] = valueStr @@ -820,22 +801,23 @@ 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.keys()) + names_var_user = list(variables.VARIABLES) names_var_to_save = list(set(names_var_user) - set(name_var_config)) - var_to_save = dict() + var_to_save = {} for var_name in names_var_to_save: - var = variables.VARIABLES.get(var_name) - if var is not None: - value = var.raw if isinstance(var, variables.Variable) else var + var = variables.get_variable(var_name) - if isinstance(value, np.ndarray): valueStr = array_to_str( - value, threshold=1000000, max_line_width=9000000) - elif isinstance(value, pd.DataFrame): valueStr = dataframe_to_str( - value, threshold=1000000) - elif isinstance(value, (int, float, str)): - try: valueStr = f'{value:.{self.precision}g}' - except: valueStr = f'{value}' + if var is not None: + assert variables.is_Variable(var) + value_raw = var.raw + 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, (int, float, str)): + try: valueStr = f'{value_raw:.{self.precision}g}' + except: valueStr = f'{value_raw}' var_to_save[var_name] = valueStr @@ -845,29 +827,30 @@ def create_configPars(self) -> dict: def import_configPars(self, filename: str, append: bool = False): """ Import a scan configuration from file with filename name """ - if os.path.exists(filename): - try: - legacy_configPars = configparser.ConfigParser() - legacy_configPars.read(filename) - except: + if not self.gui.scanManager.isStarted(): + if os.path.exists(filename): 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) - 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()} + legacy_configPars = configparser.ConfigParser() + legacy_configPars.read(filename) + except: + 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) + 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()} - path = os.path.dirname(filename) - paths.USER_LAST_CUSTOM_FOLDER = path + path = os.path.dirname(filename) + paths.USER_LAST_CUSTOM_FOLDER = path - self.load_configPars(configPars, append=append) + 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) + if not self._got_error: self.addNewConfig() + else: + 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 """ @@ -916,7 +899,7 @@ def load_configPars(self, configPars: dict, append: bool = False): # Config config = OrderedDict() - recipeNameList = [i for i in list(configPars.keys()) if i != 'autolab' and i != 'variables'] # to remove 'autolab' from recipe list + recipeNameList = [i for i in list(configPars) if i != 'autolab' and i != 'variables'] # to remove 'autolab' from recipe list for recipe_num_name in recipeNameList: @@ -1065,9 +1048,8 @@ def load_configPars(self, configPars: dict, append: bool = False): if 'variables' in configPars: var_dict = configPars['variables'] - add_vars = list() - for var_name in var_dict.keys(): - raw_value = var_dict[var_name] + add_vars = [] + for var_name, raw_value in var_dict.items(): raw_value = variables.convert_str_to_data(raw_value) add_vars.append((var_name, raw_value)) @@ -1078,7 +1060,7 @@ def load_configPars(self, configPars: dict, append: bool = False): self.gui.setStatus(f"Impossible to load configuration file: {error}", 10000, False) self.config = previous_config else: - self.resetRecipe() + self.gui._resetRecipe() self.gui.setStatus("Configuration file loaded successfully", 5000) for device in (set(devices.list_loaded_devices()) - set(already_loaded_devices)): diff --git a/autolab/core/gui/scanning/customWidgets.py b/autolab/core/gui/scanning/customWidgets.py index 5fdcf2f9..a5881320 100644 --- a/autolab/core/gui/scanning/customWidgets.py +++ b/autolab/core/gui/scanning/customWidgets.py @@ -24,7 +24,7 @@ def __init__(self, parent: QtWidgets.QFrame, self.recipe_name = recipe_name self.scanner = gui - QtWidgets.QTreeWidget.__init__(self, parent) + super().__init__(parent) self.setAcceptDrops(True) def mimeTypes(self) -> QtWidgets.QTreeWidget.mimeTypes: @@ -66,10 +66,10 @@ def decodeData(self, encoded: QtCore.QByteArray, stream = QtCore.QDataStream(encoded, QtCore.QIODevice.ReadOnly) while not stream.atEnd(): nItems = stream.readInt32() - for i in range(nItems): + for _ in range(nItems): path = stream.readInt32() row = [] - for j in range(path): + for _ in range(path): row.append(stream.readInt32()) rows.append(row) @@ -110,7 +110,7 @@ def dropEvent(self, event): self.scanner.configManager.configHistory.active = True self.scanner.configManager.addRecipeStep(self.recipe_name, stepType, stepElement, name, stepValue) - try: self.scanner.configManager.getAllowedRecipe(self.recipe_name) + try: self.scanner.configManager.getAllowedRecipe(self.recipe_name) # OBSOLETE except ValueError: self.scanner.setStatus('ERROR cycle between recipes detected! change cancelled', 10000, False) self.scanner.configManager.configHistory.active = False @@ -161,7 +161,7 @@ def dragEnterEvent(self, event): shadow = QtWidgets.QGraphicsDropShadowEffect(blurRadius=25, xOffset=3, yOffset=3) self.setGraphicsEffect(shadow) - elif type(event.source()) == type(self): + elif isinstance(event.source(), type(self)): try: # Refuse drop recipe in itself if event.mimeData().hasFormat(MyQTreeWidget.customMimeType): encoded = event.mimeData().data(MyQTreeWidget.customMimeType) @@ -211,7 +211,7 @@ def __init__(self, frame: QtWidgets.QFrame, self.recipe_name = recipe_name self.gui = gui - QtWidgets.QTabWidget.__init__(self) + super().__init__() self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.menu) @@ -265,7 +265,7 @@ def menu(self, position: QtCore.QPoint): moveDownRecipeAction.setIcon(QtGui.QIcon(icons['down'])) config = self.gui.configManager.config - keys = list(config.keys()) + keys = list(config) pos = keys.index(self.recipe_name) if pos == 0: moveUpRecipeAction.setEnabled(False) @@ -308,7 +308,7 @@ class parameterQFrame(QtWidgets.QFrame): def __init__(self, parent: QtWidgets.QMainWindow, recipe_name: str, param_name: str): self.recipe_name = recipe_name self.param_name = param_name - QtWidgets.QFrame.__init__(self, parent) + super().__init__(parent) self.setAcceptDrops(True) def dropEvent(self, event): @@ -326,7 +326,6 @@ def dragEnterEvent(self, event): # OPTIMIZE: create mimedata like for recipe if want to drag/drop parameter to recipe or parap to param if (hasattr(event.source(), "last_drag") and (hasattr(event.source().last_drag, "parameter_allowed") and event.source().last_drag.parameter_allowed)): event.accept() - shadow = QtWidgets.QGraphicsDropShadowEffect(blurRadius=25, xOffset=3, yOffset=3) self.setGraphicsEffect(shadow) else: diff --git a/autolab/core/gui/scanning/data.py b/autolab/core/gui/scanning/data.py index 7d76ac56..d45f55c5 100644 --- a/autolab/core/gui/scanning/data.py +++ b/autolab/core/gui/scanning/data.py @@ -11,7 +11,8 @@ import shutil import tempfile import sys -from typing import List +import random +from typing import List, Union import numpy as np import pandas as pd @@ -28,7 +29,7 @@ class DataManager: def __init__(self, gui: QtWidgets.QMainWindow): self.gui = gui - self.datasets = list() + self.datasets = [] self.queue = Queue() scanner_config = autolab_config.get_scanner_config() @@ -39,9 +40,10 @@ def __init__(self, gui: QtWidgets.QMainWindow): self.timer.setInterval(33) #30fps self.timer.timeout.connect(self.sync) - def getData(self, nbDataset: int, varList: list, - selectedData: int = 0, data_name: str = "Scan"): - """ This function returns to the figure manager the required data """ + def getData(self, nbDataset: int, var_list: list, + selectedData: int = 0, data_name: str = "Scan", + filter_condition: List[dict] = []) -> List[pd.DataFrame]: + """ Returns the required data """ dataList = [] recipe_name = self.gui.scan_recipe_comboBox.currentText() @@ -54,7 +56,8 @@ def getData(self, nbDataset: int, varList: list, if data_name == "Scan": try: - data = dataset.getData(varList, data_name=data_name) # OPTIMIZE: Currently can't recover dataset if error before end of first recipe loop + data = dataset.getData(var_list, data_name=data_name, + filter_condition=filter_condition) # OPTIMIZE: Currently can't recover dataset if error before end of first recipe loop except KeyError: pass # this occurs when plot variable from scani that is not in scanj except Exception as e: @@ -62,19 +65,24 @@ def getData(self, nbDataset: int, varList: list, f"Scan warning: Can't plot Scan{len(self.datasets)-i}: {e}", 10000, False) dataList.append(data) - elif dataset.dictListDataFrame.get(data_name) is not None: + elif dataset.data_arrays.get(data_name) is not None: dataList2 = [] - lenListDataFrame = len(dataset.dictListDataFrame[data_name]) - for index in range(lenListDataFrame): + try: + ids = dataset.getData(['id'], data_name='Scan', + filter_condition=filter_condition) + ids = ids['id'].values - 1 + except KeyError: + ids = [] + + for index in ids: try: - checkBoxChecked = self.gui.figureManager.menuBoolList[index] - if checkBoxChecked: - data = dataset.getData( - varList, data_name=data_name, dataID=index) + data = dataset.getData( + var_list, data_name=data_name, dataID=index) except Exception as e: self.gui.setStatus( - f"Scan warning: Can't plot Scan{len(self.datasets)-i} and dataframe {data_name} with ID {index+1}: {e}", + f"Scan warning: Can't plot Scan{len(self.datasets)-i}" \ + f" and dataframe {data_name} with ID {index+1}: {e}", 10000, False) dataList2.append(data) @@ -86,36 +94,34 @@ def getData(self, nbDataset: int, varList: list, dataList.reverse() return dataList - def getLastDataset(self) -> dict: - """ This return the last created dataset """ + def getLastDataset(self) -> Union[dict, None]: + """ Returns the last created dataset """ return self.datasets[-1] if len(self.datasets) > 0 else None - def getLastSelectedDataset(self) -> List[dict]: - """ This return the last selected dataset """ + def getLastSelectedDataset(self) -> Union[dict, None]: + """ Returns the last selected dataset """ index = self.gui.data_comboBox.currentIndex() if index != -1 and index < len(self.datasets): return self.datasets[index] - else: - return None + return None def newDataset(self, config: dict): - """ This function creates and returns a new empty dataset """ + """ Creates and returns a new empty dataset """ maximum = 0 - datasets = dict() + datasets = {} if self.save_temp: - temp_folder = os.environ['TEMP'] # This variable can be changed at autolab start-up - tempFolderPath = tempfile.mkdtemp(dir=temp_folder) # Creates a temporary directory for this dataset - self.gui.configManager.export(os.path.join(tempFolderPath, 'config.conf')) + FOLDER_TEMP = os.environ['TEMP'] # This variable can be changed at autolab start-up + folder_dataset_temp = tempfile.mkdtemp(dir=FOLDER_TEMP) # Creates a temporary directory for this dataset + self.gui.configManager.export( + os.path.join(folder_dataset_temp, 'config.conf')) else: - import random - tempFolderPath = str(random.random()) + folder_dataset_temp = str(random.random()) - for recipe_name in list(config.keys()): - recipe = config[recipe_name] + for recipe_name, recipe in config.items(): if recipe['active']: - sub_folder = os.path.join(tempFolderPath, recipe_name) + sub_folder = os.path.join(folder_dataset_temp, recipe_name) if self.save_temp: os.mkdir(sub_folder) dataset = Dataset(sub_folder, recipe_name, @@ -130,8 +136,11 @@ def newDataset(self, config: dict): values = variables.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: nbpts *= len(values) + self.gui.progressBar.setStyleSheet( + "QProgressBar::chunk {background-color: orange;}") + else: + values = utilities.create_array(values) + nbpts *= len(values) else: nbpts *= len(parameter['values']) else: nbpts *= parameter['nbpts'] @@ -176,7 +185,7 @@ def sync(self): lenQueue = self.queue.qsize() # Add scan data to dataset - for i in range(lenQueue): + for _ in range(lenQueue): try: point = self.queue.get() # point is collections.OrderedDict{0:recipe_name, 'parameter_name':parameter_value, 'step1_name':step1_value, 'step2_name':step2_value, ...} except: break @@ -211,75 +220,84 @@ def updateDisplayableResults(self): dataset = datasets[recipe_name] - if data_name == "Scan": - data = dataset.data + data = None + if data_name == "Scan": data = dataset.data else: - if dataset.dictListDataFrame.get(data_name) is not None: - for data in dataset.dictListDataFrame[data_name]: # used only to get columns name - if data is not None: - break - else: - return None + if dataset.data_arrays.get(data_name) is not None: + for data in dataset.data_arrays[data_name]: # used only to get columns name + if data is not None: break + else: return None # if text or if image of type ndarray return - if type(data) is str or (type(data) is np.ndarray and not (len(data.T.shape) == 1 or data.T.shape[0] == 2)): + if isinstance(data, str) or ( + isinstance(data, np.ndarray) and not ( + len(data.T.shape) == 1 or ( + len(data.T.shape) != 0 and data.T.shape[0] == 2))): self.gui.variable_x_comboBox.clear() + self.gui.variable_x2_comboBox.clear() self.gui.variable_y_comboBox.clear() return None - try: - data = utilities.formatData(data) - except AssertionError as e: # if np.ndarray of string for example + try: data = utilities.formatData(data) + except AssertionError: # if np.ndarray of string for example self.gui.variable_x_comboBox.clear() + self.gui.variable_x2_comboBox.clear() self.gui.variable_y_comboBox.clear() - # self.gui.setStatus(f"Error, can't plot data {data_name}: {e}", 10000, False) # already catched by getData return None - resultNamesList = [] + result_names = [] - for resultName in data.columns: - if resultName not in ['id']: + for result_name in data.columns: + if result_name not in ['id']: try: - point = data.iloc[0][resultName] + point = data.iloc[0][result_name] if isinstance(point, pd.Series): - print(f"Warning: At least two variables have the same name. Data acquisition is incorrect for {resultName}!", file=sys.stderr) + print('Warning: At least two variables have the same name.' \ + f" Data acquisition is incorrect for {result_name}!", + file=sys.stderr) float(point[0]) else: float(point) - resultNamesList.append(resultName) + result_names.append(result_name) except: pass variable_x_index = self.gui.variable_x_comboBox.currentIndex() variable_y_index = self.gui.variable_y_comboBox.currentIndex() - AllItems = [self.gui.variable_x_comboBox.itemText(i) for i in range(self.gui.variable_x_comboBox.count())] + items = [self.gui.variable_x_comboBox.itemText(i) for i in range( + self.gui.variable_x_comboBox.count())] - if resultNamesList != AllItems: # only refresh if change labels, to avoid gui refresh that prevent user to click on combobox + if result_names != items: # only refresh if change labels, to avoid gui refresh that prevent user to click on combobox self.gui.variable_x_comboBox.clear() - self.gui.variable_x_comboBox.addItems(resultNamesList) # parameter first - - if resultNamesList: - name = resultNamesList.pop(0) - resultNamesList.append(name) + self.gui.variable_x2_comboBox.clear() + self.gui.variable_x_comboBox.addItems(result_names) # parameter first + self.gui.variable_x2_comboBox.addItems(result_names) + if result_names: + name = result_names.pop(0) + result_names.append(name) self.gui.variable_y_comboBox.clear() - self.gui.variable_y_comboBox.addItems(resultNamesList) # first numerical measure first + self.gui.variable_y_comboBox.addItems(result_names) # first numerical measure first if data_name == "Scan": - if variable_x_index != -1: self.gui.variable_x_comboBox.setCurrentIndex(variable_x_index) - if variable_y_index != -1: self.gui.variable_y_comboBox.setCurrentIndex(variable_y_index) + if variable_x_index != -1: + self.gui.variable_x_comboBox.setCurrentIndex(variable_x_index) + self.gui.variable_x2_comboBox.setCurrentIndex(variable_x_index) + if variable_y_index != -1: + self.gui.variable_y_comboBox.setCurrentIndex(variable_y_index) + return None class Dataset(): """ Collection of data from a scan """ - def __init__(self, tempFolderPath: str, recipe_name: str, config: dict, + def __init__(self, folder_dataset_temp: str, recipe_name: str, config: dict, save_temp: bool = True): - self.all_data_temp = list() + self._data_temp = [] self.recipe_name = recipe_name - self.list_array = list() - self.dictListDataFrame = dict() - self.tempFolderPath = tempFolderPath + self.folders = [] + self.data_arrays = {} + self.folder_dataset_temp = folder_dataset_temp self.new = True self.save_temp = save_temp @@ -288,7 +306,7 @@ def __init__(self, tempFolderPath: str, recipe_name: str, config: dict, list_recipe_new = [recipe] has_sub_recipe = True - while has_sub_recipe: + while has_sub_recipe: # OBSOLETE has_sub_recipe = False recipe_list = list_recipe_new @@ -308,44 +326,82 @@ def __init__(self, tempFolderPath: str, recipe_name: str, config: dict, list_step = [recipe['recipe'] for recipe in list_recipe] self.list_step = sum(list_step, []) - self.header = ["id"] + [step['name'] for step in self.list_param] + [step['name'] for step in self.list_step if step['stepType'] == 'measure' and step['element'].type in [int, float, bool]] + self.header = (["id"] + + [step['name'] for step in self.list_param] + + [step['name'] for step in self.list_step if ( + step['stepType'] == 'measure' + and step['element'].type in [int, float, bool])] + ) self.data = pd.DataFrame(columns=self.header) - def getData(self, varList: list, data_name: str = "Scan", - dataID: int = 0) -> pd.DataFrame: + def getData(self, var_list: list, 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 """ if data_name == "Scan": data = self.data else: - data = self.dictListDataFrame[data_name][dataID] + data = self.data_arrays[data_name][dataID] - if ((data is not None) - and (type(data) is not str) - and (len(data.T.shape) == 1 or data.T.shape[0] == 2)): + if (data is not None + 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) else: # Image return data - if any(map(lambda v: v in varList, list(data.columns))): - if varList[0] == varList[1]: return data.loc[:, [varList[0]]] - else: return data.loc[:,varList] - else: - return None + # Add var for filtering + for var_filter in filter_condition: + if var_filter['enable']: + if (var_filter['name'] not in var_list + and var_filter['name'] != '' + and var_filter['name'] is not None): + var_list.append(var_filter['name']) + elif isinstance(var_filter['condition'], str): + for key in self.header: + if key in var_filter['condition']: + var_list.append(key) + + if any(map(lambda v: v in var_list, list(data.columns))): + data = data.loc[:,~data.columns.duplicated()].copy() # unique data column + unique_var_list = list(dict.fromkeys(var_list)) # unique var_list + # Filter data + for var_filter in filter_condition: + if var_filter['enable']: + if var_filter['name'] in data: + filter_cond = var_filter['condition'] + filter_name = var_filter['name'] + filter_value = var_filter['value'] + mask = filter_cond(data[filter_name], filter_value) + data = data[mask] + elif isinstance(var_filter['condition'], str): + filter_cond = var_filter['condition'] + if filter_cond: + try: + data = data.query(filter_cond) + except: + # If error, output empty dataframe + data = pd.DataFrame(columns=self.header) + break + + return data.loc[:,unique_var_list] + + return None def save(self, filename: str): """ This function saved the dataset in the provided path """ - dataset_folder, extension = os.path.splitext(filename) - data_name = os.path.join(self.tempFolderPath, 'data.txt') + dataset_folder = os.path.splitext(filename)[0] + data_name = os.path.join(self.folder_dataset_temp, 'data.txt') if os.path.exists(data_name): shutil.copy(data_name, filename) else: self.data.to_csv(filename, index=False, header=self.header) - if self.list_array: + if self.folders: if not os.path.exists(dataset_folder): os.mkdir(dataset_folder) - for tmp_folder in self.list_array: + for tmp_folder in self.folders: array_name = os.path.basename(tmp_folder) dest_folder = os.path.join(dataset_folder, array_name) @@ -355,20 +411,20 @@ def save(self, filename: str): # This is only executed if no temp folder is set if not os.path.exists(dest_folder): os.mkdir(dest_folder) - if array_name in self.dictListDataFrame.keys(): - list_data = self.dictListDataFrame[array_name] # data is list representing id 1,2 + if array_name in self.data_arrays: + list_data = self.data_arrays[array_name] # data is list representing id 1,2 for i, value in enumerate(list_data): path = os.path.join(dest_folder, f"{i+1}.txt") - if type(value) in [int, float, bool, str, tuple]: + if isinstance(value, (int, float, bool, str, tuple)): with open(path, 'w') as f: f.write(str(value)) - elif type(value) == bytes: + elif isinstance(value, bytes): with open(path, 'wb') as f: f.write(value) - elif type(value) == np.ndarray: + elif isinstance(value, np.ndarray): df = pd.DataFrame(value) df.to_csv(path, index=False, header=None) # faster and handle better different dtype than np.savetxt - elif type(value) == pd.DataFrame: + elif isinstance(value, pd.DataFrame): value.to_csv(path, index=False) def addPoint(self, dataPoint: OrderedDict): @@ -377,50 +433,48 @@ def addPoint(self, dataPoint: OrderedDict): simpledata = OrderedDict() simpledata['id'] = ID - for resultName in dataPoint.keys(): - result = dataPoint[resultName] + for result_name, result in dataPoint.items(): - if resultName == 0: # skip first result which is recipe_name - continue - else: - element_list = [step['element'] for step in self.list_param if step['name']==resultName] - if len(element_list) != 0: - element = element_list[0] - else: - element_list = [step['element'] for step in self.list_step if step['name']==resultName] - if len(element_list) != 0: - element = element_list[0] - # should always find element in lists above + if result_name == 0: continue # skip first result which is recipe_name + + elements = [step['element'] for step in ( + self.list_param+self.list_step) if step['name']==result_name] + element = elements[0] + # should always find exactly one element in list above # If the result is displayable (numerical), keep it in memory if element is None or element.type in [int, float, bool]: - simpledata[resultName] = result + simpledata[result_name] = result else : # Else write it on a file, in a temp directory - folderPath = os.path.join(self.tempFolderPath, resultName) + results_folder = os.path.join(self.folder_dataset_temp, result_name) if self.save_temp: - if not os.path.exists(folderPath): os.mkdir(folderPath) - filePath = os.path.join(folderPath, f'{ID}.txt') + if not os.path.exists(results_folder): os.mkdir(results_folder) + result_path = os.path.join(results_folder, f'{ID}.txt') if element is not None: - element.save(filePath, value=result) + element.save(result_path, value=result) - if folderPath not in self.list_array: - self.list_array.append(folderPath) + if results_folder not in self.folders: + self.folders.append(results_folder) - if self.dictListDataFrame.get(resultName) is None: - self.dictListDataFrame[resultName] = [] + if self.data_arrays.get(result_name) is None: + self.data_arrays[result_name] = [] - self.dictListDataFrame[resultName].append(result) + self.data_arrays[result_name].append(result) - self.all_data_temp.append(simpledata) - self.data = pd.DataFrame(self.all_data_temp, columns=self.header) + self._data_temp.append(simpledata) + self.data = pd.DataFrame(self._data_temp, columns=self.header) if self.save_temp: if ID == 1: - self.data.tail(1).to_csv(os.path.join(self.tempFolderPath, 'data.txt'), index=False, mode='a', header=self.header) - else : - self.data.tail(1).to_csv(os.path.join(self.tempFolderPath, 'data.txt'), index=False, mode='a', header=False) + self.data.tail(1).to_csv( + os.path.join(self.folder_dataset_temp, 'data.txt'), + index=False, mode='a', header=self.header) + else: + self.data.tail(1).to_csv( + os.path.join(self.folder_dataset_temp, 'data.txt'), + index=False, mode='a', header=False) def __len__(self): """ Returns the number of data point of this dataset """ diff --git a/autolab/core/gui/scanning/display.py b/autolab/core/gui/scanning/display.py index 4c2c4ed2..39968373 100644 --- a/autolab/core/gui/scanning/display.py +++ b/autolab/core/gui/scanning/display.py @@ -16,12 +16,11 @@ def __init__(self, name: str, size: QtCore.QSize = (250, 400)): """ Create a QWidget displaying the dataFrame input to the refresh method. size is of type QtCore.QSize or tuple of int """ - if type(size) in (tuple, list): - size = QtCore.QSize(*size) + if isinstance(size, (tuple, list)): size = QtCore.QSize(*size) self.active = False - QtWidgets.QWidget.__init__(self) + super().__init__() self.setWindowTitle(name) self.resize(size) @@ -50,9 +49,7 @@ def closeEvent(self, event): self.active = False def close(self): - - for children in self.findChildren( - QtWidgets.QWidget, options=QtCore.Qt.FindDirectChildrenOnly): + for children in self.findChildren(QtWidgets.QWidget): children.deleteLater() super().close() @@ -61,7 +58,7 @@ def close(self): class TableModel(QtCore.QAbstractTableModel): "From https://www.pythonguis.com/tutorials/pyqt6-qtableview-modelviews-numpy-pandas/" def __init__(self, data: pd.DataFrame): - super(TableModel, self).__init__() + super().__init__() self._data = data def data(self, index, role): diff --git a/autolab/core/gui/scanning/figure.py b/autolab/core/gui/scanning/figure.py index fe068f3b..86e9a626 100644 --- a/autolab/core/gui/scanning/figure.py +++ b/autolab/core/gui/scanning/figure.py @@ -6,17 +6,27 @@ """ import os +from typing import List import numpy as np import pandas as pd import pyqtgraph as pg -import pyqtgraph.exporters -from pyqtgraph.Qt import QtWidgets, QtGui +from qtpy import QtWidgets, QtGui, QtCore from .display import DisplayValues -from ..GUI_utilities import get_font_size, setLineEditBackground +from ..GUI_utilities import (get_font_size, setLineEditBackground, + pyqtgraph_fig_ax, pyqtgraph_image) +from ..slider import Slider +from ..variables import Variable from ..icons import icons -from ... import utilities + + +if hasattr(pd.errors, 'UndefinedVariableError'): + UndefinedVariableError = pd.errors.UndefinedVariableError +elif hasattr(pd.core.computation.ops, 'UndefinedVariableError'): # pd 1.1.5 + UndefinedVariableError = pd.core.computation.ops.UndefinedVariableError +else: + UndefinedVariableError = Exception class FigureManager: @@ -26,31 +36,49 @@ def __init__(self, gui: QtWidgets.QMainWindow): self.gui = gui self.curves = [] + self.filter_condition = [] self._font_size = get_font_size() + 1 # Configure and initialize the figure in the GUI - self.fig, self.ax = utilities.pyqtgraph_fig_ax() + self.fig, self.ax = pyqtgraph_fig_ax() self.gui.graph.addWidget(self.fig) - self.figMap, widget = utilities.pyqtgraph_image() + self.figMap, widget = pyqtgraph_image() self.gui.graph.addWidget(widget) self.figMap.hide() - getattr(self.gui, 'variable_x_comboBox').activated.connect( + self.gui.variable_x_comboBox.activated.connect( self.variableChanged) - getattr(self.gui, 'variable_y_comboBox').activated.connect( + self.gui.variable_x2_comboBox.activated.connect( + self.variableChanged) + self.gui.variable_y_comboBox.activated.connect( self.variableChanged) + pgv = pg.__version__.split('.') + if int(pgv[0]) == 0 and int(pgv[1]) < 12: + self.gui.variable_x2_checkBox.setEnabled(False) + self.gui.variable_x2_checkBox.setToolTip( + "Can't use 2D plot for scan, need pyqtgraph >= 0.13.2") + self.gui.setStatus( + "Can't use 2D plot for scan, need pyqtgraph >= 0.13.2", + 10000, False) + else: + self.fig.activate_img() + self.gui.variable_x2_checkBox.stateChanged.connect(self.reloadData) + # Number of traces self.nbtraces = 5 self.gui.nbTraces_lineEdit.setText(f'{self.nbtraces:g}') self.gui.nbTraces_lineEdit.returnPressed.connect(self.nbTracesChanged) - self.gui.nbTraces_lineEdit.textEdited.connect(lambda: setLineEditBackground( - self.gui.nbTraces_lineEdit,'edited', self._font_size)) - setLineEditBackground(self.gui.nbTraces_lineEdit, 'synced', self._font_size) + self.gui.nbTraces_lineEdit.textEdited.connect( + lambda: setLineEditBackground( + self.gui.nbTraces_lineEdit, 'edited', self._font_size)) + setLineEditBackground( + self.gui.nbTraces_lineEdit, 'synced', self._font_size) # Window to show scan data - self.gui.displayScanData_pushButton.clicked.connect(self.displayScanDataButtonClicked) + 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'])) @@ -60,53 +88,237 @@ def __init__(self, gui: QtWidgets.QMainWindow): self.gui.data_comboBox.hide() # Combobo to select the recipe to plot - self.gui.scan_recipe_comboBox.activated.connect(self.scan_recipe_comboBoxCurrentChanged) + self.gui.scan_recipe_comboBox.activated.connect( + self.scan_recipe_comboBoxCurrentChanged) self.gui.scan_recipe_comboBox.hide() # Combobox to select datafram to plot - self.gui.dataframe_comboBox.activated.connect(self.dataframe_comboBoxCurrentChanged) + self.gui.dataframe_comboBox.activated.connect( + self.dataframe_comboBoxCurrentChanged) self.gui.dataframe_comboBox.addItem("Scan") self.gui.dataframe_comboBox.hide() - self.gui.toolButton.hide() - self.clearMenuID() - - def clearMenuID(self): - self.gui.toolButton.setText("Parameter") - self.gui.toolButton.setPopupMode(QtWidgets.QToolButton.MenuButtonPopup) - self.gui.toolButton.setMenu(QtWidgets.QMenu(self.gui.toolButton)) - - # TODO: add bool 'all' like in drivers - - self.menuBoolList = list() # OPTIMIZE: edit: maybe not necessary <- when will merge everything, maybe have some class MetaDataset with init(dataSet) to collect all dataSet and organize data relative to scan id and dataframe - self.menuWidgetList = list() - self.menuActionList = list() - self.nbCheckBoxMenuID = 0 - - def addCheckBox2MenuID(self, name_ID: int): - self.menuBoolList.append(True) - checkBox = QtWidgets.QCheckBox(self.gui) - checkBox.setChecked(True) # Warning: trigger stateChanged (which do reloadData) - checkBox.stateChanged.connect(lambda state, checkBox=checkBox: self.checkBoxChanged(checkBox, state)) - checkBox.setText(str(name_ID)) - self.menuWidgetList.append(checkBox) - action = QtWidgets.QWidgetAction(self.gui.toolButton) - action.setDefaultWidget(checkBox) - self.gui.toolButton.menu().addAction(action) - self.menuActionList.append(action) - self.nbCheckBoxMenuID += 1 - - def removeLastCheckBox2MenuID(self): - self.menuBoolList.pop(-1) - self.menuWidgetList.pop(-1) - self.gui.toolButton.menu().removeAction(self.menuActionList.pop(-1)) - self.nbCheckBoxMenuID -= 1 # edit: not true anymore because display only one scan <- will cause "Error encountered for scan id 1: list index out of range" if do scan with n points and due a new scan with n-m points - - def checkBoxChanged(self, checkBox: QtWidgets.QCheckBox, state: bool): - index = self.menuWidgetList.index(checkBox) - self.menuBoolList[index] = bool(state) - if self.gui.dataframe_comboBox.currentText() != "Scan": - self.reloadData() + # Filter widgets + self.gui.scrollArea_filter.hide() + self.gui.checkBoxFilter.stateChanged.connect(self.checkBoxFilterChanged) + self.gui.addFilterPushButton.clicked.connect( + lambda: self.addFilterClicked('standard')) + self.gui.addSliderFilterPushButton.clicked.connect( + lambda: self.addFilterClicked('slider')) + self.gui.addCustomFilterPushButton.clicked.connect( + lambda: self.addFilterClicked('custom')) + self.gui.splitterGraph.setSizes([9000, 1000]) # fixe wrong proportion + + def refresh_filters(self): + """ Apply filters to data """ + 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() + + if layout.count() == 5: + enable = bool(layout.itemAt(0).widget().isChecked()) + variableComboBox = layout.itemAt(1).widget() + self.refresh_filter_combobox(variableComboBox) + name = variableComboBox.currentText() + condition_raw = layout.itemAt(2).widget().currentText() + valueWidget = layout.itemAt(3).widget() + if isinstance(valueWidget, Slider): + value = float(valueWidget.valueWidget.text()) # for custom slider + setLineEditBackground( + valueWidget.valueWidget, 'synced', self._font_size) + else: + try: + value = float(valueWidget.text()) # for editline + except: + continue + setLineEditBackground( + valueWidget, 'synced', self._font_size) + + convert_condition = { + '==': np.equal, '!=': np.not_equal, + '<': np.less, '<=': np.less_equal, + '>=': np.greater_equal, '>': np.greater + } + condition = convert_condition[condition_raw] + + filter_i = {'enable': enable, 'condition': condition, + 'name': name, 'value': value} + + elif layout.count() == 3: + enable = bool(layout.itemAt(0).widget().isChecked()) + customConditionWidget = layout.itemAt(1).widget() + condition_txt = customConditionWidget.text() + try: + pd.eval(condition_txt) # syntax check + except UndefinedVariableError: + pass + except Exception: + continue + setLineEditBackground( + customConditionWidget, 'synced', self._font_size) + + filter_i = {'enable': enable, 'condition': condition_txt, + 'name': None, 'value': None} + + 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: + if key not in items: + items.append(key) + + existing_items = [comboBox.itemText(i) for i in range(comboBox.count())] + if items != existing_items: + comboBox.clear() + comboBox.addItems(items) + + def addFilterClicked(self, filter_type): + """ Add filter condition """ + conditionLayout = QtWidgets.QHBoxLayout() + + 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) + conditionLayout.addWidget(filterCheckBox) + + 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) + filterCheckBox.stateChanged.connect( + lambda: self.refresh_filter_combobox(variableComboBox)) + conditionLayout.addWidget(variableComboBox) + + filterComboBox = QtWidgets.QComboBox() + filterComboBox.setMinimumSize(0, 21) + filterComboBox.setMaximumSize(16777215, 21) + items = ['==', '!=', '<', '<=', '>=', '>'] + filterComboBox.addItems(items) + filterComboBox.activated.connect(self.refresh_filters) + conditionLayout.addWidget(filterComboBox) + + 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( + valueWidget, 'edited', self._font_size)) + setLineEditBackground(valueWidget, 'synced', self._font_size) + conditionLayout.addWidget(valueWidget) + 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) + 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') + customConditionWidget.returnPressed.connect(self.refresh_filters) + customConditionWidget.textEdited.connect( + lambda: setLineEditBackground( + customConditionWidget, 'edited', self._font_size)) + setLineEditBackground(customConditionWidget, 'synced', self._font_size) + conditionLayout.addWidget(customConditionWidget) + + removePushButton = QtWidgets.QPushButton() + removePushButton.setMinimumSize(0, 21) + removePushButton.setMaximumSize(16777215, 21) + removePushButton.setIcon(QtGui.QIcon(icons['remove'])) + removePushButton.clicked.connect( + lambda: self.remove_filter(conditionLayout)) + conditionLayout.addWidget(removePushButton) + + self.gui.layoutFilter.insertLayout( + self.gui.layoutFilter.count()-1, conditionLayout) + self.refresh_filters() + + def remove_filter(self, layout): + """ Remove filter condition """ + for j in reversed(range(layout.count())): + layout.itemAt(j).widget().setParent(None) + layout.setParent(None) + self.refresh_filters() + + def checkBoxFilterChanged(self): + """ Show/hide filters frame and refresh filters """ + if self.gui.checkBoxFilter.isChecked(): + if not self.gui.scrollArea_filter.isVisible(): + self.gui.scrollArea_filter.show() + else: + if self.gui.scrollArea_filter.isVisible(): + self.gui.scrollArea_filter.hide() + + self.refresh_filters() def data_comboBoxClicked(self): """ This function select a dataset """ @@ -115,13 +327,14 @@ def data_comboBoxClicked(self): dataset = self.gui.dataManager.getLastSelectedDataset() index = self.gui.scan_recipe_comboBox.currentIndex() - resultNamesList = list(dataset.keys()) - AllItems = [self.gui.scan_recipe_comboBox.itemText(i) for i in range(self.gui.scan_recipe_comboBox.count())] + result_names = list(dataset) + items = [self.gui.scan_recipe_comboBox.itemText(i) for i in range( + self.gui.scan_recipe_comboBox.count())] - if AllItems != resultNamesList: + if items != result_names: self.gui.scan_recipe_comboBox.clear() - self.gui.scan_recipe_comboBox.addItems(resultNamesList) - if (index + 1) > len(resultNamesList) or index == -1: index = 0 + self.gui.scan_recipe_comboBox.addItems(result_names) + if (index + 1) > len(result_names) or index == -1: index = 0 self.gui.scan_recipe_comboBox.setCurrentIndex(index) if self.gui.scan_recipe_comboBox.count() > 1: @@ -134,7 +347,8 @@ def data_comboBoxClicked(self): self.gui.data_comboBox.hide() # Change save button text to inform on scan that will be saved - self.gui.save_pushButton.setText('Save '+self.gui.data_comboBox.currentText().lower()) + self.gui.save_pushButton.setText( + 'Save '+self.gui.data_comboBox.currentText().lower()) def scan_recipe_comboBoxCurrentChanged(self): self.dataframe_comboBoxCurrentChanged() @@ -142,7 +356,6 @@ def scan_recipe_comboBoxCurrentChanged(self): def dataframe_comboBoxCurrentChanged(self): self.updateDataframe_comboBox() - self.resetCheckBoxMenuID() self.gui.dataManager.updateDisplayableResults() self.reloadData() @@ -150,9 +363,9 @@ def dataframe_comboBoxCurrentChanged(self): data_name = self.gui.dataframe_comboBox.currentText() if data_name == "Scan" or self.fig.isHidden(): - self.gui.toolButton.hide() + self.gui.variable_x2_checkBox.show() else: - self.gui.toolButton.show() + self.gui.variable_x2_checkBox.hide() def updateDataframe_comboBox(self): # Executed each time the queue is read @@ -164,43 +377,24 @@ def updateDataframe_comboBox(self): sub_dataset = dataset[recipe_name] - resultNamesList = ["Scan"] + [ - i for i in sub_dataset.dictListDataFrame.keys() if type( - sub_dataset.dictListDataFrame[i][0]) not in (str, tuple)] # Remove this condition if want to plot string or tuple: Tuple[List[str], int] + result_names = ["Scan"] + [ + i for i, val in sub_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] - AllItems = [self.gui.dataframe_comboBox.itemText(i) for i in range(self.gui.dataframe_comboBox.count())] + items = [self.gui.dataframe_comboBox.itemText(i) for i in range( + self.gui.dataframe_comboBox.count())] - if resultNamesList != AllItems: # only refresh if change labels, to avoid gui refresh that prevent user to click on combobox + if result_names != items: # only refresh if change labels, to avoid gui refresh that prevent user to click on combobox self.gui.dataframe_comboBox.clear() - self.gui.dataframe_comboBox.addItems(resultNamesList) - if (index + 1) > len(resultNamesList): index = 0 + self.gui.dataframe_comboBox.addItems(result_names) + if (index + 1) > len(result_names): index = 0 self.gui.dataframe_comboBox.setCurrentIndex(index) - if len(resultNamesList) == 1: + if len(result_names) == 1: self.gui.dataframe_comboBox.hide() else: self.gui.dataframe_comboBox.show() - def resetCheckBoxMenuID(self): - recipe_name = self.gui.scan_recipe_comboBox.currentText() - dataset = self.gui.dataManager.getLastSelectedDataset() - data_name = self.gui.dataframe_comboBox.currentText() - - if dataset is not None and recipe_name in dataset and data_name != "Scan": - sub_dataset = dataset[recipe_name] - - dataframe = sub_dataset.dictListDataFrame[data_name] - nb_id = len(dataframe) - nb_bool = len(self.menuBoolList) - - if nb_id != nb_bool: - if nb_id > nb_bool: - for i in range(nb_bool+1, nb_id+1): - self.addCheckBox2MenuID(i) - else: - for i in range(nb_bool-nb_id): - self.removeLastCheckBox2MenuID() - # AXE LABEL ########################################################################### @@ -219,16 +413,22 @@ def clearData(self): try: self.ax.removeItem(curve) # try because curve=None if close before end of scan except: pass self.curves = [] + self.figMap.clear() + + if self.fig.img_active: + if self.fig.img.isVisible(): + self.fig.img.hide() # OPTIMIZE: would be better to erase data def reloadData(self): - ''' This function removes any plotted curves and reload all required curves from - data available in the data manager''' + ''' This function removes any plotted curves and reload all required + curves from data available in the data manager''' # Remove all curves self.clearData() # Get current displayed result data_name = self.gui.dataframe_comboBox.currentText() variable_x = self.gui.variable_x_comboBox.currentText() + variable_x2 = self.gui.variable_x2_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) @@ -241,34 +441,117 @@ def reloadData(self): self.setLabel('x', variable_x) self.setLabel('y', variable_y) - self.gui.frame_axis.show() - if data_name == "Scan": + self.displayed_as_image = self.gui.variable_x2_checkBox.isChecked() + + if data_name == "Scan" and not self.displayed_as_image: nbtraces_temp = self.nbtraces - self.gui.nbTraces_lineEdit.show() - self.gui.graph_nbTracesLabel.show() + if not self.gui.nbTraces_lineEdit.isVisible(): + self.gui.nbTraces_lineEdit.show() + self.gui.graph_nbTracesLabel.show() else: - nbtraces_temp = 1 # decided to only show the last scan data for dataframes - self.gui.nbTraces_lineEdit.hide() - self.gui.graph_nbTracesLabel.hide() + # decided to only show the last scan data for dataframes and scan displayed as image + nbtraces_temp = 1 + if self.gui.nbTraces_lineEdit.isVisible(): + self.gui.nbTraces_lineEdit.hide() + self.gui.graph_nbTracesLabel.hide() # Load the last results data - data: pd.DataFrame = self.gui.dataManager.getData( - nbtraces_temp, [variable_x, variable_y], - selectedData=selectedData, data_name=data_name) + if self.displayed_as_image: + var_to_display = [variable_x, variable_x2, variable_y] + else: + var_to_display = [variable_x, variable_y] - # update displayScan - self.refreshDisplayScanData() + can_filter = var_to_display != ['', ''] # 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 {} + + data: List[pd.DataFrame] = self.gui.dataManager.getData( + nbtraces_temp, var_to_display, + selectedData=selectedData, data_name=data_name, + filter_condition=filter_condition) # Plot data if data is not None: true_nbtraces = max(nbtraces_temp, len(data)) # not good but avoid error + if len(data) != 0: + # update displayScan + self.refreshDisplayScanData() + if not self.gui.displayScanData_pushButton.isVisible(): + self.gui.displayScanData_pushButton.show() + for temp_data in data: if temp_data is not None: break else: return None - if len(data) != 0 and type(data[0]) is np.ndarray: # to avoid errors - image_data = np.empty((len(data), *temp_data.shape)) + # If plot scan as image + if data_name == "Scan" and self.displayed_as_image: + + if not self.gui.variable_x2_comboBox.isVisible(): + self.gui.variable_x2_comboBox.show() + self.gui.label_scan_2D.show() + self.gui.label_y_axis.setText('Z axis') + + if not self.fig.isVisible(): + self.figMap.hide() + self.fig.show() + + if not self.fig.colorbar.isVisible(): + self.fig.colorbar.show() + + self.setLabel('x', variable_x) + self.setLabel('y', variable_x2) + + if variable_x == variable_x2: + return None + + # Data + if len(data) == 0: + return None + + subdata: pd.DataFrame = data[-1] # Only plot last scan + + if subdata is None: + return None + + subdata = subdata.astype(float) + + try: + pivot_table = subdata.pivot( + index=variable_x, columns=variable_x2, values=variable_y) + except ValueError: # if more than 2 parameters + return None + + # Extract data for plotting + x = np.array(pivot_table.columns) + y = np.array(pivot_table.index) + z = np.array(pivot_table) + + if 0 in (len(x), len(y), len(z)): + return None + + self.fig.update_img(x, y, z) + + if not self.fig.img.isVisible(): + self.fig.img.show() + + return None + + # If plot scan or array + if self.gui.variable_x2_comboBox.isVisible(): + self.gui.variable_x2_comboBox.hide() + self.gui.label_scan_2D.hide() + self.gui.label_y_axis.setText('Y axis') + + if self.fig.img_active: + if self.fig.colorbar.isVisible(): + self.fig.colorbar.hide() + + if self.fig.img.isVisible(): + self.fig.img.hide() + + if len(data) != 0 and isinstance(data[0], np.ndarray): # to avoid errors + images = np.empty((len(data), *temp_data.shape)) for i in range(len(data)): # Data @@ -276,38 +559,69 @@ def reloadData(self): if subdata is None: continue - if type(subdata) is str: # Could think of someway to show text. Currently removed it from dataset directly + if isinstance(subdata, str): # Could think of someway to show text. Currently removed it from dataset directly print("Warning: Can't display text") continue - elif type(subdata) is tuple: + if isinstance(subdata, tuple): print("Warning: Can't display tuple") continue subdata = subdata.astype(float) - if type(subdata) is np.ndarray: # is image - self.fig.hide() - self.figMap.show() - self.gui.frame_axis.hide() - image_data[i] = subdata + if isinstance(subdata, np.ndarray): # is image + if self.fig.isVisible(): + self.fig.hide() + self.figMap.show() + if self.gui.frameAxis.isVisible(): + self.gui.frameAxis.hide() + # OPTIMIZE: should be able to plot images of different shapes + if subdata.shape == temp_data.shape: + images[i] = subdata + has_bad_shape = False + else: + has_bad_shape = True if i == len(data)-1: - if image_data.ndim == 3: - x,y = (0, 1) if self.figMap.imageItem.axisOrder == 'col-major' else (1, 0) + if images.ndim == 3: + if self.figMap.imageItem.axisOrder == 'col-major': + x, y = (0, 1) + else: + x, y = (1, 0) axes = {'t': 0, 'x': x+1, 'y': y+1, 'c': None} # to avoid a special case in pg that incorrectly assumes the axis else: axes = None - self.figMap.setImage(image_data, axes=axes)# xvals=() # Defined which axe is major using pg.setConfigOption('imageAxisOrder', 'row-major') in gui start-up so no need to .T data + if has_bad_shape: + images_plot = np.array([subdata]) + print(f"Warning only plot last {data_name}." \ + " Images should have the same shape." \ + f" Given {subdata.shape} & {temp_data.shape}") + else: + images_plot = images + try: + self.figMap.setImage(images_plot, axes=axes) # Defined which axe is major using pg.setConfigOption('imageAxisOrder', 'row-major') in gui start-up so no need to .T data + except Exception as e: + print(f"Warning can't plot {data_name}: {e}") + continue self.figMap.setCurrentIndex(len(self.figMap.tVals)) else: # not an image (is pd.DataFrame) - self.figMap.hide() - self.fig.show() - x = subdata.loc[:,variable_x] - y = subdata.loc[:,variable_y] + if not self.fig.isVisible(): + self.fig.show() + self.figMap.hide() + if not self.gui.frameAxis.isVisible(): + self.gui.frameAxis.show() + + try: # If empty dataframe can't find variables + x = subdata.loc[:, variable_x] + y = subdata.loc[:, variable_y] + except KeyError: + continue + if isinstance(x, pd.DataFrame): - print(f"Warning: At least two variables have the same name. Data plotted is incorrect for {variable_x}!") + print('Warning: At least two variables have the same name.' \ + f" Data plotted is incorrect for {variable_x}!") if isinstance(y, pd.DataFrame): - print(f"Warning: At least two variables have the same name. Data plotted is incorrect for {variable_y}!") + print('Warning: At least two variables have the same name.' \ + f" Data plotted is incorrect for {variable_y}!") y = y.iloc[:, 0] if i == (len(data) - 1): @@ -318,14 +632,18 @@ def reloadData(self): alpha = (true_nbtraces - (len(data) - 1 - i)) / true_nbtraces # Plot - curve = self.ax.plot(x, y, symbol='x', symbolPen=color, symbolSize=10, pen=color, symbolBrush=color) + # careful, now that can filter data, need .values to avoid pyqtgraph bug + 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): """ 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 and self.gui.variable_y_comboBox.currentIndex() != -1: + if (self.gui.variable_x_comboBox.currentIndex() != -1 + and self.gui.variable_y_comboBox.currentIndex() != -1): self.reloadData() else: self.clearData() @@ -355,7 +673,6 @@ def nbTracesChanged(self): # Show data ########################################################################### def refreshDisplayScanData(self): - self.gui.displayScanData_pushButton.show() recipe_name = self.gui.scan_recipe_comboBox.currentText() datasets = self.gui.dataManager.getLastSelectedDataset() if datasets is not None and recipe_name in datasets: @@ -377,7 +694,7 @@ def save(self, filename: str): """ This function save the figure with the provided filename """ raw_name, extension = os.path.splitext(filename) new_filename = raw_name + ".png" - exporter = pg.exporters.ImageExporter(self.fig.plotItem) + exporter = pg.exporters.ImageExporter(self.ax) exporter.export(new_filename) def close(self): diff --git a/autolab/core/gui/scanning/interface.ui b/autolab/core/gui/scanning/interface.ui index f3ae081e..e558fbab 100644 --- a/autolab/core/gui/scanning/interface.ui +++ b/autolab/core/gui/scanning/interface.ui @@ -6,8 +6,8 @@ 0 0 - 1200 - 750 + 1154 + 740 @@ -55,12 +55,6 @@ - - Qt::ScrollBarAlwaysOn - - - Qt::ScrollBarAlwaysOff - true @@ -69,8 +63,8 @@ 0 0 - 513 - 553 + 362 + 543 @@ -133,7 +127,7 @@ - 40 + 0 20 @@ -326,17 +320,10 @@ - - - - ... - - - - Take start and end value, and type of scale, from figure. + Display scan data in tabular format Scan data @@ -350,7 +337,7 @@ - 40 + 0 20 @@ -359,26 +346,20 @@ - + Qt::Vertical - + 0 65 - - - 16777215 - 65 - - QFrame::StyledPanel @@ -405,7 +386,10 @@ X axis - Qt::AlignCenter + Qt::AlignBottom|Qt::AlignHCenter + + + 5 @@ -418,6 +402,55 @@ + + + + Qt::Horizontal + + + QSizePolicy::Maximum + + + + 10 + 20 + + + + + + + + 0 + + + + + + 75 + true + + + + Y axis + + + Qt::AlignBottom|Qt::AlignHCenter + + + 5 + + + + + + + QComboBox::AdjustToContents + + + + + @@ -425,7 +458,291 @@ - 40 + 0 + 20 + + + + + + + + Qt::Horizontal + + + QSizePolicy::Minimum + + + + 5 + 20 + + + + + + + + + + Qt::Vertical + + + + 20 + 5 + + + + + + + + Filter data + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 8 + + + + + + + + + 2 + 2 + + + + QFrame::StyledPanel + + + true + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + 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 + + + + + + + + + + + + + + + + + + + + + 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 @@ -442,7 +759,10 @@ Nb traces - Qt::AlignCenter + Qt::AlignBottom|Qt::AlignHCenter + + + 5 @@ -473,6 +793,22 @@ + + + + Qt::Horizontal + + + QSizePolicy::Minimum + + + + 5 + 20 + + + + @@ -480,7 +816,7 @@ - 40 + 0 20 @@ -492,7 +828,7 @@ 0 - + 75 @@ -503,7 +839,10 @@ Y axis - Qt::AlignCenter + Qt::AlignBottom|Qt::AlignHCenter + + + 5 @@ -538,7 +877,7 @@ 0 0 - 1200 + 1154 21 diff --git a/autolab/core/gui/scanning/main.py b/autolab/core/gui/scanning/main.py index 0cf69e99..5795d860 100644 --- a/autolab/core/gui/scanning/main.py +++ b/autolab/core/gui/scanning/main.py @@ -31,10 +31,10 @@ def __init__(self, mainGui: QtWidgets.QMainWindow): self.mainGui = mainGui # Configuration of the window - QtWidgets.QMainWindow.__init__(self) + super().__init__() ui_path = os.path.join(os.path.dirname(__file__), 'interface.ui') uic.loadUi(ui_path, self) - self.setWindowTitle("AUTOLAB Scanner") + self.setWindowTitle("AUTOLAB - Scanner") self.setWindowIcon(QtGui.QIcon(icons['scanner'])) self.splitter.setSizes([500, 700]) # Set the width of the two main widgets self.setAcceptDrops(True) @@ -92,6 +92,9 @@ def __init__(self, mainGui: QtWidgets.QMainWindow): # Clear button configuration self.clear_pushButton.clicked.connect(self.clear) + self.variable_x2_comboBox.hide() + self.label_scan_2D.hide() + def populateOpenRecent(self): """ https://realpython.com/python-menus-toolbars/#populating-python-menus-dynamically """ self.openRecentMenu.clear() @@ -119,7 +122,7 @@ def addOpenRecent(self, filename: str): 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(dict.fromkeys(reversed(lines)))) # unique names + lines = list(reversed(list(dict.fromkeys(reversed(lines))))) # unique names lines = lines[-10:] with open(paths.HISTORY_CONFIG, 'w') as f: f.writelines(lines) @@ -134,19 +137,17 @@ def clearOpenRecent(self): def clear(self): """ This reset any recorded data, and the GUI accordingly """ - self.dataManager.datasets = list() + self.dataManager.datasets = [] self.figureManager.clearData() - self.figureManager.clearMenuID() self.figureManager.figMap.hide() self.figureManager.fig.show() self.figureManager.setLabel("x", " ") self.figureManager.setLabel("y", " ") - self.nbTraces_lineEdit.show() - self.graph_nbTracesLabel.show() - self.frame_axis.show() - self.toolButton.hide() + self.frameAxis.show() self.variable_x_comboBox.clear() + self.variable_x2_comboBox.clear() self.variable_y_comboBox.clear() + self.variable_x2_checkBox.show() self.data_comboBox.clear() self.data_comboBox.hide() self.save_pushButton.setEnabled(False) @@ -163,6 +164,7 @@ def clear(self): def openVariablesMenu(self): if self.variablesMenu is None: self.variablesMenu = variables.VariablesMenu(self) + self.variablesMenu.show() else: self.variablesMenu.refresh() @@ -215,7 +217,7 @@ def _update_recipe_combobox(self): def _clearRecipe(self): """ Clears recipes from managers. Called by configManager """ - for recipe_name in list(self.recipeDict.keys()): + for recipe_name in list(self.recipeDict): self._removeRecipe(recipe_name) def _addParameter(self, recipe_name: str, param_name: str): @@ -224,7 +226,7 @@ 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(len(layoutAll)-1, new_ParameterManager.mainFrame) + layoutAll.insertWidget(layoutAll.count()-1, new_ParameterManager.mainFrame) self._updateSelectParameter() self.selectParameter_comboBox.setCurrentIndex(self.selectParameter_comboBox.count()-1) @@ -260,6 +262,36 @@ def _updateSelectParameter(self): if not self.selectRecipe_comboBox.isVisible(): self.label_selectRecipeParameter.hide() + def _refreshParameterRange(self, recipe_name: str, param_name: str, + newName: str = None): + """ Updates parameterManager with new parameter name """ + recipeDictParam = self.recipeDict[recipe_name]['parameterManager'] + + if newName is None: + recipeDictParam[param_name].refresh() + else: + if param_name in recipeDictParam: + recipeDictParam[newName] = recipeDictParam.pop(param_name) + recipeDictParam[newName].changeName(newName) + recipeDictParam[newName].refresh() + else: + print(f"Error: Can't refresh parameter '{param_name}', not found in recipeDictParam '{recipeDictParam}'") + + self._updateSelectParameter() + + def _refreshRecipe(self, recipe_name: str): + self.recipeDict[recipe_name]['recipeManager'].refresh() + + def _resetRecipe(self): + """ Resets recipe """ + self._clearRecipe() # before everything to have access to recipe and del it + + for recipe_name in self.configManager.recipeNameList(): + self._addRecipe(recipe_name) + for parameterManager in self.recipeDict[recipe_name]['parameterManager'].values(): + parameterManager.refresh() + self._refreshRecipe(recipe_name) + def importActionClicked(self): """ Prompts the user for a configuration filename, and import the current scan configuration from it """ @@ -288,8 +320,6 @@ def __init__(self, parent: QtWidgets.QMainWindow, append: bool): appendCheck.stateChanged.connect(self.appendCheckChanged) layout.addWidget(appendCheck) - self.show() - self.exec_ = file_dialog.exec_ self.selectedFiles = file_dialog.selectedFiles @@ -297,14 +327,13 @@ def appendCheckChanged(self, event): self.append = event def closeEvent(self, event): - for children in self.findChildren( - QtWidgets.QWidget, options=QtCore.Qt.FindDirectChildrenOnly): + for children in self.findChildren(QtWidgets.QWidget): children.deleteLater() super().closeEvent(event) - main_dialog = ImportDialog(self, self._append) + main_dialog.show() once_or_append = True while once_or_append: @@ -344,10 +373,10 @@ def exportActionClicked(self): 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 """ - filename, _ = QtWidgets.QFileDialog.getSaveFileName( + filename = QtWidgets.QFileDialog.getSaveFileName( self, caption="Save data", directory=paths.USER_LAST_CUSTOM_FOLDER, - filter=utilities.SUPPORTED_EXTENSION) + filter=utilities.SUPPORTED_EXTENSION)[0] path = os.path.dirname(filename) if path != '': @@ -355,7 +384,7 @@ def saveButtonClicked(self): self.setStatus('Saving data...', 5000) datasets = self.dataManager.getLastSelectedDataset() - for dataset_name in datasets.keys(): + for dataset_name in datasets: dataset = datasets[dataset_name] if len(datasets) == 1: @@ -371,7 +400,8 @@ def saveButtonClicked(self): if save_config: dataset_folder, extension = os.path.splitext(filename) new_configname = dataset_folder + ".conf" - config_name = os.path.join(os.path.dirname(dataset.tempFolderPath), 'config.conf') + 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) @@ -388,8 +418,6 @@ def saveButtonClicked(self): self.setStatus( f'Last dataset successfully saved in {filename}', 5000) - - def dropEvent(self, event): """ Imports config file if event has url of a file """ filename = event.mimeData().urls()[0].toLocalFile() @@ -436,8 +464,7 @@ def closeEvent(self, event): self.figureManager.close() - for children in self.findChildren( - QtWidgets.QWidget, options=QtCore.Qt.FindDirectChildrenOnly): + for children in self.findChildren(QtWidgets.QWidget): children.deleteLater() # Remove scan variables from VARIABLES diff --git a/autolab/core/gui/scanning/parameter.py b/autolab/core/gui/scanning/parameter.py index f5318ead..5769b18f 100644 --- a/autolab/core/gui/scanning/parameter.py +++ b/autolab/core/gui/scanning/parameter.py @@ -328,7 +328,7 @@ def refresh(self): address = element.address() unit = element.unit else: - address = 'None' + address = self.param_name unit = '' self.parameterName_lineEdit.setEnabled(True) @@ -510,7 +510,7 @@ def endChanged(self): xrange[1] = value self.gui.configManager.setRange(self.recipe_name, self.param_name, xrange) - except : + except: self.refresh() def meanChanged(self): @@ -525,8 +525,9 @@ def meanChanged(self): xrange_new = xrange.copy() xrange_new[0] = value - (xrange[1] - xrange[0])/2 xrange_new[1] = value + (xrange[1] - xrange[0])/2 - assert xrange_new[0] > 0 - assert xrange_new[1] > 0 + if log: + assert xrange_new[0] > 0 + assert xrange_new[1] > 0 self.gui.configManager.setRange(self.recipe_name, self.param_name, xrange_new) except: @@ -544,8 +545,9 @@ def widthChanged(self): xrange_new = xrange.copy() xrange_new[0] = (xrange[1]+xrange[0])/2 - value/2 xrange_new[1] = (xrange[1]+xrange[0])/2 + value/2 - assert xrange_new[0] > 0 - assert xrange_new[1] > 0 + if log: + assert xrange_new[0] > 0 + assert xrange_new[1] > 0 self.gui.configManager.setRange(self.recipe_name, self.param_name, xrange_new) except: @@ -614,8 +616,9 @@ def valuesChanged(self): values = raw_values elif not variables.has_variable(raw_values): values = variables.eval_safely(raw_values) - values = create_array(values) - assert len(values) != 0, "Cannot have empty array" + if not isinstance(values, str): + values = create_array(values) + assert len(values) != 0, "Cannot have empty array" self.gui.configManager.setValues(self.recipe_name, self.param_name, raw_values) except Exception as e: @@ -647,7 +650,7 @@ def setProcessingState(self, state: str): if state == 'idle': self.parameterAddress_label.setStyleSheet( f"font-size: {self._font_size+1}pt;") - else : + else: if state == 'started': color = '#ff8c1a' if state == 'finished': color = '#70db70' self.parameterAddress_label.setStyleSheet( diff --git a/autolab/core/gui/scanning/recipe.py b/autolab/core/gui/scanning/recipe.py index 7b6b1808..0df7fad2 100644 --- a/autolab/core/gui/scanning/recipe.py +++ b/autolab/core/gui/scanning/recipe.py @@ -146,21 +146,23 @@ def refresh(self): # Column 5 : Value if stepType is 'set' value = step['value'] if value is not None: - - 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: + 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 @@ -207,11 +209,11 @@ def rightClick(self, position: QtCore.QPoint): choice = menu.exec_(self.tree.viewport().mapToGlobal(position)) - if 'rename' in menuActions.keys() and choice == menuActions['rename']: + if 'rename' in menuActions and choice == menuActions['rename']: self.renameStep(name) - elif 'remove' in menuActions.keys() and choice == menuActions['remove']: + elif 'remove' in menuActions and choice == menuActions['remove']: self.gui.configManager.delRecipeStep(self.recipe_name, name) - elif 'setvalue' in menuActions.keys() and choice == menuActions['setvalue']: + 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 # config = self.gui.configManager.config @@ -273,17 +275,21 @@ def setStepValue(self, name: str): self.recipe_name, name) # Default value displayed in the QInputDialog - 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 variables.has_eval(value): + defaultValue = f'{value}' else: - try: - defaultValue = f'{value:.{self.precision}g}' - except (ValueError, TypeError): - defaultValue = f'{value}' + 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) + else: + try: + defaultValue = f'{value:.{self.precision}g}' + except (ValueError, TypeError): + defaultValue = f'{value}' main_dialog = variables.VariablesDialog(self.gui, name, defaultValue) + main_dialog.show() if main_dialog.exec_() == QtWidgets.QInputDialog.Accepted: value = main_dialog.textValue() diff --git a/autolab/core/gui/scanning/scan.py b/autolab/core/gui/scanning/scan.py index 57108db8..c7a8f944 100644 --- a/autolab/core/gui/scanning/scan.py +++ b/autolab/core/gui/scanning/scan.py @@ -5,6 +5,7 @@ @author: qchat """ +import os import time import math as m import threading @@ -16,7 +17,9 @@ from qtpy import QtCore, QtWidgets from .. import variables -from ...utilities import create_array +from ... import paths +from ..GUI_utilities import qt_object_exists +from ...utilities import create_array, SUPPORTED_EXTENSION class ScanManager: @@ -38,6 +41,7 @@ def __init__(self, gui: QtWidgets.QMainWindow): # Thread self.thread = None + self.main_dialog = None # START AND STOP ############################################################################# @@ -58,7 +62,7 @@ def start(self): 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) + self.gui.setStatus(f'ERROR The scan cannot start with the current configuration: {str(e)}', 10000, False) # Only if current config is valid to start a scan else: # Prepare a new dataset in the datacenter @@ -74,6 +78,7 @@ def start(self): 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')) @@ -98,10 +103,119 @@ def start(self): 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) + def handler_user_input(self, stepInfos: dict): + unit = stepInfos['element'].unit + name = stepInfos['name'] + + 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.show() + + if self.main_dialog.exec_() == QtWidgets.QInputDialog.Accepted: + filename = self.main_dialog.selectedFiles()[0] + else: + filename = '' + + elif unit == "save-file": + self.main_dialog = FileDialog(self.gui, name, QtWidgets.QFileDialog.AcceptSave) + self.main_dialog.show() + + if self.main_dialog.exec_() == QtWidgets.QInputDialog.Accepted: + filename = self.main_dialog.selectedFiles()[0] + else: + filename = '' + + if filename != '': + path = os.path.dirname(filename) + paths.USER_LAST_CUSTOM_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.show() + + if self.main_dialog.exec_() == QtWidgets.QInputDialog.Accepted: + response = self.main_dialog.textValue() + else: + response = '' + + if qt_object_exists(self.main_dialog): self.main_dialog.deleteLater() + 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): self.gui.progressBar.setStyleSheet("") @@ -129,7 +243,9 @@ def stop(self): """ Stops manually the scan """ self.disableContinuousMode() 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() self.thread.wait() # SIGNALS @@ -144,6 +260,7 @@ def finished(self): 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 @@ -200,6 +317,7 @@ class ScanThread(QtCore.QThread): """ This thread class is dedicated to read the variable, and send its data to GUI through a queue """ # Signals + userSignal = QtCore.Signal(dict) errorSignal = QtCore.Signal(object) startParameterSignal = QtCore.Signal(object, object) @@ -212,23 +330,25 @@ class ScanThread(QtCore.QThread): scanCompletedSignal = QtCore.Signal() def __init__(self, queue: Queue, config: dict): - QtCore.QThread.__init__(self) + super().__init__() self.config = config self.queue = queue self.pauseFlag = threading.Event() self.stopFlag = threading.Event() + self.user_response = None + def run(self): # Start the scan - for recipe_name in self.config.keys(): + for recipe_name in self.config: if self.config[recipe_name]['active']: self.execRecipe(recipe_name) self.scanCompletedSignal.emit() def execRecipe(self, recipe_name: str, initPoint: OrderedDict = None): - """ Executes a recipe. initPoint is used to add parameters values + """ Executes a recipe. initPoint is obsolete, was used to add parameters values and master-recipe name to a sub-recipe """ paramValues_list = [] @@ -239,8 +359,12 @@ def execRecipe(self, recipe_name: str, if 'values' in parameter: paramValues = parameter['values'] - paramValues = variables.eval_variable(paramValues) - paramValues = create_array(paramValues) + try: + paramValues = variables.eval_variable(paramValues) + paramValues = create_array(paramValues) + except Exception as e: + self.errorSignal.emit(e) + self.stopFlag.set() else: startValue, endValue = parameter['range'] nbpts = parameter['nbpts'] @@ -261,7 +385,7 @@ def execRecipe(self, recipe_name: str, if not self.stopFlag.is_set(): - if initPoint is None: + if initPoint is None: # OBSOLETE initPoint = OrderedDict() initPoint[0] = recipe_name @@ -269,15 +393,17 @@ def execRecipe(self, recipe_name: str, try: self._source_of_error = None + ID += 1 + variables.set_variable('ID', ID) + for parameter, paramValue in zip( self.config[recipe_name]['parameter'], paramValueList): self._source_of_error = parameter element = parameter['element'] param_name = parameter['name'] - ID += 1 - variables.set_variable('ID', ID) - variables.set_variable(param_name, paramValue) + variables.set_variable(param_name, element.type( + paramValue) if element is not None else paramValue) # Set the parameter value self.startParameterSignal.emit(recipe_name, param_name) @@ -357,17 +483,27 @@ def processElement(self, recipe_name: str, stepInfos: dict, if stepType == 'measure': result = element() variables.set_variable(stepInfos['name'], result) - elif stepType == 'set': value = variables.eval_variable(stepInfos['value']) + if element.type in [np.ndarray]: value = create_array(value) element(value) elif stepType == 'action': if stepInfos['value'] is not None: - value = variables.eval_variable(stepInfos['value']) - element(value) + # Open dialog for open file, save file or input text + if stepInfos['value'] == '': + self.userSignal.emit(stepInfos) + while (not self.stopFlag.is_set() + and self.user_response is None): + time.sleep(0.1) + if not self.stopFlag.is_set(): + element(self.user_response) + self.user_response = None + else: + value = variables.eval_variable(stepInfos['value']) + element(value) else: element() - elif stepType == 'recipe': + elif stepType == 'recipe': # OBSOLETE self.execRecipe(element, initPoint=initPoint) # Execute a recipe in the recipe self.finishStepSignal.emit(recipe_name, stepInfos['name']) diff --git a/autolab/core/gui/slider.py b/autolab/core/gui/slider.py new file mode 100644 index 00000000..5feffe65 --- /dev/null +++ b/autolab/core/gui/slider.py @@ -0,0 +1,302 @@ +# -*- coding: utf-8 -*- +""" +Created on Mon Apr 17 23:23:51 2023 + +@author: jonathan +""" +from typing import Any + +import numpy as np +from qtpy import QtCore, QtWidgets, QtGui + +from .icons import icons +from .GUI_utilities import get_font_size, setLineEditBackground +from .. import config + + +if hasattr(QtCore.Qt.LeftButton, 'value'): + LeftButton = QtCore.Qt.LeftButton.value +else: + LeftButton = QtCore.Qt.LeftButton + + +class Slider(QtWidgets.QMainWindow): + + changed = QtCore.Signal() + + def __init__(self, var: Any, 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.item = item + self.main_gui = self.item.gui if hasattr(self.item, 'gui') else None + self.resize(self.minimumSizeHint()) + self.setWindowTitle(self.variable.address()) + self.setWindowIcon(QtGui.QIcon(icons['slider'])) + + # Load configuration + control_center_config = config.get_control_center_config() + self.precision = int(control_center_config['precision']) + + self._font_size = get_font_size() + 1 + + # Slider + self.slider_instantaneous = True + + self.true_min = self.variable.type(0) + self.true_max = self.variable.type(10) + self.true_step = self.variable.type(1) + + centralWidget = QtWidgets.QWidget() + layoutWindow = QtWidgets.QVBoxLayout() + layoutTopValue = QtWidgets.QHBoxLayout() + layoutSlider = QtWidgets.QHBoxLayout() + layoutBottomValues = QtWidgets.QHBoxLayout() + + centralWidget.setLayout(layoutWindow) + layoutWindow.addLayout(layoutTopValue) + layoutWindow.addLayout(layoutSlider) + layoutWindow.addLayout(layoutBottomValues) + + self.instantCheckBox = QtWidgets.QCheckBox() + self.instantCheckBox.setToolTip("True: Changes instantaneously the value.\nFalse: Changes the value when click released.") + self.instantCheckBox.setCheckState(QtCore.Qt.Checked) + self.instantCheckBox.stateChanged.connect(self.instantChanged) + + layoutTopValue.addWidget(QtWidgets.QLabel("Instant")) + layoutTopValue.addWidget(self.instantCheckBox) + + self.valueWidget = QtWidgets.QLineEdit() + self.valueWidget.setAlignment(QtCore.Qt.AlignCenter) + self.valueWidget.setReadOnly(True) + self.valueWidget.setText(f'{self.true_min}') + setLineEditBackground(self.valueWidget, 'edited', self._font_size) + + layoutTopValue.addStretch() + layoutTopValue.addWidget(QtWidgets.QLabel("Value")) + layoutTopValue.addWidget(self.valueWidget) + layoutTopValue.addStretch() + layoutTopValue.addSpacing(40) + + self.sliderWidget = QtWidgets.QSlider(QtCore.Qt.Horizontal) + self.sliderWidget.setValue(0) + self.sliderWidget.setTickPosition(QtWidgets.QSlider.TicksBelow) + self.sliderWidget.valueChanged.connect(self.valueChanged) + self.sliderWidget.sliderReleased.connect(self.sliderReleased) + self.sliderWidget.setStyle(ProxyStyle()) + + button_minus = QtWidgets.QToolButton() + button_minus.setArrowType(QtCore.Qt.LeftArrow) + button_minus.clicked.connect(self.minusClicked) + + button_plus = QtWidgets.QToolButton() + button_plus.setArrowType(QtCore.Qt.RightArrow) + button_plus.clicked.connect(self.plusClicked) + + layoutSlider.addWidget(button_minus) + layoutSlider.addWidget(self.sliderWidget) + layoutSlider.addWidget(button_plus) + + self.minWidget = QtWidgets.QLineEdit() + self.minWidget.setAlignment(QtCore.Qt.AlignLeft) + self.minWidget.returnPressed.connect(self.minWidgetValueChanged) + self.minWidget.textEdited.connect(lambda: setLineEditBackground( + self.minWidget, 'edited', self._font_size)) + + layoutBottomValues.addWidget(QtWidgets.QLabel("Min")) + layoutBottomValues.addWidget(self.minWidget) + layoutBottomValues.addSpacing(10) + layoutBottomValues.addStretch() + + self.stepWidget = QtWidgets.QLineEdit() + self.stepWidget.setAlignment(QtCore.Qt.AlignCenter) + self.stepWidget.returnPressed.connect(self.stepWidgetValueChanged) + self.stepWidget.textEdited.connect(lambda: setLineEditBackground( + self.stepWidget, 'edited', self._font_size)) + + layoutBottomValues.addWidget(QtWidgets.QLabel("Step")) + layoutBottomValues.addWidget(self.stepWidget) + layoutBottomValues.addStretch() + layoutBottomValues.addSpacing(10) + + self.maxWidget = QtWidgets.QLineEdit() + self.maxWidget.setAlignment(QtCore.Qt.AlignRight) + self.maxWidget.returnPressed.connect(self.maxWidgetValueChanged) + self.maxWidget.textEdited.connect(lambda: setLineEditBackground( + self.maxWidget, 'edited', self._font_size)) + + layoutBottomValues.addWidget(QtWidgets.QLabel("Max")) + layoutBottomValues.addWidget(self.maxWidget) + + self.setCentralWidget(centralWidget) + + self.updateStep() + + self.resize(self.minimumSizeHint()) + + def updateStep(self): + + if self.variable.type in (int, float): + slider_points = 1 + int( + np.floor((self.true_max - self.true_min) / self.true_step)) + self.true_max = self.variable.type( + self.true_step*(slider_points - 1) + self.true_min) + + self.minWidget.setText(f'{self.true_min}') + setLineEditBackground(self.minWidget, 'synced', self._font_size) + self.maxWidget.setText(f'{self.true_max}') + setLineEditBackground(self.maxWidget, 'synced', self._font_size) + self.stepWidget.setText(f'{self.true_step}') + setLineEditBackground(self.stepWidget, 'synced', self._font_size) + + temp = self.slider_instantaneous + self.slider_instantaneous = False + self.sliderWidget.setMinimum(0) + self.sliderWidget.setSingleStep(1) + self.sliderWidget.setTickInterval(1) + self.sliderWidget.setMaximum(slider_points - 1) + self.slider_instantaneous = temp + else: self.badType() + + def updateTrueValue(self, old_true_value: Any): + + if self.variable.type in (int, float): + new_cursor_step = round( + (old_true_value - self.true_min) / self.true_step) + slider_points = 1 + int( + np.floor((self.true_max - self.true_min) / self.true_step)) + if new_cursor_step > (slider_points - 1): + new_cursor_step = slider_points - 1 + elif new_cursor_step < 0: + new_cursor_step = 0 + + temp = self.slider_instantaneous + self.slider_instantaneous = False + self.sliderWidget.setSliderPosition(new_cursor_step) + self.slider_instantaneous = temp + + true_value = self.variable.type( + new_cursor_step*self.true_step + self.true_min) + self.valueWidget.setText(f'{true_value:.{self.precision}g}') + setLineEditBackground(self.valueWidget, 'edited', self._font_size) + else: self.badType() + + def stepWidgetValueChanged(self): + + if self.variable.type in (int, float): + 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 ? + else: + self.updateStep() + self.updateTrueValue(old_true_value) + else: self.badType() + + def minWidgetValueChanged(self): + + if self.variable.type in (int, float): + 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) + else: + self.updateStep() + self.updateTrueValue(old_true_value) + else: self.badType() + + def maxWidgetValueChanged(self): + + if self.variable.type in (int, float): + 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) + else: + self.updateStep() + self.updateTrueValue(old_true_value) + else: self.badType() + + def sliderReleased(self): + """ Do something when the cursor is released """ + if self.variable.type in (int, float): + 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.changed.emit() + self.updateStep() + 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): + 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.changed.emit() + else: + setLineEditBackground(self.valueWidget, 'edited', self._font_size) + # self.updateStep() # Don't use it here, infinite loop leading to crash if set min > max + else: self.badType() + + def instantChanged(self, value): + self.slider_instantaneous = self.instantCheckBox.isChecked() + + def minusClicked(self): + self.sliderWidget.setSliderPosition(self.sliderWidget.value()-1) + if not self.slider_instantaneous: self.sliderReleased() + + def plusClicked(self): + self.sliderWidget.setSliderPosition(self.sliderWidget.value()+1) + if not self.slider_instantaneous: self.sliderReleased() + + def badType(self): + setLineEditBackground(self.valueWidget, 'edited', self._font_size) + setLineEditBackground(self.minWidget, 'edited', self._font_size) + setLineEditBackground(self.stepWidget, 'edited', self._font_size) + setLineEditBackground(self.maxWidget, 'edited', self._font_size) + + def closeEvent(self, event): + """ This function does some steps before the window is really killed """ + if hasattr(self.item, 'clearSlider'): self.item.clearSlider() + + if self.is_main: + 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/variables.py b/autolab/core/gui/variables.py index 7fae6a5b..44edbaaf 100644 --- a/autolab/core/gui/variables.py +++ b/autolab/core/gui/variables.py @@ -8,17 +8,20 @@ import sys import re # import ast -from typing import Any, List, Tuple +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) + array_to_str, dataframe_to_str, clean_string) +from .monitoring.main import Monitor +from .slider import Slider # class AddVarSignal(QtCore.QObject): @@ -64,11 +67,18 @@ def update_allowed_dict() -> 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 __init__(self, var: Any): + def refresh(self, name: str, var: Any): if isinstance(var, Variable): self.raw = var.raw self.value = var.value @@ -80,8 +90,17 @@ def __init__(self, var: Any): 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) - def __call__(self) -> Any: - return self.evaluate() + 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): @@ -89,36 +108,50 @@ def evaluate(self): call = eval(str(value), {}, allowed_dict) self.value = call else: - call = self.raw + call = self.value return call def __repr__(self) -> str: - if type(self.raw) in [np.ndarray]: + if isinstance(self.raw, np.ndarray): raw_value_str = array_to_str(self.raw, threshold=1000000, max_line_width=9000000) - elif type(self.raw) in [pd.DataFrame]: + 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): - for character in r'$*."/\[]:;|, ': name = name.replace(character, '') - assert re.match('^[a-zA-Z_][a-zA-Z0-9_]*$', name) is not None, f"Wrong format for variable '{name}'" - var = Variable(value) if has_eval(value) else value + ''' 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) -> Any: +def get_variable(name: str) -> Union[Variable, None]: + ''' Return Variable with provided name if exists else None ''' return VARIABLES.get(name) -def list_variable() -> List[str]: - return list(VARIABLES.keys()) - - def remove_variable(name: str) -> Any: value = VARIABLES.pop(name) if name in VARIABLES else None update_allowed_dict() @@ -135,7 +168,6 @@ def update_from_config(listVariable: List[Tuple[str, Any]]): 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): @@ -154,9 +186,10 @@ def convert_str_to_data(raw_value: str) -> Any: 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.keys())+list(VARIABLES.keys()): - if key in re.findall(pattern, str(value)): return True - else: return False + 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: @@ -172,18 +205,18 @@ def is_Variable(value: Any): 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(value) + if has_eval(value): value = Variable('temp', value) if is_Variable(value): return value() - else: 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(value) + if has_eval(value): value = Variable('temp', value) if is_Variable(value): return value.value - else: return value + return value class VariablesDialog(QtWidgets.QDialog): @@ -223,8 +256,6 @@ def __init__(self, parent: QtWidgets.QMainWindow, name: str, defaultValue: str): layout.setSpacing(0) layout.setContentsMargins(0,0,0,0) - self.show() - self.exec_ = dialog.exec_ self.textValue = dialog.textValue self.setTextValue = dialog.setTextValue @@ -237,6 +268,7 @@ def variablesButtonClicked(self): self.variablesMenu.variableSignal.connect(self.toggleVariableName) self.variablesMenu.deviceSignal.connect(self.toggleDeviceName) + self.variablesMenu.show() else: self.variablesMenu.refresh() @@ -263,8 +295,7 @@ def toggleDeviceName(self, name): self.toggleVariableName(name) def closeEvent(self, event): - for children in self.findChildren( - QtWidgets.QWidget, options=QtCore.Qt.FindDirectChildrenOnly): + for children in self.findChildren(QtWidgets.QWidget): children.deleteLater() super().closeEvent(event) @@ -279,6 +310,7 @@ 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() @@ -300,6 +332,8 @@ def __init__(self, parent: QtWidgets.QMainWindow = None): 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) @@ -351,8 +385,9 @@ def __init__(self, parent: QtWidgets.QMainWindow = None): self.resize(550, 300) self.refresh() - self.show() + self.monitors = {} + self.sliders = {} # self.timer = QtCore.QTimer(self) # self.timer.setInterval(400) # ms # self.timer.timeout.connect(self.refresh_new) @@ -363,9 +398,13 @@ def __init__(self, parent: QtWidgets.QMainWindow = None): 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) + if hasattr(item, 'name'): self.deviceSignal.emit(item.name) def removeVariableAction(self): for variableItem in self.variablesWidget.selectedItems(): @@ -382,7 +421,7 @@ def removeVariableItem(self, item: QtWidgets.QTreeWidgetItem): def addVariableAction(self): basename = 'var' name = basename - names = list_variable() + names = list(VARIABLES) compt = 0 while True: @@ -393,7 +432,9 @@ def addVariableAction(self): break set_variable(name, 0) - MyQTreeWidgetItem(self.variablesWidget, name, self) # not catched by VARIABLES signal + + 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) @@ -423,11 +464,12 @@ def addVariableAction(self): def refresh(self): self.variablesWidget.clear() - for i, var_name in enumerate(list_variable()): - MyQTreeWidgetItem(self.variablesWidget, var_name, self) + for var_name in VARIABLES: + variable = get_variable(var_name) + MyQTreeWidgetItem(self.variablesWidget, var_name, variable, self) self.devicesWidget.clear() - for i, device_name in enumerate(DEVICES): + for device_name in DEVICES: device = DEVICES[device_name] deviceItem = QtWidgets.QTreeWidgetItem( self.devicesWidget, [device_name]) @@ -445,28 +487,33 @@ def setStatus(self, message: str, timeout: int = 0, stdout: bool = True): def closeEvent(self, event): # self.timer.stop() - if self.gui is not None and hasattr(self.gui, 'clearVariablesMenu'): + if hasattr(self.gui, 'clearVariablesMenu'): self.gui.clearVariablesMenu() - for children in self.findChildren( - QtWidgets.QWidget, options=QtCore.Qt.FindDirectChildrenOnly): + 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, gui): + def __init__(self, itemParent, name, variable, gui): super().__init__(itemParent, ['', name]) self.itemParent = itemParent self.gui = gui self.name = name - - raw_value = get_variable(name) - self.raw_value = raw_value + self.variable = variable nameWidget = QtWidgets.QLineEdit() nameWidget.setText(name) @@ -506,13 +553,66 @@ def __init__(self, itemParent, name, gui): 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 list_variable(): + if new_name in VARIABLES: self.gui.setStatus( f"Error: {new_name} already exist!", 10000, False) return None @@ -521,22 +621,21 @@ def renameVariable(self): new_name = new_name.replace(character, '') try: - set_variable(new_name, get_variable(self.name)) + rename_variable(self.name, new_name) except Exception as e: self.gui.setStatus(f'Error: {e}', 10000, False) else: - remove_variable(self.name) 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.raw_value + raw_value = self.variable.raw - if type(raw_value) in [np.ndarray]: + if isinstance(raw_value, np.ndarray): raw_value_str = array_to_str(raw_value) - elif type(raw_value) in [pd.DataFrame]: + elif isinstance(raw_value, pd.DataFrame): raw_value_str = dataframe_to_str(raw_value) else: raw_value_str = str(raw_value) @@ -544,7 +643,7 @@ def refresh_rawValue(self): self.rawValueWidget.setText(raw_value_str) setLineEditBackground(self.rawValueWidget, 'synced') - if isinstance(raw_value, Variable) and has_variable(raw_value): # OPTIMIZE: use hide and show instead but doesn't hide on instantiation + 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') @@ -558,13 +657,11 @@ def refresh_rawValue(self): self.actionButtonWidget = None def refresh_value(self): - raw_value = self.raw_value - - value = eval_safely(raw_value) + value = self.variable.value - if type(value) in [np.ndarray]: + if isinstance(value, np.ndarray): value_str = array_to_str(value) - elif type(value) in [pd.DataFrame]: + elif isinstance(value, pd.DataFrame): value_str = dataframe_to_str(value) else: value_str = str(value) @@ -594,18 +691,17 @@ def changeRawValue(self): except Exception as e: self.gui.setStatus(f'Error: {e}', 10000) else: - self.raw_value = get_variable(name) self.refresh_rawValue() self.refresh_value() def convertVariableClicked(self): - try: value = eval_variable(self.raw_value) + try: value = eval_variable(self.variable) except Exception as e: self.gui.setStatus(f'Error: {e}', 10000, False) else: - if type(value) in [np.ndarray]: + if isinstance(value, np.ndarray): value_str = array_to_str(value) - elif type(value) in [pd.DataFrame]: + elif isinstance(value, pd.DataFrame): value_str = dataframe_to_str(value) else: value_str = str(value) diff --git a/autolab/core/infos.py b/autolab/core/infos.py index 032de614..3fd52392 100644 --- a/autolab/core/infos.py +++ b/autolab/core/infos.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import sys from . import config from . import drivers @@ -17,16 +18,16 @@ def list_drivers(_print: bool = True) -> str: s = '\n' s += f'{len(drivers.DRIVERS_PATHS)} drivers found\n\n' - for i, source_name in enumerate(paths.DRIVER_SOURCES.keys()): - sub_driver_list = sorted([key for key in drivers.DRIVERS_PATHS.keys() if drivers.DRIVERS_PATHS[key]['source']==source_name]) - s += f'Drivers in {paths.DRIVER_SOURCES[source_name]}:\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]) + s += f'Drivers in {source}:\n' if len(sub_driver_list) > 0: txt_list = [[f' - {driver_name}', f'({drivers.get_driver_category(driver_name)})'] for driver_name in sub_driver_list] s += utilities.two_columns(txt_list) + '\n\n' else: - if i+1 == len(paths.DRIVER_SOURCES.keys()): + if (i + 1) == len(paths.DRIVER_SOURCES): s += ' \n\n' else: s += ' (or overwritten)\n\n' @@ -34,7 +35,7 @@ def list_drivers(_print: bool = True) -> str: if _print: print(s) return None - else: return s + return s def list_devices(_print: bool = True) -> str: @@ -54,7 +55,7 @@ def list_devices(_print: bool = True) -> str: if _print: print(s) return None - else: return s + return s def infos(_print: bool = True) -> str: @@ -66,7 +67,7 @@ def infos(_print: bool = True) -> str: if _print: print(s) return None - else: return s + return s # ============================================================================= # DRIVERS @@ -79,7 +80,11 @@ def config_help(driver_name: str, _print: bool = True, _parser: bool = False) -> except: pass # Load list of all parameters - driver_lib = drivers.load_driver_lib(driver_name) + try: + driver_lib = drivers.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'] = {} @@ -98,10 +103,9 @@ def config_help(driver_name: str, _print: bool = True, _parser: bool = False) -> mess += utilities.emphasize(submess, sign='=') + '\n' # Connections types - c_option='' - if _parser: c_option='(-C option)' - mess += f'\nAvailable connections types {c_option}:\n' - for connection in params['connection'].keys(): + c_option=' (-C option)' if _parser else '' + mess += f'\nAvailable connections types{c_option}:\n' + for connection in params['connection']: mess += f' - {connection}\n' mess += '\n' @@ -109,44 +113,44 @@ def config_help(driver_name: str, _print: bool = True, _parser: bool = False) -> if hasattr(drivers.get_driver_class(driver_lib), 'slot_config'): mess += 'Available modules:\n' modules = drivers.get_module_names(driver_lib) - for module in modules : + for module in modules: moduleClass = drivers.get_module_class(driver_lib, module) mess += f' - {module}' - if hasattr(moduleClass,'category'): mess += f' ({moduleClass.category})' + if hasattr(moduleClass, 'category'): mess += f' ({moduleClass.category})' mess += '\n' mess += '\n' # Example of a devices_config.ini section - mess += '\n\n' + utilities.underline( + mess += '\n' + utilities.underline( 'Saving a Device configuration in devices_config.ini:') + '\n' - for conn in params['connection'].keys(): + for conn in params['connection']: 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(): + for arg, value in params['connection'][conn].items(): mess += f" {arg} = {value}\n" - for arg,value in params['other'].items(): + for arg, value in params['other'].items(): mess += f" {arg} = {value}\n" # Example of get_driver mess += '\n' + utilities.underline('Loading a Driver:') + '\n\n' - for conn in params['connection'].keys(): + for conn in params['connection']: if not _parser: args_str = f"'{params['driver']}', connection='{conn}'" - for arg,value in params['connection'][conn].items(): + for arg, value in params['connection'][conn].items(): args_str += f", {arg}='{value}'" - for arg,value in params['other'].items(): - if type(value) is str: + for arg, value in params['other'].items(): + if isinstance(value, str): args_str += f", {arg}='{value}'" else: args_str += f", {arg}={value}" mess += f" a = autolab.get_driver({args_str})\n" - else : + else: args_str = f"-D {params['driver']} -C {conn} " for arg,value in params['connection'][conn].items(): - if arg == 'address' : args_str += f"-A {value} " - if arg == 'port' : args_str += f"-P {value} " - if len(params['other'])>0 : args_str += '-O ' + if arg == 'address': args_str += f"-A {value} " + if arg == 'port': args_str += f"-P {value} " + 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" @@ -156,10 +160,10 @@ def config_help(driver_name: str, _print: bool = True, _parser: bool = False) -> 'Loading a Device configured in devices_config.ini:') + '\n\n' if not _parser: mess += f" a = autolab.get_device('my_{params['driver']}')" - else : + else: mess += f" autolab device -D my_{params['driver']} -e element -v value \n" if _print: print(mess) return None - else: return mess + return mess diff --git a/autolab/core/repository.py b/autolab/core/repository.py index 162e7991..10377269 100644 --- a/autolab/core/repository.py +++ b/autolab/core/repository.py @@ -58,7 +58,7 @@ def _download_repo(url: str, output_dir: str): progress_bar.update(len(data)) file.write(data) - if total_size != 0 and progress_bar.n != total_size: + if total_size not in (0, progress_bar.n): raise RuntimeError("Could not download file") @@ -122,8 +122,8 @@ def _check_empty_driver_folder(): install_drivers() -def install_drivers(*repo_url: Union[str, Tuple[str, str]], skip_input=False, - experimental_feature=False): +def install_drivers(*repo_url: Union[None, str, Tuple[str, str]], + skip_input=False, experimental_feature=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. @@ -131,7 +131,7 @@ def install_drivers(*repo_url: Union[str, Tuple[str, str]], skip_input=False, Also install mandatory drivers (system, dummy, plotter) from official repo.""" if experimental_feature: _install_drivers_custom() - return + return None # Download mandatory drivers official_folder = paths.DRIVER_SOURCES['official'] @@ -149,15 +149,15 @@ def install_drivers(*repo_url: Union[str, Tuple[str, str]], skip_input=False, # create list of tuple with tuple being ('path to install', 'url to download') if len(repo_url) == 0: - list_repo_tuple = [(key, paths.DRIVER_REPOSITORY[key]) for key in paths.DRIVER_REPOSITORY.keys()] # This variable can be modified in autolab_config.ini + list_repo_tuple = list(paths.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): - if type(repo_url_tmp) is str: + if isinstance(repo_url_tmp, str): list_repo_tuple[i] = (official_folder, repo_url_tmp) - elif type(repo_url_tmp) is dict: + elif isinstance(repo_url_tmp, dict): raise TypeError("Error: This option has been removed, use tuple instead with (folder, url)") - elif type(repo_url_tmp) is not tuple: + elif not isinstance(repo_url_tmp, tuple): raise TypeError(f'repo_url must be str or tuple. Given {type(repo_url_tmp)}') assert len(list_repo_tuple[i]) == 2, "Expect (folder, url), got wrong length: {len(list_repo_tuple[i])} for {list_repo_tuple[i]}" @@ -178,10 +178,10 @@ def install_drivers(*repo_url: Union[str, Tuple[str, str]], skip_input=False, ans = input_wrap(f'Install drivers from {drivers_url} to {drivers_folder}? [default:yes] > ') if ans.strip().lower() == 'no': continue - else: - _download_repo(repo_zip_url, temp_repo_zip) - _unzip_repo(temp_repo_zip, drivers_folder) - os.remove(temp_repo_zip) + + _download_repo(repo_zip_url, temp_repo_zip) + _unzip_repo(temp_repo_zip, drivers_folder) + os.remove(temp_repo_zip) os.rmdir(temp_repo_folder) # Update available drivers @@ -229,10 +229,14 @@ def _download_driver(url, driver_name, output_dir, _print=True): # 'HTTP Error 403: rate limit exceeded' due to too much download if don't have github account download(driver_url, output_dir=output_dir, _print=_print) except: # if use Exception, crash python when having error - print(f"Error when downloading driver '{driver_name}'", file=sys.stderr) + e = f"Error when downloading driver '{driver_name}'" + if _print: + print(e, file=sys.stderr) + else: + return e -def _install_drivers_custom(_print=True): +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. """ @@ -243,7 +247,7 @@ def _install_drivers_custom(_print=True): list_driver = _get_drivers_list_from_github(official_url) except: print(f'Cannot access {official_url}, skip installation') - return + return None try: from qtpy import QtWidgets, QtGui @@ -252,7 +256,7 @@ def _install_drivers_custom(_print=True): if _print: print(f"Drivers will be downloaded to {official_folder}") - for i, driver_name in enumerate(list_driver): + 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 @@ -262,19 +266,22 @@ def _install_drivers_custom(_print=True): class DriverInstaller(QtWidgets.QMainWindow): - def __init__(self, url, list_driver, OUTPUT_DIR): + 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 - QtWidgets.QMainWindow.__init__(self) + super().__init__(parent) - self.setWindowTitle("Autolab Driver Installer") + self.setWindowTitle("Autolab - Driver Installer") self.setFocus() self.activateWindow() + self.statusBar = self.statusBar() + centralWidget = QtWidgets.QWidget() self.setCentralWidget(centralWidget) @@ -299,8 +306,10 @@ def __init__(self, url, list_driver, OUTPUT_DIR): 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.horizontalHeader().setSectionResizeMode( + 0, QtWidgets.QHeaderView.ResizeToContents) + tab.setSizePolicy(QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding) tab.setSizeAdjustPolicy(tab.AdjustToContents) # Init checkBox @@ -326,27 +335,50 @@ def masterCheckBoxChanged(self): for checkBox in self.list_checkBox: checkBox.setChecked(state) - def closeEvent(self,event): - """ This function does some steps before the window is really killed """ - QtWidgets.QApplication.quit() # close the interface - 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) - 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 self.gui is None: + QtWidgets.QApplication.quit() # close the app - for driver in list_driver_to_download: - _download_driver(self.url, driver, self.OUTPUT_DIR, _print=_print) - print('Done!') + 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 _print: print("Open driver installer") - app = QtWidgets.QApplication.instance() - if app is None: app = QtWidgets.QApplication([]) + 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) + driverInstaller = DriverInstaller( + official_url, list_driver, official_folder, parent=parent) driverInstaller.show() - app.exec_() + 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 3ebf08bc..9460c967 100644 --- a/autolab/core/server.py +++ b/autolab/core/server.py @@ -39,7 +39,7 @@ def write(self, object): class ClientThread(threading.Thread, Driver_SOCKET): def __init__(self, client_socket, server): - threading.Thread.__init__(self) + super().__init__() self.socket = client_socket self.server = server self.stop_flag = threading.Event() diff --git a/autolab/core/utilities.py b/autolab/core/utilities.py index 80fc499e..eb9048fe 100644 --- a/autolab/core/utilities.py +++ b/autolab/core/utilities.py @@ -4,7 +4,15 @@ @author: qchat """ -from typing import Any, List, Tuple +from typing import Any, List +import re +import ast +from io import StringIO +import platform +import os + +import numpy as np +import pandas as pd SUPPORTED_EXTENSION = "Text Files (*.txt);; Supported text Files (*.txt;*.csv;*.dat);; All Files (*)" @@ -38,7 +46,7 @@ def two_columns(txt_list: List[str]) -> str: ''' Returns a string of the form: txt[0] txt[1] with a minimal spacing between the first character of txt1 and txt2 ''' - spacing = max([len(txt[0]) for txt in txt_list]) + 5 + spacing = max(len(txt[0]) for txt in txt_list) + 5 return '\n'.join([txt[0] + ' '*(spacing-len(txt[0])) + txt[1] for txt in txt_list]) @@ -54,6 +62,7 @@ def boolean(value: Any) -> bool: def str_to_value(s: str) -> Any: + ''' Tries to convert string to int, float, bool or None in this order ''' try: int_val = int(s) if str(int_val) == s: return int_val @@ -67,22 +76,21 @@ def str_to_value(s: str) -> Any: if s.lower() in ('true', 'false'): return s.lower() == 'true' - if s == 'None': - s = None + if s == 'None': s = None # If none of the above works, return the string itself return s -def create_array(value: Any) -> Any: # actually -> np.ndarray - import numpy as np - try: array = np.array(value, ndmin=1, dtype=float) # check validity of array - except ValueError as e: raise ValueError(e) - else: array = np.array(value, ndmin=1) # ndim=1 to avoid having float if 0D +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 -def str_to_array(s: str) -> Any: # actually -> np.ndarray - import re, ast +def str_to_array(s: str) -> np.ndarray: + ''' Convert string to a numpy array ''' if "," in s: ls = re.sub(r'\s,+', ',', s) else: ls = re.sub(r'\s+', ',', s) test = ast.literal_eval(ls) @@ -91,40 +99,35 @@ def str_to_array(s: str) -> Any: # actually -> np.ndarray def array_to_str(value: Any, threshold: int = None, max_line_width: int = None) -> str: - import numpy as np + ''' Convert a numpy array to a string ''' return np.array2string(np.array(value), separator=',', suppress_small=True, threshold=threshold, max_line_width=max_line_width) -def str_to_dataframe(s: str) -> Any: - from io import StringIO - import pandas as pd +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") return df -def dataframe_to_str(value: Any, threshold=1000) -> str: - import pandas as pd - if isinstance(value, str) and value == '': - value = None +def dataframe_to_str(value: pd.DataFrame, threshold=1000) -> str: + ''' Convert a pandas DataFrame to a string ''' + if isinstance(value, str) and value == '': value = None 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): - import platform - import os + ''' Opens a file using the platform specific command ''' system = platform.system() if system == 'Windows': os.startfile(filename) elif system == 'Linux': os.system(f'gedit "{filename}"') elif system == 'Darwin': os.system(f'open "{filename}"') -def formatData(data: Any) -> Any: # actually -> pd.DataFrame but don't want to import it in file +def formatData(data: Any) -> pd.DataFrame: """ Format data to DataFrame """ - import pandas as pd - try: data = pd.DataFrame(data) except ValueError: data = pd.DataFrame([data]) @@ -148,196 +151,6 @@ def formatData(data: Any) -> Any: # actually -> pd.DataFrame but don't want to i return data -def pyqtgraph_image() -> Any: # actually -> pyqtgraph.imageview.ImageView.ImageView but don't want to import it in file - import numpy as np - import pyqtgraph as pg - from qtpy import QtWidgets, QtCore - - class myImageView(pg.ImageView): - def __init__(self, *args, **kwargs): - pg.ImageView.__init__(self, *args, **kwargs) - - # update tick background on gradient change - self.ui.histogram.gradient.sigGradientChanged.connect(self.update_ticks) - - self.figLineROI, self.axLineROI = pyqtgraph_fig_ax() - self.figLineROI.hide() - self.plot = self.axLineROI.plot([], [], pen='k') - - self.lineROI = pg.LineSegmentROI([[0, 100], [100, 100]], pen='r') - self.lineROI.sigRegionChanged.connect(self.updateLineROI) - self.lineROI.hide() - - self.addItem(self.lineROI) - - # update slice when change frame number in scanner - self.timeLine.sigPositionChanged.connect(self.updateLineROI) - - slice_pushButton = QtWidgets.QPushButton('Slice') - slice_pushButton.state = False - slice_pushButton.setMinimumSize(0, 23) - slice_pushButton.setMaximumSize(75, 23) - slice_pushButton.clicked.connect(self.slice_pushButtonClicked) - self.slice_pushButton = slice_pushButton - - horizontalLayoutButton = QtWidgets.QHBoxLayout() - horizontalLayoutButton.setSpacing(0) - horizontalLayoutButton.setContentsMargins(0,0,0,0) - horizontalLayoutButton.addStretch() - horizontalLayoutButton.addWidget(self.slice_pushButton) - - widgetButton = QtWidgets.QWidget() - widgetButton.setLayout(horizontalLayoutButton) - - verticalLayoutImageButton = QtWidgets.QVBoxLayout() - verticalLayoutImageButton.setSpacing(0) - verticalLayoutImageButton.setContentsMargins(0,0,0,0) - verticalLayoutImageButton.addWidget(self) - verticalLayoutImageButton.addWidget(widgetButton) - - widgetImageButton = QtWidgets.QWidget() - widgetImageButton.setLayout(verticalLayoutImageButton) - - splitter = QtWidgets.QSplitter() - splitter.setOrientation(QtCore.Qt.Vertical) - splitter.addWidget(widgetImageButton) - splitter.addWidget(self.figLineROI) - splitter.setSizes([500,500]) - - verticalLayoutMain = QtWidgets.QVBoxLayout() - verticalLayoutMain.setSpacing(0) - verticalLayoutMain.setContentsMargins(0,0,0,0) - verticalLayoutMain.addWidget(splitter) - - centralWidget = QtWidgets.QWidget() - centralWidget.setLayout(verticalLayoutMain) - self.centralWidget = centralWidget - - def update_ticks(self): - for tick in self.ui.histogram.gradient.ticks: - tick.pen = pg.mkPen(pg.getConfigOption("foreground")) - tick.currentPen = tick.pen - tick.hoverPen = pg.mkPen(200, 120, 0) - - def slice_pushButtonClicked(self): - self.slice_pushButton.state = not self.slice_pushButton.state - self.display_line() - - def display_line(self): - if self.slice_pushButton.state: - self.figLineROI.show() - self.lineROI.show() - self.updateLineROI() - else: - self.figLineROI.hide() - self.lineROI.hide() - - def show(self): - self.centralWidget.show() - - def hide(self): - self.centralWidget.hide() - - def roiChanged(self): - pg.ImageView.roiChanged(self) - for c in self.roiCurves: - c.setPen(pg.getConfigOption("foreground")) - - def setImage(self, *args, **kwargs): - pg.ImageView.setImage(self, *args, **kwargs) - self.updateLineROI() - - def updateLineROI(self): - if self.slice_pushButton.state: - img = self.image if self.image.ndim == 2 else self.image[self.currentIndex] - img = np.array([img]) - - x,y = (0, 1) if self.imageItem.axisOrder == 'col-major' else (1, 0) - d2 = self.lineROI.getArrayRegion(img, self.imageItem, axes=(x+1, y+1)) - self.plot.setData(d2[0]) - - def close(self): - self.figLineROI.deleteLater() - super().close() - - imageView = myImageView() - - return imageView, imageView.centralWidget - - -def pyqtgraph_fig_ax() -> Tuple[Any, Any]: # actually -> Tuple[pyqtgraph.widgets.PlotWidget.PlotWidget, pyqtgraph.graphicsItems.PlotItem.PlotItem.PlotItem] but don't want to import it in file - """ Return a formated fig and ax pyqtgraph for a basic plot """ - import pyqtgraph as pg - from pyqtgraph import QtGui - - # Configure and initialize the figure in the GUI - fig = pg.PlotWidget() - ax = fig.getPlotItem() - - ax.setLabel("bottom", ' ', **{'color':0.4, 'font-size': '12pt'}) - ax.setLabel("left", ' ', **{'color':0.4, 'font-size': '12pt'}) - - # Set your custom font for both axes - my_font = QtGui.QFont('Arial', 12) - my_font_tick = QtGui.QFont('Arial', 10) - ax.getAxis("bottom").label.setFont(my_font) - ax.getAxis("left").label.setFont(my_font) - 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)) - - ## Text label for the data coordinates of the mouse pointer - dataLabel = pg.LabelItem(color='k', parent=ax.getAxis('bottom')) - dataLabel.anchor(itemPos=(1,1), parentPos=(1,1), offset=(0,0)) - - def mouseMoved(point): - """ This function marks the position of the cursor in data coordinates""" - vb = ax.getViewBox() - mousePoint = vb.mapSceneToView(point) - l = f'x = {mousePoint.x():g}, y = {mousePoint.y():g}' - dataLabel.setText(l) - - # data reader signal connection - ax.scene().sigMouseMoved.connect(mouseMoved) - - return fig, ax - - -CHECK_ONCE = True - - -def qt_object_exists(QtObject) -> bool: - """ Return True if object exists (not deleted). - Check if use pyqt5, pyqt6, pyside2 or pyside6 to use correct implementation - """ - global CHECK_ONCE - import os - QT_API = os.environ.get("QT_API") - - try: - if QT_API in ("pyqt5", "pyqt6"): - import sip - return not sip.isdeleted(QtObject) - elif QT_API == "pyside2": - import shiboken2 - return shiboken2.isValid(QtObject) - elif QT_API =="pyside6": - import shiboken6 - return shiboken6.isValid(QtObject) - else: - raise ModuleNotFoundError(f"QT_API '{QT_API}' unknown") - except ModuleNotFoundError as e: - if CHECK_ONCE: - print(f"Warning: {e}. Skip check if Qt Object not deleted.") - CHECK_ONCE = False - return True - - def input_wrap(*args): """ Wrap input function to avoid crash with Spyder using Qtconsole=5.3 """ input_allowed = True diff --git a/autolab/core/web.py b/autolab/core/web.py index 1c129f37..79141603 100644 --- a/autolab/core/web.py +++ b/autolab/core/web.py @@ -11,21 +11,26 @@ import inspect +project_url = 'https://github.com/autolab-project/autolab' +drivers_url = 'https://github.com/autolab-project/autolab-drivers' +doc_url = 'https://autolab.readthedocs.io' + + def report(): """ Open the github link to open an issue: https://github.com/autolab-project/autolab/issues """ - webbrowser.open('https://github.com/autolab-project/autolab/issues') + webbrowser.open(project_url+'/issues') -def doc(online="default"): +def doc(online: bool = "default"): """ By default try to open the online doc and if no internet connection, open the local pdf documentation. Can open online or offline documentation by using True or False.""" if online == "default": - if has_internet(): webbrowser.open('https://autolab.readthedocs.io') + if has_internet(): webbrowser.open(doc_url) else: print("No internet connection found. Open local pdf documentation instead") doc_offline() - elif online: webbrowser.open('https://autolab.readthedocs.io') + elif online: webbrowser.open(doc_url) elif not online: doc_offline() diff --git a/autolab/version.txt b/autolab/version.txt index 9d508c1a..7fb48b5e 100644 --- a/autolab/version.txt +++ b/autolab/version.txt @@ -1 +1 @@ -2.0b2 \ No newline at end of file +2.0rc1 diff --git a/docs/about.rst b/docs/about.rst index 1df73a96..58168328 100644 --- a/docs/about.rst +++ b/docs/about.rst @@ -8,12 +8,14 @@ This Python package has been created in 2019 by `Quentin Chateiller `_. +In 2023, Mathieu Jeannin from the `Odin team `_ joined the adventure. -Thanks to Maxime, Giuseppe and Guilhem for their contributions. +Thanks to Maxime, Giuseppe, Guilhem, Victor and Hamza for their contributions. -**You find this package useful?** We would be really grateful if you could help us to improve its visibility ! You can: +**You find this package useful?** We would be really grateful if you could help us to improve its visibility! You can: * Add a star on the `GitHub page of this project `_ * Spread the word around you diff --git a/docs/conf.py b/docs/conf.py index 1ad2e2ce..3ab86ad5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,8 +30,8 @@ # -- Project information ----------------------------------------------------- project = 'Autolab' -copyright = '2023, Quentin Chateiller & Bruno Garbin & Jonathan Peltier, (C2N-CNRS)' -author = 'Quentin Chateiller & Bruno Garbin & Jonathan Peltier' +copyright = '2024, Quentin Chateiller & Bruno Garbin & Jonathan Peltier & Mathieu Jeannin, (C2N-CNRS)' +author = 'Quentin Chateiller & Bruno Garbin & Jonathan Peltier & Mathieu Jeannin' # The full version, including alpha/beta/rc tags release = version diff --git a/docs/gui/scanning.rst b/docs/gui/scanning.rst index a3da03d9..f20892b4 100644 --- a/docs/gui/scanning.rst +++ b/docs/gui/scanning.rst @@ -25,21 +25,23 @@ It it possible to add extra parameters to a recipe by right cliking on the top o 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 is optional, a recipe is executed once if no parameter is given. -It is possible to set a custom array by right cliking on the parameter frame and selecting **Custom values**. 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 checking the **Log** check box. +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. +It is also possible to use a custom array for the parameter using the **Custom** option. Steps ----- The third step is to configure recipe steps, that will be executed for each value of parameters. There are four kinds of recipe steps: - * **Measure the value of a Variable**. Right click on the desired *Variable* in the control panel and select **Measure in scan recipe** to append this step to the recipe. - * **Set the value of a Variable**. Right click on the desired *Variable* in the control panel and select **Set value in scan recipe** to append this step to the recipe. The variable must be numerical (integer, float or boolean value). To set the value, right click on the recipe step and click **Set value**. The user can also directly double click on the value to change it. - * **Execute an Action**. Right click on the desired *Action* in the control panel and select **Do in scan recipe** to append this step to the recipe. + * **Measure** the value of a Variable. Right click on the desired *Variable* in the control panel and select **Measure in scan recipe** to append this step to the recipe. + * **Set** the value of a Variable. Right click on the desired *Variable* in the control panel and select **Set value in scan recipe** to append this step to the recipe. The variable must be numerical (integer, float or boolean value). To set the value, right click on the recipe step and click **Set value**. The user can also directly double click on the value to change it. + * **Execute** an Action. Right click on the desired *Action* in the control panel and select **Do in scan recipe** to append this step to the recipe. Each recipe step must have a unique name. To change the name of a recipe step, right click on it and select **Rename**, or directly double click on the name to change it. This name will be used in the data files. @@ -56,6 +58,7 @@ Once the configuration of a scan is finished, the user can save it locally in a 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. +Alternatively, recently opened configuration files can be accessed via the **Import recent configuration** menu. Scan execution ############## @@ -88,4 +91,6 @@ If the user has created several recipes in a scan, it is possible to display its 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. +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. + .. image:: multiple_recipes.png diff --git a/docs/high_level.rst b/docs/high_level.rst index bc7095de..eab3e4a8 100644 --- a/docs/high_level.rst +++ b/docs/high_level.rst @@ -62,7 +62,7 @@ To close properly the connection to the instrument, simply call its the function >>> lightSource.close() -To close all devices connection (not drivers) at once you can use the Autolab close function. +To close the connection to all instruments (devices, not drivers) at once, you can use Autolab's ``close`` function. .. 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 a 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 sweep the wavelength of a light source, and measure the power of a power meter: .. code-block:: python diff --git a/docs/installation.rst b/docs/installation.rst index aa38d3b9..acea8654 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -16,11 +16,16 @@ Additional required packages (installed automatically with Autolab): * pandas * pyvisa * python-vxi11 +* qtpy +* pyqtgraph +* requests +* tqdm +* comtypes Autolab package --------------- -This project is hosted in the global python repository PyPi at the following address : https://pypi.org/project/autolab/ +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: .. code-block:: none diff --git a/docs/local_config.rst b/docs/local_config.rst index aa7354cc..963b0398 100644 --- a/docs/local_config.rst +++ b/docs/local_config.rst @@ -9,9 +9,10 @@ More precisely, this configuration is stored in a local configuration file named .. code-block:: python - INFORMATION: The local directory of AUTOLAB has been created: C:\Users\\autolab - INFORMATION: The devices configuration file devices_config.ini has been created: C:\Users\\autolab\devices_config.ini - + 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. @@ -65,3 +66,9 @@ Save the configuration file, and go back to Autolab. You don't need to restart A .. code-block:: python >>> laserSource = autolab.get_device('my_tunics') + +You can also use Autolab's ``add_device`` function to open up a minimalist graphical interface, allowing you to configure an instrument in a more user-friendly way. + +.. code-block:: python + + >>> autolab.add_device() diff --git a/docs/low_level/create_driver.rst b/docs/low_level/create_driver.rst index 9343bc5f..275ebe31 100644 --- a/docs/low_level/create_driver.rst +++ b/docs/low_level/create_driver.rst @@ -53,7 +53,7 @@ The Driver is organized in several `python class _\_utilities.py* file) ########################################################################### -This file should be present in the driver directory (*\_\.py*). +This optional file can be added to the driver directory (*\_\.py*). -Here is a commented example of the file *\_\_utilities.py*, further explained bellow: +Here is a commented example of the file *\_\_utilities.py*, further explained below: .. code-block:: python diff --git a/docs/low_level/index.rst b/docs/low_level/index.rst index ee3d528f..6ba44550 100644 --- a/docs/low_level/index.rst +++ b/docs/low_level/index.rst @@ -6,7 +6,7 @@ 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 `_ +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. The first part of this section explains how to configure and open a **Driver**, and how to use it to communicate with your instrument. diff --git a/docs/low_level/open_and_use.rst b/docs/low_level/open_and_use.rst index b30c548d..b50c0d6b 100644 --- a/docs/low_level/open_and_use.rst +++ b/docs/low_level/open_and_use.rst @@ -67,7 +67,7 @@ You can get the list of the available functions by calling the function ``autola 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 a 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 sweep the wavelength of a light source, and measure the power of a power meter: .. code-block:: python diff --git a/docs/shell/connection.rst b/docs/shell/connection.rst index 2e570033..bb47d923 100644 --- a/docs/shell/connection.rst +++ b/docs/shell/connection.rst @@ -7,7 +7,7 @@ The two sections that follow are equivalent for the commands ``autolab driver`` Getting help ============ -Three helps are configured (device or driver may be used equally in the lines bellow): +Three helps are configured (device or driver may be used equally in the lines below): 1) Basic help of the commands autolab driver/device: