From 38a5ee7f78326f551b17b582bfce71fcdc65d1c9 Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Thu, 2 May 2024 22:52:51 +0200 Subject: [PATCH 01/29] clean code - Should not change anything (unless I messed up while cherry-picking changes) --- autolab/__init__.py | 6 +- autolab/core/config.py | 26 +- autolab/core/drivers.py | 16 +- autolab/core/elements.py | 56 ++-- autolab/core/gui/GUI_utilities.py | 192 +++++++++++++- autolab/core/gui/controlcenter/main.py | 30 ++- autolab/core/gui/controlcenter/slider.py | 19 +- autolab/core/gui/controlcenter/thread.py | 8 +- autolab/core/gui/controlcenter/treewidgets.py | 14 +- autolab/core/gui/monitoring/data.py | 6 +- autolab/core/gui/monitoring/figure.py | 9 +- autolab/core/gui/monitoring/monitor.py | 2 +- autolab/core/gui/plotting/data.py | 2 +- autolab/core/gui/plotting/figure.py | 4 +- autolab/core/gui/plotting/main.py | 21 +- autolab/core/gui/plotting/thread.py | 2 +- autolab/core/gui/plotting/treewidgets.py | 16 +- autolab/core/gui/scanning/config.py | 14 +- autolab/core/gui/scanning/customWidgets.py | 7 +- autolab/core/gui/scanning/data.py | 96 +++---- autolab/core/gui/scanning/display.py | 7 +- autolab/core/gui/scanning/figure.py | 32 +-- autolab/core/gui/scanning/main.py | 7 +- autolab/core/gui/scanning/parameter.py | 4 +- autolab/core/gui/scanning/recipe.py | 1 + autolab/core/gui/scanning/scan.py | 4 +- autolab/core/gui/variables.py | 41 ++- autolab/core/infos.py | 38 +-- autolab/core/repository.py | 22 +- autolab/core/utilities.py | 241 ++---------------- autolab/core/web.py | 2 +- 31 files changed, 472 insertions(+), 473 deletions(-) diff --git a/autolab/__init__.py b/autolab/__init__.py index f3f35323..e7843284 100644 --- a/autolab/__init__.py +++ b/autolab/__init__.py @@ -31,7 +31,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() @@ -68,10 +68,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/core/config.py b/autolab/core/config.py index 09cc8f8f..635c400d 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(): + for key, dic in section_dic.items(): if key not in conf.keys(): - conf[key] = str(dic[key]) + 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(): + for key, dic in section_dic.items(): if key not in conf.keys(): - conf[key] = str(dic[key]) + conf[key] = str(dic) else: - conf = dic + conf = section_dic plotter_config[section_key] = conf diff --git a/autolab/core/drivers.py b/autolab/core/drivers.py index 4dc8b6b7..5c17136a 100644 --- a/autolab/core/drivers.py +++ b/autolab/core/drivers.py @@ -181,7 +181,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,7 +189,7 @@ 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()) @@ -198,10 +198,9 @@ def get_instance_methods(instance: Type) -> Type: # 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: + try: # explicit to avoid visa and inspect.getmembers issue + for name, _ in inspect.getmembers(instance_vars[key], inspect.ismethod): + if name != '__init__': attr = getattr(getattr(instance, key), name) args = list(inspect.signature(attr).parameters.keys()) methods.append([f'{key}.{name}', args]) @@ -224,8 +223,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 +254,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..a8c144ca 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,7 +31,7 @@ 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): @@ -37,9 +40,6 @@ def __init__(self, parent: Type, config: dict): Element.__init__(self, parent, 'variable', config['name']) - import numpy as np - import pandas as pd - # Type assert 'type' in config.keys(), 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" @@ -56,7 +56,7 @@ def __init__(self, parent: Type, config: dict): 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" + 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 @@ -88,9 +88,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): @@ -114,7 +111,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 +133,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,15 +143,15 @@ 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): @@ -163,9 +160,6 @@ def __init__(self, parent: Type, config: dict): Element.__init__(self, parent, 'action', config['name']) - import pandas as pd - import numpy as np - # Do function assert 'do' in config.keys(), f"Action {self.address()}: Missing 'do' function" assert inspect.ismethod(config['do']), f"Action {self.address()} configuration: Do parameter must be a function" @@ -192,7 +186,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 +198,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,7 +219,7 @@ 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( @@ -340,13 +334,13 @@ 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[List[str]]: """ Returns the structure of the module as a list containing each element address associated with its type as [['address1', 'variable'], ['address2', 'action'],...] """ structure = [] @@ -366,7 +360,7 @@ def sub_hierarchy(self, level: int = 0) -> List[Tuple[str, str, int]]: ''' h = [] - from .devices import Device + 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]) diff --git a/autolab/core/gui/GUI_utilities.py b/autolab/core/gui/GUI_utilities.py index 04bc3679..77f3c46e 100644 --- a/autolab/core/gui/GUI_utilities.py +++ b/autolab/core/gui/GUI_utilities.py @@ -5,8 +5,12 @@ @author: jonathan """ +from typing import Tuple +import os -from qtpy import QtWidgets +import numpy as np +from qtpy import QtWidgets, QtCore, QtGui +import pyqtgraph as pg from ..config import get_GUI_config @@ -34,3 +38,189 @@ 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 + + +def pyqtgraph_fig_ax() -> Tuple[pg.PlotWidget, pg.PlotItem]: + """ Return a formated fig and ax pyqtgraph for a basic plot """ + + # 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 + + +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]: + + imageView = myImageView() + + return imageView, imageView.centralWidget diff --git a/autolab/core/gui/controlcenter/main.py b/autolab/core/gui/controlcenter/main.py index 510d2b17..aa50224f 100644 --- a/autolab/core/gui/controlcenter/main.py +++ b/autolab/core/gui/controlcenter/main.py @@ -13,6 +13,8 @@ import uuid from typing import Any, Type +import numpy as np +import pandas as pd from qtpy import QtCore, QtWidgets, QtGui from qtpy.QtWidgets import QApplication @@ -179,12 +181,12 @@ 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') # Timer for device instantiation @@ -194,7 +196,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 +234,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 +258,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 """ @@ -410,15 +409,18 @@ def openPlotter(self): self.plotter.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) self.plotter.activateWindow() - def openAutolabConfig(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) diff --git a/autolab/core/gui/controlcenter/slider.py b/autolab/core/gui/controlcenter/slider.py index 9ee10d48..2f75ff9c 100644 --- a/autolab/core/gui/controlcenter/slider.py +++ b/autolab/core/gui/controlcenter/slider.py @@ -122,7 +122,6 @@ def __init__(self, item: QtWidgets.QTreeWidgetItem): self.updateStep() self.resize(self.minimumSizeHint()) - self.show() def updateStep(self): @@ -177,9 +176,9 @@ def stepWidgetValueChanged(self): 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) + else: + self.updateStep() + self.updateTrueValue(old_true_value) def minWidgetValueChanged(self): @@ -189,9 +188,9 @@ def minWidgetValueChanged(self): 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) + else: + self.updateStep() + self.updateTrueValue(old_true_value) def maxWidgetValueChanged(self): @@ -201,9 +200,9 @@ def maxWidgetValueChanged(self): 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) + else: + self.updateStep() + self.updateTrueValue(old_true_value) def sliderReleased(self): """ Do something when the cursor is released """ diff --git a/autolab/core/gui/controlcenter/thread.py b/autolab/core/gui/controlcenter/thread.py index e187ea1a..386178c1 100644 --- a/autolab/core/gui/controlcenter/thread.py +++ b/autolab/core/gui/controlcenter/thread.py @@ -9,9 +9,9 @@ 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: @@ -57,8 +57,8 @@ def start(self, item: QtWidgets.QTreeWidgetItem, intType: str, value = None): tid = id(thread) self.threads[tid] = thread thread.endSignal.connect( - lambda error, x=tid : self.threadFinished(x, error)) - thread.finished.connect(lambda x=tid : self.delete(x)) + lambda error, x=tid: self.threadFinished(x, error)) + thread.finished.connect(lambda x=tid: self.delete(x)) # Starting thread thread.start() @@ -142,7 +142,7 @@ 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)}' + 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)) diff --git a/autolab/core/gui/controlcenter/treewidgets.py b/autolab/core/gui/controlcenter/treewidgets.py index 40ff3f5d..41b830bb 100644 --- a/autolab/core/gui/controlcenter/treewidgets.py +++ b/autolab/core/gui/controlcenter/treewidgets.py @@ -17,9 +17,10 @@ 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) @@ -33,7 +34,7 @@ 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']) @@ -173,9 +174,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 @@ -407,9 +408,9 @@ 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) 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 @@ -562,6 +563,7 @@ def openSlider(self): # 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)].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..ecbdd2d4 100644 --- a/autolab/core/gui/monitoring/data.py +++ b/autolab/core/gui/monitoring/data.py @@ -44,14 +44,14 @@ 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 + y = point[1] - if type(y) is np.ndarray: + if isinstance(y, 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: + elif isinstance(y, pd.DataFrame): self._addArray(y.values.T) else: self._addPoint(point) diff --git a/autolab/core/gui/monitoring/figure.py b/autolab/core/gui/monitoring/figure.py index e3cf0daa..480e7fd6 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() diff --git a/autolab/core/gui/monitoring/monitor.py b/autolab/core/gui/monitoring/monitor.py index 12686e70..0b1d2f01 100644 --- a/autolab/core/gui/monitoring/monitor.py +++ b/autolab/core/gui/monitoring/monitor.py @@ -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..5a649e05 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 diff --git a/autolab/core/gui/plotting/main.py b/autolab/core/gui/plotting/main.py index 457bb5f4..9f33ac8d 100644 --- a/autolab/core/gui/plotting/main.py +++ b/autolab/core/gui/plotting/main.py @@ -36,14 +36,14 @@ def __init__(self,parent, plotter): 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) @@ -60,8 +60,8 @@ 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 @@ -142,7 +142,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 +161,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 """ diff --git a/autolab/core/gui/plotting/thread.py b/autolab/core/gui/plotting/thread.py index 7fcfffae..c912a0a8 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 : diff --git a/autolab/core/gui/plotting/treewidgets.py b/autolab/core/gui/plotting/treewidgets.py index aee6019e..a7fcbc27 100644 --- a/autolab/core/gui/plotting/treewidgets.py +++ b/autolab/core/gui/plotting/treewidgets.py @@ -14,30 +14,28 @@ 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) + QtWidgets.QTreeWidgetItem.__init__(self, 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 diff --git a/autolab/core/gui/scanning/config.py b/autolab/core/gui/scanning/config.py index c4f84fc2..697907d6 100644 --- a/autolab/core/gui/scanning/config.py +++ b/autolab/core/gui/scanning/config.py @@ -535,7 +535,7 @@ def recipeNameList(self): def getLinkedRecipe(self) -> Dict[str, list]: """ 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] @@ -573,7 +573,7 @@ def getRecipeLink(self, recipe_name: str) -> List[str]: """ 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]: @@ -720,7 +720,7 @@ 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()): @@ -820,14 +820,14 @@ 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) @@ -1065,7 +1065,7 @@ def load_configPars(self, configPars: dict, append: bool = False): if 'variables' in configPars: var_dict = configPars['variables'] - add_vars = list() + add_vars = [] for var_name in var_dict.keys(): raw_value = var_dict[var_name] raw_value = variables.convert_str_to_data(raw_value) diff --git a/autolab/core/gui/scanning/customWidgets.py b/autolab/core/gui/scanning/customWidgets.py index 5fdcf2f9..eaaa14b5 100644 --- a/autolab/core/gui/scanning/customWidgets.py +++ b/autolab/core/gui/scanning/customWidgets.py @@ -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) @@ -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) @@ -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..70d40e3a 100644 --- a/autolab/core/gui/scanning/data.py +++ b/autolab/core/gui/scanning/data.py @@ -11,6 +11,7 @@ import shutil import tempfile import sys +import random from typing import List import numpy as np @@ -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() @@ -95,24 +96,21 @@ def getLastSelectedDataset(self) -> List[dict]: 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 """ 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')) else: - import random tempFolderPath = 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) @@ -176,7 +174,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,27 +209,25 @@ 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 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 data.T.shape[0] == 2)): self.gui.variable_x_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_y_comboBox.clear() - # self.gui.setStatus(f"Error, can't plot data {data_name}: {e}", 10000, False) # already catched by getData return None resultNamesList = [] @@ -252,7 +248,8 @@ def updateDisplayableResults(self): 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())] + AllItems = [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 self.gui.variable_x_comboBox.clear() @@ -266,8 +263,11 @@ def updateDisplayableResults(self): self.gui.variable_y_comboBox.addItems(resultNamesList) # 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) + if variable_y_index != -1: + self.gui.variable_y_comboBox.setCurrentIndex(variable_y_index) + return None class Dataset(): @@ -275,10 +275,10 @@ class Dataset(): def __init__(self, tempFolderPath: str, recipe_name: str, config: dict, save_temp: bool = True): - self.all_data_temp = list() + self.all_data_temp = [] self.recipe_name = recipe_name - self.list_array = list() - self.dictListDataFrame = dict() + self.list_array = [] + self.dictListDataFrame = {} self.tempFolderPath = tempFolderPath self.new = True self.save_temp = save_temp @@ -320,8 +320,8 @@ def getData(self, varList: list, data_name: str = "Scan", else: data = self.dictListDataFrame[data_name][dataID] - if ((data is not None) - and (type(data) is not str) + if (data is not None + and not isinstance(data, str) and (len(data.T.shape) == 1 or data.T.shape[0] == 2)): data = utilities.formatData(data) else: # Image @@ -330,12 +330,12 @@ def getData(self, varList: list, data_name: str = "Scan", 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 + + return None def save(self, filename: str): """ This function saved the dataset in the provided path """ - dataset_folder, extension = os.path.splitext(filename) + dataset_folder = os.path.splitext(filename)[0] data_name = os.path.join(self.tempFolderPath, 'data.txt') if os.path.exists(data_name): @@ -361,14 +361,14 @@ def save(self, filename: str): 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,20 +377,18 @@ def addPoint(self, dataPoint: OrderedDict): simpledata = OrderedDict() simpledata['id'] = ID - for resultName in dataPoint.keys(): - result = dataPoint[resultName] + for resultName, result in dataPoint.items(): + + if resultName == 0: continue # skip first result which is recipe_name - if resultName == 0: # skip first result which is recipe_name - continue + 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_param if step['name']==resultName] + element_list = [step['element'] for step in self.list_step 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 + # should always find element in lists above # If the result is displayable (numerical), keep it in memory if element is None or element.type in [int, float, bool]: @@ -418,9 +416,13 @@ def addPoint(self, dataPoint: OrderedDict): 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.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) 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..9211b0b6 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) @@ -61,7 +60,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..a60ba29d 100644 --- a/autolab/core/gui/scanning/figure.py +++ b/autolab/core/gui/scanning/figure.py @@ -10,13 +10,12 @@ 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 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 ..icons import icons -from ... import utilities class FigureManager: @@ -30,9 +29,9 @@ def __init__(self, gui: QtWidgets.QMainWindow): 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() @@ -78,9 +77,9 @@ def clearMenuID(self): # 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.menuBoolList = [] # 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 = [] + self.menuActionList = [] self.nbCheckBoxMenuID = 0 def addCheckBox2MenuID(self, name_ID: int): @@ -152,7 +151,7 @@ def dataframe_comboBoxCurrentChanged(self): if data_name == "Scan" or self.fig.isHidden(): self.gui.toolButton.hide() else: - self.gui.toolButton.show() + self.gui.toolButton.show() def updateDataframe_comboBox(self): # Executed each time the queue is read @@ -165,8 +164,8 @@ 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] + i for i, val in sub_dataset.dictListDataFrame.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())] @@ -267,7 +266,7 @@ def reloadData(self): if temp_data is not None: break else: return None - if len(data) != 0 and type(data[0]) is np.ndarray: # to avoid errors + if len(data) != 0 and isinstance(data[0], np.ndarray): # to avoid errors image_data = np.empty((len(data), *temp_data.shape)) for i in range(len(data)): @@ -276,16 +275,16 @@ 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 + if isinstance(subdata, np.ndarray): # is image self.fig.hide() self.figMap.show() self.gui.frame_axis.hide() @@ -318,6 +317,7 @@ def reloadData(self): alpha = (true_nbtraces - (len(data) - 1 - i)) / true_nbtraces # Plot + # OPTIMIZE: known issue but from pyqtgraph, error if use FFT on one point curve = self.ax.plot(x, y, symbol='x', symbolPen=color, symbolSize=10, pen=color, symbolBrush=color) curve.setAlpha(alpha, False) self.curves.append(curve) diff --git a/autolab/core/gui/scanning/main.py b/autolab/core/gui/scanning/main.py index 0cf69e99..8908ea97 100644 --- a/autolab/core/gui/scanning/main.py +++ b/autolab/core/gui/scanning/main.py @@ -134,7 +134,7 @@ 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() @@ -163,6 +163,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 +216,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): @@ -288,7 +289,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 @@ -305,6 +305,7 @@ def closeEvent(self, event): main_dialog = ImportDialog(self, self._append) + main_dialog.show() once_or_append = True while once_or_append: diff --git a/autolab/core/gui/scanning/parameter.py b/autolab/core/gui/scanning/parameter.py index f5318ead..f97059d9 100644 --- a/autolab/core/gui/scanning/parameter.py +++ b/autolab/core/gui/scanning/parameter.py @@ -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): @@ -647,7 +647,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..935da98d 100644 --- a/autolab/core/gui/scanning/recipe.py +++ b/autolab/core/gui/scanning/recipe.py @@ -284,6 +284,7 @@ def setStepValue(self, name: str): 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..a2576c09 100644 --- a/autolab/core/gui/scanning/scan.py +++ b/autolab/core/gui/scanning/scan.py @@ -58,7 +58,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 @@ -221,7 +221,7 @@ def __init__(self, queue: Queue, config: dict): 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() diff --git a/autolab/core/gui/variables.py b/autolab/core/gui/variables.py index 7fae6a5b..33ccb236 100644 --- a/autolab/core/gui/variables.py +++ b/autolab/core/gui/variables.py @@ -8,7 +8,7 @@ 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 @@ -94,9 +94,9 @@ def evaluate(self): 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) @@ -104,6 +104,7 @@ def __repr__(self) -> str: def set_variable(name: str, value: Any): + ''' Create or modify a Variable with provided name and value ''' 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 @@ -111,7 +112,8 @@ def set_variable(name: str, value: Any): 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) @@ -154,9 +156,9 @@ 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)): def has_eval(value: Any) -> bool: @@ -183,7 +185,7 @@ def eval_safely(value: Any) -> Any: if has_eval(value): value = Variable(value) if is_Variable(value): return value.value - else: return value + return value class VariablesDialog(QtWidgets.QDialog): @@ -223,8 +225,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 +237,7 @@ def variablesButtonClicked(self): self.variablesMenu.variableSignal.connect(self.toggleVariableName) self.variablesMenu.deviceSignal.connect(self.toggleDeviceName) + self.variablesMenu.show() else: self.variablesMenu.refresh() @@ -351,7 +352,6 @@ def __init__(self, parent: QtWidgets.QMainWindow = None): self.resize(550, 300) self.refresh() - self.show() # self.timer = QtCore.QTimer(self) # self.timer.setInterval(400) # ms @@ -364,8 +364,7 @@ def variableActivated(self, item: QtWidgets.QTreeWidgetItem): self.variableSignal.emit(item.name) 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 +381,7 @@ def removeVariableItem(self, item: QtWidgets.QTreeWidgetItem): def addVariableAction(self): basename = 'var' name = basename - names = list_variable() + names = list(VARIABLES) compt = 0 while True: @@ -423,11 +422,11 @@ 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: 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]) @@ -512,7 +511,7 @@ def renameVariable(self): 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 @@ -534,9 +533,9 @@ def renameVariable(self): def refresh_rawValue(self): raw_value = self.raw_value - 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) @@ -562,9 +561,9 @@ def refresh_value(self): value = eval_safely(raw_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) @@ -603,9 +602,9 @@ def convertVariableClicked(self): 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..e2bc280c 100644 --- a/autolab/core/infos.py +++ b/autolab/core/infos.py @@ -17,16 +17,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 +34,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 +54,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 +66,7 @@ def infos(_print: bool = True) -> str: if _print: print(s) return None - else: return s + return s # ============================================================================= # DRIVERS @@ -109,7 +109,7 @@ 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})' @@ -123,9 +123,9 @@ def config_help(driver_name: str, _print: bool = True, _parser: bool = False) -> 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 @@ -133,20 +133,20 @@ def config_help(driver_name: str, _print: bool = True, _parser: bool = False) -> for conn in params['connection'].keys(): 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 +156,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..1bcd322a 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") @@ -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 @@ -252,7 +252,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 @@ -326,7 +326,7 @@ def masterCheckBoxChanged(self): for checkBox in self.list_checkBox: checkBox.setChecked(state) - def closeEvent(self,event): + def closeEvent(self, event): """ This function does some steps before the window is really killed """ QtWidgets.QApplication.quit() # close the interface 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..094f0a7f 100644 --- a/autolab/core/web.py +++ b/autolab/core/web.py @@ -16,7 +16,7 @@ def report(): webbrowser.open('https://github.com/autolab-project/autolab/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.""" From 9ed2e6691f3f6ee05e3ba5d549860ab2f38154ea Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Thu, 2 May 2024 23:11:25 +0200 Subject: [PATCH 02/29] several fixes + changes to Variable - readme: add pypi and doc badges - fixe: remove possibility to import a config using 'recent import' during a scan - Change names for Autolab - Scanner, Plotter and Monitor - fixe bug in pyside2 due to len(layout) instead of layout.count() - fixe bug if using custom parameter with one point (now use create_array to force array dimension) - can now have a monitor switching from monitoring a float in time to monitor an array (only useful for Variable if change definition) Modify Variable: - now force the use of Variable (can't use $eval.ID anymore but should use $eval:ID() instead) - can monitor Variable with right click - fixe impossibility to use $eval in variable with currently non-existing device or variable name - now raise error if start scan with bad parameter $eval - A Variable is not longer recreated everytime a set_variable is called (or rename), useful to keep track of variable (monitor for example) --- README.md | 3 + autolab/core/gui/monitoring/data.py | 12 +++ autolab/core/gui/monitoring/main.py | 24 ++--- autolab/core/gui/plotting/main.py | 2 +- autolab/core/gui/scanning/config.py | 54 +++++------ autolab/core/gui/scanning/data.py | 4 +- autolab/core/gui/scanning/main.py | 4 +- autolab/core/gui/scanning/parameter.py | 5 +- autolab/core/gui/scanning/scan.py | 10 ++- autolab/core/gui/variables.py | 119 +++++++++++++++++++------ docs/conf.py | 2 +- 11 files changed, 160 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 74345517..f54abe85 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +[![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__ diff --git a/autolab/core/gui/monitoring/data.py b/autolab/core/gui/monitoring/data.py index ecbdd2d4..7447770e 100644 --- a/autolab/core/gui/monitoring/data.py +++ b/autolab/core/gui/monitoring/data.py @@ -46,6 +46,11 @@ def addPoint(self, point: Tuple[Any, Any]): """ This function either replace list by array or add point to list depending on datapoint type """ y = point[1] + 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) == 1 or y.T.shape[0] == 2: self._addArray(y.T) @@ -56,6 +61,11 @@ def addPoint(self, point: Tuple[Any, Any]): else: self._addPoint(point) + self.gui.figureManager.setLabel('x', 'Time [s]') + self.gui.windowLength_lineEdit.show() + self.gui.windowLength_label.show() + self.gui.dataDisplay.show() + def _addImage(self, image: np.ndarray): """ Add image to ylist data as np.ndarray """ self.xlist = None @@ -79,6 +89,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 diff --git a/autolab/core/gui/monitoring/main.py b/autolab/core/gui/monitoring/main.py index 76e4900b..c9108a0c 100644 --- a/autolab/core/gui/monitoring/main.py +++ b/autolab/core/gui/monitoring/main.py @@ -32,7 +32,7 @@ def __init__(self, item: QtWidgets.QTreeWidgetItem): QtWidgets.QMainWindow.__init__(self) 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: diff --git a/autolab/core/gui/plotting/main.py b/autolab/core/gui/plotting/main.py index 9f33ac8d..0305f9e9 100644 --- a/autolab/core/gui/plotting/main.py +++ b/autolab/core/gui/plotting/main.py @@ -69,7 +69,7 @@ def __init__(self,mainGui): QtWidgets.QMainWindow.__init__(self) ui_path = os.path.join(os.path.dirname(__file__),'interface.ui') uic.loadUi(ui_path,self) - self.setWindowTitle("AUTOLAB Plotter") + self.setWindowTitle("AUTOLAB - Plotter") self.setWindowIcon(QtGui.QIcon(icons['plotter'])) # Loading of the different centers diff --git a/autolab/core/gui/scanning/config.py b/autolab/core/gui/scanning/config.py index 697907d6..347b19de 100644 --- a/autolab/core/gui/scanning/config.py +++ b/autolab/core/gui/scanning/config.py @@ -825,17 +825,18 @@ def create_configPars(self) -> dict: var_to_save = {} for var_name in names_var_to_save: - 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( + 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, threshold=1000000, max_line_width=9000000) - elif isinstance(value, pd.DataFrame): valueStr = dataframe_to_str( + elif isinstance(value_raw, 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}' + 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 +846,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 """ diff --git a/autolab/core/gui/scanning/data.py b/autolab/core/gui/scanning/data.py index 70d40e3a..6268b4fd 100644 --- a/autolab/core/gui/scanning/data.py +++ b/autolab/core/gui/scanning/data.py @@ -129,7 +129,9 @@ def newDataset(self, config: dict): 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) + else: + values = utilities.create_array(values) + nbpts *= len(values) else: nbpts *= len(parameter['values']) else: nbpts *= parameter['nbpts'] diff --git a/autolab/core/gui/scanning/main.py b/autolab/core/gui/scanning/main.py index 8908ea97..fe3d3835 100644 --- a/autolab/core/gui/scanning/main.py +++ b/autolab/core/gui/scanning/main.py @@ -34,7 +34,7 @@ def __init__(self, mainGui: QtWidgets.QMainWindow): QtWidgets.QMainWindow.__init__(self) 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) @@ -225,7 +225,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) diff --git a/autolab/core/gui/scanning/parameter.py b/autolab/core/gui/scanning/parameter.py index f97059d9..f997e33f 100644 --- a/autolab/core/gui/scanning/parameter.py +++ b/autolab/core/gui/scanning/parameter.py @@ -614,8 +614,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: diff --git a/autolab/core/gui/scanning/scan.py b/autolab/core/gui/scanning/scan.py index a2576c09..2d40f89a 100644 --- a/autolab/core/gui/scanning/scan.py +++ b/autolab/core/gui/scanning/scan.py @@ -98,6 +98,7 @@ 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) @@ -144,6 +145,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 @@ -239,8 +241,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'] diff --git a/autolab/core/gui/variables.py b/autolab/core/gui/variables.py index 33ccb236..6d972e37 100644 --- a/autolab/core/gui/variables.py +++ b/autolab/core/gui/variables.py @@ -15,11 +15,12 @@ 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) - +from .monitoring.main import Monitor # class AddVarSignal(QtCore.QObject): # add = QtCore.Signal(object, object) @@ -64,11 +65,15 @@ def update_allowed_dict() -> dict: allowed_dict = update_allowed_dict() - +# 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(): - def __init__(self, var: Any): + def __init__(self, name: str, var: Any): + + self.refresh(name, var) + + def refresh(self, name: str, var: Any): if isinstance(var, Variable): self.raw = var.raw self.value = var.value @@ -80,6 +85,10 @@ 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) + self.name = name + self.unit = None + self.address = lambda: name + def __call__(self) -> Any: return self.evaluate() @@ -89,7 +98,7 @@ def evaluate(self): call = eval(str(value), {}, allowed_dict) self.value = call else: - call = self.raw + call = self.value return call @@ -103,11 +112,28 @@ def __repr__(self) -> str: return raw_value_str +def rename_variable(name, new_name): + var = remove_variable(name) + assert var is not None + set_variable(new_name, var) + + def set_variable(name: str, value: Any): ''' Create or modify a Variable with provided name and value ''' 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 + + 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() @@ -117,10 +143,6 @@ def get_variable(name: str) -> Union[Variable, 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() @@ -156,9 +178,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_]*)?' - 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: @@ -174,15 +197,15 @@ 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 return value @@ -301,6 +324,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) @@ -353,6 +378,7 @@ def __init__(self, parent: QtWidgets.QMainWindow = None): self.resize(550, 300) self.refresh() + self.monitors = {} # self.timer = QtCore.QTimer(self) # self.timer.setInterval(400) # ms # self.timer.timeout.connect(self.refresh_new) @@ -363,6 +389,11 @@ 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 recipe """ + 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) @@ -392,7 +423,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) @@ -422,8 +455,9 @@ def addVariableAction(self): def refresh(self): self.variablesWidget.clear() - 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 device_name in DEVICES: @@ -447,6 +481,9 @@ def closeEvent(self, event): if self.gui is not None and hasattr(self.gui, 'clearVariablesMenu'): self.gui.clearVariablesMenu() + for monitor in list(self.monitors.values()): + monitor.close() + for children in self.findChildren( QtWidgets.QWidget, options=QtCore.Qt.FindDirectChildrenOnly): children.deleteLater() @@ -456,16 +493,14 @@ def closeEvent(self, event): 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) @@ -505,6 +540,36 @@ 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))) + + choice = menu.exec_(self.gui.variablesWidget.viewport().mapToGlobal(position)) + if choice == monitoringAction: + self.openMonitor() + + def openMonitor(self): + """ This function open the monitor associated to this variable. """ + # If the monitor is not already running, create one + if id(self) not in self.gui.monitors.keys(): + self.gui.monitors[id(self)] = Monitor(self) + self.gui.monitors[id(self)].show() + # If the monitor is already running, just make as the front window + else: + monitor = self.gui.monitors[id(self)] + monitor.setWindowState( + monitor.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) + monitor.activateWindow() + + def clearMonitor(self): + """ This clear monitor instances reference when quitted """ + if id(self) in self.gui.monitors.keys(): + self.gui.monitors.pop(id(self)) + def renameVariable(self): new_name = self.nameWidget.text() if new_name == self.name: @@ -520,18 +585,17 @@ 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 isinstance(raw_value, np.ndarray): raw_value_str = array_to_str(raw_value) @@ -543,7 +607,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') @@ -557,9 +621,7 @@ 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 isinstance(value, np.ndarray): value_str = array_to_str(value) @@ -593,12 +655,11 @@ 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: diff --git a/docs/conf.py b/docs/conf.py index 1ad2e2ce..7b4db4ce 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,7 +30,7 @@ # -- Project information ----------------------------------------------------- project = 'Autolab' -copyright = '2023, Quentin Chateiller & Bruno Garbin & Jonathan Peltier, (C2N-CNRS)' +copyright = '2024, Quentin Chateiller & Bruno Garbin & Jonathan Peltier, (C2N-CNRS)' author = 'Quentin Chateiller & Bruno Garbin & Jonathan Peltier' # The full version, including alpha/beta/rc tags From 226263a8a3960083743ddad9b25a278678a08ed0 Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Sat, 4 May 2024 20:43:07 +0200 Subject: [PATCH 03/29] Add about window +docstring & cleanning - add About window showing versions used, authors, licence and project urls - Add authors information - code cleanning --- README.md | 2 +- autolab/__init__.py | 2 +- autolab/core/devices.py | 4 +- autolab/core/elements.py | 6 +- autolab/core/gui/controlcenter/main.py | 166 +++++++++++++++++- autolab/core/gui/controlcenter/slider.py | 2 +- autolab/core/gui/controlcenter/thread.py | 2 +- autolab/core/gui/controlcenter/treewidgets.py | 18 +- autolab/core/gui/monitoring/main.py | 2 +- autolab/core/gui/monitoring/monitor.py | 2 +- autolab/core/gui/plotting/main.py | 12 +- autolab/core/gui/plotting/thread.py | 7 +- autolab/core/gui/plotting/treewidgets.py | 27 ++- autolab/core/gui/scanning/customWidgets.py | 6 +- autolab/core/gui/scanning/main.py | 2 +- autolab/core/gui/scanning/scan.py | 2 +- autolab/core/repository.py | 2 +- autolab/core/server.py | 2 +- autolab/core/web.py | 11 +- autolab/version.txt | 2 +- docs/about.rst | 3 +- docs/conf.py | 4 +- 22 files changed, 221 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index f54abe85..70a564f1 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ __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 e7843284..87ef8ea5 100644 --- a/autolab/__init__.py +++ b/autolab/__init__.py @@ -7,7 +7,7 @@ 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/core/devices.py b/autolab/core/devices.py index bd8f2751..d1bae523 100644 --- a/autolab/core/devices.py +++ b/autolab/core/devices.py @@ -27,8 +27,8 @@ 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 """ diff --git a/autolab/core/elements.py b/autolab/core/elements.py index a8c144ca..0d2b77ab 100644 --- a/autolab/core/elements.py +++ b/autolab/core/elements.py @@ -38,7 +38,7 @@ class Variable(Element): def __init__(self, parent: Type, config: dict): - Element.__init__(self, parent, 'variable', config['name']) + super().__init__(parent, 'variable', config['name']) # Type assert 'type' in config.keys(), f"Variable {self.address()}: Missing variable type" @@ -158,7 +158,7 @@ class Action(Element): def __init__(self, parent: Type, config: dict): - Element.__init__(self, parent, 'action', config['name']) + super().__init__(parent, 'action', config['name']) # Do function assert 'do' in config.keys(), f"Action {self.address()}: Missing 'do' function" @@ -249,7 +249,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 = {} diff --git a/autolab/core/gui/controlcenter/main.py b/autolab/core/gui/controlcenter/main.py index aa50224f..97e9e4ea 100644 --- a/autolab/core/gui/controlcenter/main.py +++ b/autolab/core/gui/controlcenter/main.py @@ -7,6 +7,7 @@ """ +import platform import sys import queue import time @@ -15,8 +16,10 @@ 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 @@ -25,6 +28,8 @@ from ..variables import VARIABLES from ..icons import icons from ... import devices, web, paths, config, utilities +from .... import __version__ +from ...web import project_url, drivers_url, doc_url class OutputWrapper(QtCore.QObject): @@ -69,7 +74,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") @@ -85,7 +90,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): @@ -132,6 +137,7 @@ def startDrag(self, event): # Scanner / Monitors self.scanner = None self.plotter = None + self.about = None self.monitors = {} self.sliders = {} self.threadDeviceDict = {} @@ -189,6 +195,13 @@ def startDrag(self, event): 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 @@ -380,7 +393,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) @@ -394,7 +407,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) @@ -409,6 +422,20 @@ def openPlotter(self): self.plotter.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) self.plotter.activateWindow() + 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() + self.activateWindow() # Put main window back to the front + # If the scanner 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() + @staticmethod def openAutolabConfig(): """ Open the Autolab configuration file """ @@ -465,6 +492,10 @@ 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 closeEvent(self, event): """ This function does some steps before the window is really killed """ if self.scanner is not None: @@ -478,6 +509,9 @@ def closeEvent(self, event): self.plotter.close() + if self.about is not None: + self.about.close() + monitors = list(self.monitors.values()) for monitor in monitors: monitor.close() @@ -498,7 +532,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) @@ -516,3 +549,126 @@ def closeEvent(self, event): super().closeEvent(event) VARIABLES.clear() # reset variables defined in the GUI + + +class AboutWindow(QtWidgets.QMainWindow): + + def __init__(self, parent: QtWidgets.QMainWindow = None): + + super().__init__(parent) + self.mainGui = parent + self.setWindowTitle('Autolab - About') + + 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 + self.mainGui.clearAbout() + + +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 index 2f75ff9c..584d72be 100644 --- a/autolab/core/gui/controlcenter/slider.py +++ b/autolab/core/gui/controlcenter/slider.py @@ -18,7 +18,7 @@ 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) + super().__init__() self.item = item self.resize(self.minimumSizeHint()) self.setWindowTitle(self.item.variable.address()) diff --git a/autolab/core/gui/controlcenter/thread.py b/autolab/core/gui/controlcenter/thread.py index 386178c1..030313bc 100644 --- a/autolab/core/gui/controlcenter/thread.py +++ b/autolab/core/gui/controlcenter/thread.py @@ -93,7 +93,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 diff --git a/autolab/core/gui/controlcenter/treewidgets.py b/autolab/core/gui/controlcenter/treewidgets.py index 41b830bb..ab1b5f50 100644 --- a/autolab/core/gui/controlcenter/treewidgets.py +++ b/autolab/core/gui/controlcenter/treewidgets.py @@ -37,9 +37,9 @@ def __init__(self, itemParent, name, gui): 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) @@ -99,7 +99,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 @@ -291,10 +291,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() @@ -319,22 +319,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() diff --git a/autolab/core/gui/monitoring/main.py b/autolab/core/gui/monitoring/main.py index c9108a0c..efaa7b32 100644 --- a/autolab/core/gui/monitoring/main.py +++ b/autolab/core/gui/monitoring/main.py @@ -29,7 +29,7 @@ def __init__(self, item: QtWidgets.QTreeWidgetItem): 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.address()}") diff --git a/autolab/core/gui/monitoring/monitor.py b/autolab/core/gui/monitoring/monitor.py index 0b1d2f01..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 diff --git a/autolab/core/gui/plotting/main.py b/autolab/core/gui/plotting/main.py index 0305f9e9..e1b7d190 100644 --- a/autolab/core/gui/plotting/main.py +++ b/autolab/core/gui/plotting/main.py @@ -27,9 +27,9 @@ 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) @@ -56,7 +56,7 @@ def dragLeaveEvent(self, event): class Plotter(QtWidgets.QMainWindow): - def __init__(self,mainGui): + def __init__(self, mainGui): self.active = False self.mainGui = mainGui @@ -66,9 +66,9 @@ def __init__(self,mainGui): 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) + 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'])) diff --git a/autolab/core/gui/plotting/thread.py b/autolab/core/gui/plotting/thread.py index c912a0a8..93a0fed2 100644 --- a/autolab/core/gui/plotting/thread.py +++ b/autolab/core/gui/plotting/thread.py @@ -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 a7fcbc27..279ebe36 100644 --- a/autolab/core/gui/plotting/treewidgets.py +++ b/autolab/core/gui/plotting/treewidgets.py @@ -24,7 +24,7 @@ class TreeWidgetItemModule(QtWidgets.QTreeWidgetItem): def __init__(self, itemParent, name, nickname, gui): - QtWidgets.QTreeWidgetItem.__init__(self, itemParent, [nickname, 'Module']) + super().__init__(itemParent, [nickname, 'Module']) self.setTextAlignment(1, QtCore.Qt.AlignHCenter) self.name = name self.nickname = nickname @@ -89,17 +89,16 @@ def menu(self,position): 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 @@ -188,18 +187,16 @@ def execute(self): 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 : + if variable.unit is not None: self.displayName += f' ({variable.unit})' - QtWidgets.QTreeWidgetItem.__init__(self,itemParent,[self.displayName,'Variable']) - self.setTextAlignment(1,QtCore.Qt.AlignHCenter) + super().__init__(itemParent, [self.displayName, 'Variable']) + self.setTextAlignment(1, QtCore.Qt.AlignHCenter) self.gui = gui @@ -247,16 +244,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() diff --git a/autolab/core/gui/scanning/customWidgets.py b/autolab/core/gui/scanning/customWidgets.py index eaaa14b5..28bc491b 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: @@ -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) @@ -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): diff --git a/autolab/core/gui/scanning/main.py b/autolab/core/gui/scanning/main.py index fe3d3835..343f189a 100644 --- a/autolab/core/gui/scanning/main.py +++ b/autolab/core/gui/scanning/main.py @@ -31,7 +31,7 @@ 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") diff --git a/autolab/core/gui/scanning/scan.py b/autolab/core/gui/scanning/scan.py index 2d40f89a..47f6170f 100644 --- a/autolab/core/gui/scanning/scan.py +++ b/autolab/core/gui/scanning/scan.py @@ -214,7 +214,7 @@ 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 diff --git a/autolab/core/repository.py b/autolab/core/repository.py index 1bcd322a..b90f4750 100644 --- a/autolab/core/repository.py +++ b/autolab/core/repository.py @@ -269,7 +269,7 @@ def __init__(self, url, list_driver, OUTPUT_DIR): self.list_driver = list_driver self.OUTPUT_DIR = OUTPUT_DIR - QtWidgets.QMainWindow.__init__(self) + super().__init__() self.setWindowTitle("Autolab Driver Installer") self.setFocus() 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/web.py b/autolab/core/web.py index 094f0a7f..79141603 100644 --- a/autolab/core/web.py +++ b/autolab/core/web.py @@ -11,9 +11,14 @@ 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: bool = "default"): @@ -21,11 +26,11 @@ def doc(online: bool = "default"): 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..eab3d464 100644 --- a/docs/about.rst +++ b/docs/about.rst @@ -9,8 +9,9 @@ The first developments of the core, the GUI, and the drivers started initially i Bruno arrived in the team in 2019, providing a new set of Python drivers from its previous laboratory. In order to propose a Python alternative for the automation of scientific experiments in our research team, we finally merged our works in a Python package based on a standardized and robust driver architecture, that makes drivers easy to use and to write by the community. From 2020 onwards, development was pursued by Jonathan Peltier (PhD Student) from the `Minaphot team `_. +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: diff --git a/docs/conf.py b/docs/conf.py index 7b4db4ce..3ab86ad5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,8 +30,8 @@ # -- Project information ----------------------------------------------------- project = 'Autolab' -copyright = '2024, 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 From a96d8ebfa511e780714d0421606f0bed5aaa8f5c Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Mon, 6 May 2024 21:27:48 +0200 Subject: [PATCH 04/29] User can interact with scan - added new action parameter unit: 'user-input' creating a QInputDialog (see Driver dummy for example) - Using an action with a parameter and with the unit in ('open-file', 'save-file', 'user-input') will open the corresponding dialog box and freeze the scan until the user has answer. 'user-input' can be used to pause the scan to give the user infinite time to do things in his set-up before continuing with the scan. - remove parameter option from findChildren has it doesn't exists in pyside2 and was not needed --- autolab/core/elements.py | 18 +- autolab/core/gui/controlcenter/main.py | 6 +- autolab/core/gui/controlcenter/treewidgets.py | 26 ++- autolab/core/gui/monitoring/main.py | 3 +- autolab/core/gui/plotting/main.py | 3 +- autolab/core/gui/plotting/treewidgets.py | 195 ++++++++---------- autolab/core/gui/scanning/display.py | 4 +- autolab/core/gui/scanning/main.py | 8 +- autolab/core/gui/scanning/scan.py | 137 +++++++++++- autolab/core/gui/variables.py | 8 +- 10 files changed, 264 insertions(+), 144 deletions(-) diff --git a/autolab/core/elements.py b/autolab/core/elements.py index 0d2b77ab..2d17f634 100644 --- a/autolab/core/elements.py +++ b/autolab/core/elements.py @@ -223,12 +223,12 @@ def __call__(self, value: Any = None) -> Any: 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) @@ -237,7 +237,19 @@ def __call__(self, value: Any = None) -> Any: 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: diff --git a/autolab/core/gui/controlcenter/main.py b/autolab/core/gui/controlcenter/main.py index 97e9e4ea..77cab365 100644 --- a/autolab/core/gui/controlcenter/main.py +++ b/autolab/core/gui/controlcenter/main.py @@ -503,8 +503,7 @@ 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() @@ -542,8 +541,7 @@ 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) diff --git a/autolab/core/gui/controlcenter/treewidgets.py b/autolab/core/gui/controlcenter/treewidgets.py index ab1b5f50..c93bc8e8 100644 --- a/autolab/core/gui/controlcenter/treewidgets.py +++ b/autolab/core/gui/controlcenter/treewidgets.py @@ -141,7 +141,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) @@ -149,12 +149,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) @@ -166,6 +166,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", @@ -211,14 +222,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 diff --git a/autolab/core/gui/monitoring/main.py b/autolab/core/gui/monitoring/main.py index efaa7b32..84ac8320 100644 --- a/autolab/core/gui/monitoring/main.py +++ b/autolab/core/gui/monitoring/main.py @@ -183,8 +183,7 @@ def closeEvent(self, event): 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): + for children in self.findChildren(QtWidgets.QWidget): children.deleteLater() super().closeEvent(event) diff --git a/autolab/core/gui/plotting/main.py b/autolab/core/gui/plotting/main.py index e1b7d190..33d59c0a 100644 --- a/autolab/core/gui/plotting/main.py +++ b/autolab/core/gui/plotting/main.py @@ -456,8 +456,7 @@ def closeEvent(self,event): 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/treewidgets.py b/autolab/core/gui/plotting/treewidgets.py index 279ebe36..6e4dc453 100644 --- a/autolab/core/gui/plotting/treewidgets.py +++ b/autolab/core/gui/plotting/treewidgets.py @@ -40,23 +40,22 @@ def load(self, 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 @@ -64,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 @@ -85,9 +82,6 @@ def menu(self,position): self.loaded = False - - - class TreeWidgetItemAction(QtWidgets.QTreeWidgetItem): """ This class represents an action in an item of the tree """ @@ -103,43 +97,43 @@ def __init__(self, itemParent, action, gui): 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) @@ -147,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) @@ -161,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) @@ -173,17 +182,12 @@ 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): @@ -191,11 +195,11 @@ class TreeWidgetItemVariable(QtWidgets.QTreeWidgetItem): 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})' - super().__init__(itemParent, [self.displayName, 'Variable']) + super().__init__(itemParent, [displayName, 'Variable']) self.setTextAlignment(1, QtCore.Qt.AlignHCenter) self.gui = gui @@ -215,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) @@ -224,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() @@ -268,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/display.py b/autolab/core/gui/scanning/display.py index 9211b0b6..39968373 100644 --- a/autolab/core/gui/scanning/display.py +++ b/autolab/core/gui/scanning/display.py @@ -49,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() diff --git a/autolab/core/gui/scanning/main.py b/autolab/core/gui/scanning/main.py index 343f189a..67a36574 100644 --- a/autolab/core/gui/scanning/main.py +++ b/autolab/core/gui/scanning/main.py @@ -289,7 +289,6 @@ def __init__(self, parent: QtWidgets.QMainWindow, append: bool): appendCheck.stateChanged.connect(self.appendCheckChanged) layout.addWidget(appendCheck) - self.exec_ = file_dialog.exec_ self.selectedFiles = file_dialog.selectedFiles @@ -297,13 +296,11 @@ 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() @@ -437,8 +434,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/scan.py b/autolab/core/gui/scanning/scan.py index 47f6170f..df78f36b 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 ############################################################################# @@ -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')) @@ -103,6 +108,114 @@ def start(self): 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("") @@ -130,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 @@ -202,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) @@ -221,6 +337,8 @@ def __init__(self, queue: Queue, config: dict): self.pauseFlag = threading.Event() self.stopFlag = threading.Event() + self.user_response = None + def run(self): # Start the scan for recipe_name in self.config: @@ -363,17 +481,26 @@ 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']) 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/variables.py b/autolab/core/gui/variables.py index 6d972e37..c2d3a936 100644 --- a/autolab/core/gui/variables.py +++ b/autolab/core/gui/variables.py @@ -287,8 +287,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) @@ -390,7 +389,7 @@ 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 recipe """ + """ Provides a menu where the user right clicked to manage a variable """ item = self.variablesWidget.itemAt(position) if hasattr(item, 'menu'): item.menu(position) @@ -484,8 +483,7 @@ def closeEvent(self, event): for monitor in list(self.monitors.values()): monitor.close() - for children in self.findChildren( - QtWidgets.QWidget, options=QtCore.Qt.FindDirectChildrenOnly): + for children in self.findChildren(QtWidgets.QWidget): children.deleteLater() super().closeEvent(event) From c000b9067130429dbb9280cee535a89e1e8dd2a5 Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Sat, 11 May 2024 14:27:56 +0200 Subject: [PATCH 05/29] add sliders to Variable + fixes - add sliders to Variables (now can set Variable value by providing a parameter to __call__ (will think about merge with elements.Variable) - move sliders.py from control panel to gui - modify sliders to not raise error if bad data type (method badType) - fixe Variable not having the correct data type in scan - fixe bug in config (forgot to change var name) - cleaning (remove keys(), change to items()) --- autolab/__init__.py | 2 - autolab/_entry_script.py | 4 +- autolab/core/config.py | 4 +- autolab/core/devices.py | 10 +- autolab/core/drivers.py | 11 +- autolab/core/elements.py | 39 ++-- autolab/core/gui/controlcenter/main.py | 8 +- autolab/core/gui/controlcenter/treewidgets.py | 2 +- autolab/core/gui/scanning/config.py | 31 ++- autolab/core/gui/scanning/customWidgets.py | 4 +- autolab/core/gui/scanning/data.py | 6 +- autolab/core/gui/scanning/figure.py | 2 +- autolab/core/gui/scanning/main.py | 2 +- autolab/core/gui/scanning/recipe.py | 6 +- autolab/core/gui/scanning/scan.py | 7 +- .../core/gui/{controlcenter => }/slider.py | 201 ++++++++++-------- autolab/core/gui/variables.py | 50 ++++- autolab/core/infos.py | 10 +- 18 files changed, 229 insertions(+), 170 deletions(-) rename autolab/core/gui/{controlcenter => }/slider.py (54%) diff --git a/autolab/__init__.py b/autolab/__init__.py index 87ef8ea5..e614be45 100644 --- a/autolab/__init__.py +++ b/autolab/__init__.py @@ -1,7 +1,5 @@ # -*- 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). diff --git a/autolab/_entry_script.py b/autolab/_entry_script.py index a65ce804..44e0d81f 100644 --- a/autolab/_entry_script.py +++ b/autolab/_entry_script.py @@ -163,14 +163,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/core/config.py b/autolab/core/config.py index 635c400d..bc453cee 100644 --- a/autolab/core/config.py +++ b/autolab/core/config.py @@ -142,7 +142,7 @@ def check_autolab_config(): if section_key in autolab_config.sections(): conf = dict(autolab_config[section_key]) for key, dic in section_dic.items(): - if key not in conf.keys(): + if key not in conf: conf[key] = str(dic) else: conf = section_dic @@ -274,7 +274,7 @@ def check_plotter_config(): if section_key in plotter_config.sections(): conf = dict(plotter_config[section_key]) for key, dic in section_dic.items(): - if key not in conf.keys(): + if key not in conf: conf[key] = str(dic) else: conf = section_dic diff --git a/autolab/core/devices.py b/autolab/core/devices.py index d1bae523..a26a04ec 100644 --- a/autolab/core/devices.py +++ b/autolab/core/devices.py @@ -5,7 +5,7 @@ @author: quentin.chateiller """ -from typing import List +from typing import List, Union from . import drivers from . import config @@ -70,12 +70,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 +103,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 +124,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 5c17136a..1ab98fa9 100644 --- a/autolab/core/drivers.py +++ b/autolab/core/drivers.py @@ -110,7 +110,7 @@ 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)) # ============================================================================= @@ -192,17 +192,16 @@ def get_instance_methods(instance: Type) -> Type: 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(): + for key, val in vars(instance).items(): try: # explicit to avoid visa and inspect.getmembers issue - for name, _ in inspect.getmembers(instance_vars[key], inspect.ismethod): + 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 diff --git a/autolab/core/elements.py b/autolab/core/elements.py index 2d17f634..8bd03f07 100644 --- a/autolab/core/elements.py +++ b/autolab/core/elements.py @@ -41,37 +41,38 @@ def __init__(self, parent: Type, config: dict): 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(): + 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'] @@ -161,22 +162,22 @@ def __init__(self, parent: Type, config: dict): 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'] @@ -269,11 +270,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'] @@ -287,13 +288,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'" @@ -322,7 +323,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 """ @@ -331,7 +332,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 """ @@ -340,7 +341,7 @@ 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 """ diff --git a/autolab/core/gui/controlcenter/main.py b/autolab/core/gui/controlcenter/main.py index 77cab365..a66f2cf1 100644 --- a/autolab/core/gui/controlcenter/main.py +++ b/autolab/core/gui/controlcenter/main.py @@ -299,7 +299,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) @@ -315,9 +315,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) @@ -515,8 +514,7 @@ def closeEvent(self, event): 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 diff --git a/autolab/core/gui/controlcenter/treewidgets.py b/autolab/core/gui/controlcenter/treewidgets.py index c93bc8e8..e58d6927 100644 --- a/autolab/core/gui/controlcenter/treewidgets.py +++ b/autolab/core/gui/controlcenter/treewidgets.py @@ -13,7 +13,7 @@ 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 diff --git a/autolab/core/gui/scanning/config.py b/autolab/core/gui/scanning/config.py index 347b19de..ec43c8d5 100644 --- a/autolab/core/gui/scanning/config.py +++ b/autolab/core/gui/scanning/config.py @@ -210,7 +210,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: @@ -260,7 +260,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( @@ -569,37 +569,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 = [] - 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) @@ -726,7 +726,7 @@ def getConfigVariables(self) -> List[Tuple[str, Any]]: for recipe_name in reversed(self.recipeNameList()): for param_name in self.parameterNameList(recipe_name): values = self.getValues(recipe_name, param_name) - value = values if variables.has_eval(values) else 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': @@ -831,9 +831,9 @@ def create_configPars(self) -> dict: assert variables.is_Variable(var) value_raw = var.raw if isinstance(value_raw, np.ndarray): valueStr = array_to_str( - value, threshold=1000000, max_line_width=9000000) + value_raw, threshold=1000000, max_line_width=9000000) elif isinstance(value_raw, pd.DataFrame): valueStr = dataframe_to_str( - value, threshold=1000000) + value_raw, threshold=1000000) elif isinstance(value_raw, (int, float, str)): try: valueStr = f'{value_raw:.{self.precision}g}' except: valueStr = f'{value_raw}' @@ -918,7 +918,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: @@ -1068,8 +1068,7 @@ def load_configPars(self, configPars: dict, append: bool = False): var_dict = configPars['variables'] add_vars = [] - for var_name in var_dict.keys(): - raw_value = var_dict[var_name] + 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)) diff --git a/autolab/core/gui/scanning/customWidgets.py b/autolab/core/gui/scanning/customWidgets.py index 28bc491b..a5881320 100644 --- a/autolab/core/gui/scanning/customWidgets.py +++ b/autolab/core/gui/scanning/customWidgets.py @@ -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 @@ -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) diff --git a/autolab/core/gui/scanning/data.py b/autolab/core/gui/scanning/data.py index 6268b4fd..b9d36186 100644 --- a/autolab/core/gui/scanning/data.py +++ b/autolab/core/gui/scanning/data.py @@ -12,7 +12,7 @@ import tempfile import sys import random -from typing import List +from typing import List, Union import numpy as np import pandas as pd @@ -87,11 +87,11 @@ def getData(self, nbDataset: int, varList: list, dataList.reverse() return dataList - def getLastDataset(self) -> dict: + def getLastDataset(self) -> Union[dict, None]: """ This return the last created dataset """ return self.datasets[-1] if len(self.datasets) > 0 else None - def getLastSelectedDataset(self) -> List[dict]: + def getLastSelectedDataset(self) -> Union[dict, None]: """ This return the last selected dataset """ index = self.gui.data_comboBox.currentIndex() if index != -1 and index < len(self.datasets): diff --git a/autolab/core/gui/scanning/figure.py b/autolab/core/gui/scanning/figure.py index a60ba29d..efe89053 100644 --- a/autolab/core/gui/scanning/figure.py +++ b/autolab/core/gui/scanning/figure.py @@ -114,7 +114,7 @@ def data_comboBoxClicked(self): dataset = self.gui.dataManager.getLastSelectedDataset() index = self.gui.scan_recipe_comboBox.currentIndex() - resultNamesList = list(dataset.keys()) + resultNamesList = list(dataset) AllItems = [self.gui.scan_recipe_comboBox.itemText(i) for i in range(self.gui.scan_recipe_comboBox.count())] if AllItems != resultNamesList: diff --git a/autolab/core/gui/scanning/main.py b/autolab/core/gui/scanning/main.py index 67a36574..1373b988 100644 --- a/autolab/core/gui/scanning/main.py +++ b/autolab/core/gui/scanning/main.py @@ -353,7 +353,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: diff --git a/autolab/core/gui/scanning/recipe.py b/autolab/core/gui/scanning/recipe.py index 935da98d..bad231c8 100644 --- a/autolab/core/gui/scanning/recipe.py +++ b/autolab/core/gui/scanning/recipe.py @@ -207,11 +207,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 diff --git a/autolab/core/gui/scanning/scan.py b/autolab/core/gui/scanning/scan.py index df78f36b..ab193680 100644 --- a/autolab/core/gui/scanning/scan.py +++ b/autolab/core/gui/scanning/scan.py @@ -348,7 +348,7 @@ def run(self): 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 = [] @@ -385,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 @@ -401,7 +401,8 @@ def execRecipe(self, recipe_name: str, 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) diff --git a/autolab/core/gui/controlcenter/slider.py b/autolab/core/gui/slider.py similarity index 54% rename from autolab/core/gui/controlcenter/slider.py rename to autolab/core/gui/slider.py index 584d72be..a7a0f8c1 100644 --- a/autolab/core/gui/controlcenter/slider.py +++ b/autolab/core/gui/slider.py @@ -9,9 +9,9 @@ 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 +from .icons import icons +from .GUI_utilities import get_font_size, setLineEditBackground +from .. import config class Slider(QtWidgets.QMainWindow): @@ -32,6 +32,7 @@ def __init__(self, item: QtWidgets.QTreeWidgetItem): # 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) @@ -125,108 +126,128 @@ def __init__(self, item: QtWidgets.QTreeWidgetItem): 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 + if self.item.variable.type in (int, float): + 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 + else: self.badType() 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) + if self.item.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.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) + else: self.badType() 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) - else: - self.updateStep() - self.updateTrueValue(old_true_value) + if self.item.variable.type in (int, float): + 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) + else: + self.updateStep() + self.updateTrueValue(old_true_value) + else: self.badType() 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) - else: - self.updateStep() - self.updateTrueValue(old_true_value) + if self.item.variable.type in (int, float): + 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) + else: + self.updateStep() + self.updateTrueValue(old_true_value) + else: self.badType() 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) - else: - self.updateStep() - self.updateTrueValue(old_true_value) + if self.item.variable.type in (int, float): + 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) + else: + self.updateStep() + self.updateTrueValue(old_true_value) + else: self.badType() 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() + if self.item.variable.type in (int, float): + 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) + if hasattr(self.item.gui, 'threadManager'): + self.item.gui.threadManager.start( + self.item, 'write', value=true_value) + else: + self.item.variable(true_value) + self.updateStep() + else: self.badType() 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 + if self.item.variable.type in (int, float): + 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) + if hasattr(self.item.gui, 'threadManager'): + self.item.gui.threadManager.start( + self.item, 'write', value=true_value) + else: + self.item.variable(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 + else: self.badType() def instantChanged(self, value): self.slider_instantaneous = self.instantCheckBox.isChecked() @@ -239,6 +260,12 @@ 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 """ self.item.clearSlider() diff --git a/autolab/core/gui/variables.py b/autolab/core/gui/variables.py index c2d3a936..1360aab8 100644 --- a/autolab/core/gui/variables.py +++ b/autolab/core/gui/variables.py @@ -21,6 +21,8 @@ array_to_str, dataframe_to_str) from .monitoring.main import Monitor +from .slider import Slider + # class AddVarSignal(QtCore.QObject): # add = QtCore.Signal(object, object) @@ -65,6 +67,8 @@ 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(): @@ -88,9 +92,15 @@ def refresh(self, name: str, var: Any): 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 __call__(self) -> Any: - return self.evaluate() def evaluate(self): if has_eval(self.raw): @@ -159,7 +169,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): @@ -378,6 +387,7 @@ def __init__(self, parent: QtWidgets.QMainWindow = None): self.refresh() self.monitors = {} + self.sliders = {} # self.timer = QtCore.QTimer(self) # self.timer.setInterval(400) # ms # self.timer.timeout.connect(self.refresh_new) @@ -483,6 +493,9 @@ def closeEvent(self, event): 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() @@ -546,14 +559,19 @@ def menu(self, position: QtCore.QPoint): 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() + 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.keys(): + 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 @@ -563,11 +581,29 @@ def openMonitor(self): 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) + self.gui.sliders[id(self)].show() + # If the slider is already running, just make as the front window + else: + slider = self.gui.sliders[id(self)] + slider.setWindowState( + slider.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) + slider.activateWindow() + def clearMonitor(self): """ This clear monitor instances reference when quitted """ - if id(self) in self.gui.monitors.keys(): + 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: diff --git a/autolab/core/infos.py b/autolab/core/infos.py index e2bc280c..38ee62d2 100644 --- a/autolab/core/infos.py +++ b/autolab/core/infos.py @@ -101,7 +101,7 @@ def config_help(driver_name: str, _print: bool = True, _parser: bool = False) -> c_option='' if _parser: c_option='(-C option)' mess += f'\nAvailable connections types {c_option}:\n' - for connection in params['connection'].keys(): + for connection in params['connection']: mess += f' - {connection}\n' mess += '\n' @@ -117,9 +117,9 @@ def config_help(driver_name: str, _print: bool = True, _parser: bool = False) -> 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" @@ -130,7 +130,7 @@ def config_help(driver_name: str, _print: bool = True, _parser: bool = False) -> # 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(): @@ -146,7 +146,7 @@ def config_help(driver_name: str, _print: bool = True, _parser: bool = False) -> 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 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" From a544ddbfc3cd24736df05b177e96082b2976826e Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Tue, 14 May 2024 13:36:21 +0200 Subject: [PATCH 06/29] cleaning + remove condition on parameter mean & width - now only reject negative range when changing mean or width if in log scale - code cleaning --- autolab/core/devices.py | 4 +- autolab/core/elements.py | 14 ++--- autolab/core/gui/scanning/config.py | 71 ++++++++------------------ autolab/core/gui/scanning/main.py | 30 +++++++++++ autolab/core/gui/scanning/parameter.py | 10 ++-- autolab/core/infos.py | 7 ++- 6 files changed, 69 insertions(+), 67 deletions(-) diff --git a/autolab/core/devices.py b/autolab/core/devices.py index a26a04ec..d7cde188 100644 --- a/autolab/core/devices.py +++ b/autolab/core/devices.py @@ -46,8 +46,8 @@ def __dir__(self): # DEVICE GET FUNCTION # ============================================================================= -def get_element_by_address(address: str) -> Device: - """ Returns the Element located at the provided address """ +def get_device_by_address(address: str) -> Union[Device, None]: + """ Returns the Device located at the provided address if exists """ address = address.split('.') try: element = get_device(address[0]) diff --git a/autolab/core/elements.py b/autolab/core/elements.py index 8bd03f07..dbb6fb29 100644 --- a/autolab/core/elements.py +++ b/autolab/core/elements.py @@ -353,7 +353,7 @@ def __getattr__(self, attr: str) -> Element: 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) -> List[List[str]]: + 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 = [] @@ -361,9 +361,9 @@ def get_structure(self) -> List[List[str]]: 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 @@ -374,15 +374,15 @@ def sub_hierarchy(self, level: int = 0) -> List[Tuple[str, str, int]]: h = [] 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]) + 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/scanning/config.py b/autolab/core/gui/scanning/config.py index ec43c8d5..fc749e3c 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(): @@ -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 @@ -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,23 +304,6 @@ 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. @@ -347,7 +321,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 +336,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 +353,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 +372,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 +397,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 +407,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 +418,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, @@ -482,18 +456,15 @@ def addRecipeStep(self, recipe_name: str, stepType: str, element, 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 +474,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 +483,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 +495,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,7 +503,7 @@ 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 = {} @@ -959,7 +930,7 @@ def load_configPars(self, configPars: dict, append: bool = False): assert 'address' in param_pars, f"Missing address to {param_pars}" if param_pars['address'] == "None": element = None else: - element = devices.get_element_by_address(param_pars['address']) + element = devices.get_device_by_address(param_pars['address']) assert element is not None, f"Parameter {param_pars['address']} not found." param['element'] = element @@ -1011,7 +982,7 @@ def load_configPars(self, configPars: dict, append: bool = False): assert step['stepType'] != 'recipe', "Removed the recipe in recipe feature!" element = address else: - element = devices.get_element_by_address(address) + element = devices.get_device_by_address(address) assert element is not None, f"Address {address} not found for step {i} ({name})." step['element'] = element @@ -1079,7 +1050,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/main.py b/autolab/core/gui/scanning/main.py index 1373b988..c107ba5a 100644 --- a/autolab/core/gui/scanning/main.py +++ b/autolab/core/gui/scanning/main.py @@ -261,6 +261,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 """ diff --git a/autolab/core/gui/scanning/parameter.py b/autolab/core/gui/scanning/parameter.py index f997e33f..fd21af36 100644 --- a/autolab/core/gui/scanning/parameter.py +++ b/autolab/core/gui/scanning/parameter.py @@ -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: diff --git a/autolab/core/infos.py b/autolab/core/infos.py index 38ee62d2..cdf0eedb 100644 --- a/autolab/core/infos.py +++ b/autolab/core/infos.py @@ -98,9 +98,8 @@ 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' + 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' @@ -112,7 +111,7 @@ def config_help(driver_name: str, _print: bool = True, _parser: bool = False) -> 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' From 198d63373d8903765bc4e24341e2586437599b52 Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Wed, 15 May 2024 15:15:07 +0200 Subject: [PATCH 07/29] Can now cancel a device connection Can now right click on device to cancel the connection if takes too long -> avoid blocking the GUI --- autolab/core/gui/controlcenter/main.py | 6 ++++ autolab/core/gui/controlcenter/thread.py | 16 ++++++---- autolab/core/gui/controlcenter/treewidgets.py | 30 ++++++++++++------- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/autolab/core/gui/controlcenter/main.py b/autolab/core/gui/controlcenter/main.py index a66f2cf1..b24e108c 100644 --- a/autolab/core/gui/controlcenter/main.py +++ b/autolab/core/gui/controlcenter/main.py @@ -365,6 +365,12 @@ def itemClicked(self, item: QtWidgets.QTreeWidgetItem): 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: + self.threadManager.threads[id(item)].endSignal.emit(f'Cancel loading device {item.name}') + self.threadManager.threads[id(item)].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. diff --git a/autolab/core/gui/controlcenter/thread.py b/autolab/core/gui/controlcenter/thread.py index 030313bc..416c5b54 100644 --- a/autolab/core/gui/controlcenter/thread.py +++ b/autolab/core/gui/controlcenter/thread.py @@ -53,8 +53,9 @@ def start(self, item: QtWidgets.QTreeWidgetItem, intType: str, value = None): self.gui.setStatus(status) # Thread configuration + tid = id(item) + assert tid not in self.threads thread = InteractionThread(item, intType, value) - tid = id(thread) self.threads[tid] = thread thread.endSignal.connect( lambda error, x=tid: self.threadFinished(x, error)) @@ -65,9 +66,14 @@ def start(self, item: QtWidgets.QTreeWidgetItem, intType: str, value = None): 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: + self.gui.setStatus(str(error), 10000, False) + + if tid in self.gui.threadItemDict: + self.gui.threadItemDict.pop(tid) + else: + self.gui.clearStatus() item = self.threads[tid].item item.setDisabled(False) @@ -143,7 +149,5 @@ def run(self): 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)) self.endSignal.emit(error) diff --git a/autolab/core/gui/controlcenter/treewidgets.py b/autolab/core/gui/controlcenter/treewidgets.py index e58d6927..eb9a5865 100644 --- a/autolab/core/gui/controlcenter/treewidgets.py +++ b/autolab/core/gui/controlcenter/treewidgets.py @@ -74,20 +74,30 @@ 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: + menu = QtWidgets.QMenu() + cancelDevice = menu.addAction(f"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) class TreeWidgetItemAction(QtWidgets.QTreeWidgetItem): From 9eb4a7226fea4849116badf8ff53e67c7b5fbdbf Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Thu, 16 May 2024 14:24:02 +0200 Subject: [PATCH 08/29] monitor fixe - fixe bug with monitor 'window length' not changeable (bug introduced in 9ed2e66) - can now monitor numpy array with len(shape) = 0 (one point) - now raise error and pause if set bad data type in monitor - now check visibility before show() and hide() --- autolab/core/gui/monitoring/data.py | 48 +++++++++++++++------------ autolab/core/gui/monitoring/figure.py | 24 +++++++++----- autolab/core/gui/scanning/figure.py | 26 +++++++++------ 3 files changed, 59 insertions(+), 39 deletions(-) diff --git a/autolab/core/gui/monitoring/data.py b/autolab/core/gui/monitoring/data.py index 7447770e..01eec36f 100644 --- a/autolab/core/gui/monitoring/data.py +++ b/autolab/core/gui/monitoring/data.py @@ -46,25 +46,28 @@ def addPoint(self, point: Tuple[Any, Any]): """ This function either replace list by array or add point to list depending on datapoint type """ y = point[1] - 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) == 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) + 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: - self._addPoint(point) + 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.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): """ Add image to ylist data as np.ndarray """ @@ -72,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)) @@ -98,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 480e7fd6..f09bcb6d 100644 --- a/autolab/core/gui/monitoring/figure.py +++ b/autolab/core/gui/monitoring/figure.py @@ -50,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() @@ -68,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() diff --git a/autolab/core/gui/scanning/figure.py b/autolab/core/gui/scanning/figure.py index efe89053..d24feb8d 100644 --- a/autolab/core/gui/scanning/figure.py +++ b/autolab/core/gui/scanning/figure.py @@ -240,15 +240,16 @@ def reloadData(self): self.setLabel('x', variable_x) self.setLabel('y', variable_y) - self.gui.frame_axis.show() if data_name == "Scan": 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() + 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( @@ -285,9 +286,11 @@ def reloadData(self): subdata = subdata.astype(float) if isinstance(subdata, np.ndarray): # is image - self.fig.hide() - self.figMap.show() - self.gui.frame_axis.hide() + if self.fig.isVisible(): + self.fig.hide() + self.figMap.show() + if self.gui.frame_axis.isVisible(): + self.gui.frame_axis.hide() image_data[i] = subdata if i == len(data)-1: if image_data.ndim == 3: @@ -299,8 +302,11 @@ def reloadData(self): self.figMap.setCurrentIndex(len(self.figMap.tVals)) else: # not an image (is pd.DataFrame) - self.figMap.hide() - self.fig.show() + if not self.fig.isVisible(): + self.fig.show() + self.figMap.hide() + if not self.gui.frame_axis.isVisible(): + self.gui.frame_axis.show() x = subdata.loc[:,variable_x] y = subdata.loc[:,variable_y] if isinstance(x, pd.DataFrame): From 5fca6e4e1c7a61eac5f607bdfc65c3cd5e63dcc0 Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Tue, 28 May 2024 21:56:06 +0200 Subject: [PATCH 09/29] Can add new device directly from GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - It is now possible to right-click on the empty control panel tree, or click on “Setting ‘+’Add new device” to open a menu guiding the user through the process of adding a new device to the config file. - little code clean-up - add tiny bit of doc --- autolab/core/drivers.py | 19 +- autolab/core/gui/controlcenter/main.py | 323 ++++++++++++++++++++++++- autolab/core/gui/scanning/main.py | 4 +- autolab/core/infos.py | 7 +- autolab/core/repository.py | 6 +- docs/gui/scanning.rst | 3 +- docs/low_level/create_driver.rst | 6 +- 7 files changed, 341 insertions(+), 27 deletions(-) diff --git a/autolab/core/drivers.py b/autolab/core/drivers.py index 1ab98fa9..ccae614d 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 @@ -142,11 +140,14 @@ def get_driver_category(driver_name: str) -> str: category = 'Other' 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 diff --git a/autolab/core/gui/controlcenter/main.py b/autolab/core/gui/controlcenter/main.py index b24e108c..22eda0e0 100644 --- a/autolab/core/gui/controlcenter/main.py +++ b/autolab/core/gui/controlcenter/main.py @@ -26,8 +26,9 @@ from ..scanning.main import Scanner from ..plotting.main import Plotter from ..variables import VARIABLES +from ..GUI_utilities import get_font_size, setLineEditBackground from ..icons import icons -from ... import devices, web, paths, config, utilities +from ... import devices, drivers, web, paths, config, utilities from .... import __version__ from ...web import project_url, drivers_url, doc_url @@ -138,6 +139,7 @@ def startDrag(self, event): self.scanner = None self.plotter = None self.about = None + self.addDevice = None self.monitors = {} self.sliders = {} self.threadDeviceDict = {} @@ -171,6 +173,11 @@ def startDrag(self, event): devicesConfig.triggered.connect(self.openDevicesConfig) devicesConfig.setStatusTip("Open the devices configuration file") + addDeviceAction = settingsMenu.addAction('Add new device') + addDeviceAction.setIcon(QtGui.QIcon(icons['add'])) + addDeviceAction.triggered.connect(self.openAddDevice) + addDeviceAction.setStatusTip("Open the utility to add a device") + refreshAction = settingsMenu.addAction('Refresh devices') refreshAction.triggered.connect(self.initialize) refreshAction.setStatusTip('Reload devices setting') @@ -334,13 +341,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 """ @@ -355,6 +367,18 @@ 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 new 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. @@ -434,13 +458,25 @@ def openAbout(self): self.about = AboutWindow(self) self.about.show() self.about.activateWindow() - self.activateWindow() # Put main window back to the front - # If the scanner is already running, just make as the front window + # 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): + """ 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() + @staticmethod def openAutolabConfig(): """ Open the Autolab configuration file """ @@ -501,6 +537,10 @@ 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: @@ -516,6 +556,9 @@ def closeEvent(self, event): 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() @@ -553,6 +596,270 @@ def closeEvent(self, 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.statusBar = self.statusBar() + + self._prev_name = '' + self._prev_conn = '' + + 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) + + addButton = QtWidgets.QPushButton('Add device') + addButton.clicked.connect(self.addButtonClicked) + + layoutButton.addWidget(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 state, layout=layout: self.removeOptionalArgClicked(layout)) + layout.addWidget(widget) + + def removeOptionalArgClicked(self, layout): + """ Remove optional argument layout """ + for j in reversed(range(3)): + 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): + 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) + + self.mainGui.initialize() + + self.close() + + 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(3)): + 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() + connection_args = drivers.get_class_args( + drivers.get_connection_class(driver_lib, conn)) + + # populate layoutDriverOtherArgs + other_args = drivers.get_class_args(drivers.get_driver_class(driver_lib)) + 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(3)): + layout.itemAt(j).widget().setParent(None) + layout.setParent(None) + + # populate layoutOptionalArg + if hasattr(drivers.get_driver_class(driver_lib), 'slot_config'): + self.addOptionalArgClicked('slot1', f'{drivers.get_driver_class(driver_lib).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) + + # 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) + + 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 + self.mainGui.clearAddDevice() + + class AboutWindow(QtWidgets.QMainWindow): def __init__(self, parent: QtWidgets.QMainWindow = None): diff --git a/autolab/core/gui/scanning/main.py b/autolab/core/gui/scanning/main.py index c107ba5a..6984d668 100644 --- a/autolab/core/gui/scanning/main.py +++ b/autolab/core/gui/scanning/main.py @@ -372,10 +372,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 != '': diff --git a/autolab/core/infos.py b/autolab/core/infos.py index cdf0eedb..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 @@ -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'] = {} diff --git a/autolab/core/repository.py b/autolab/core/repository.py index b90f4750..f3f2811d 100644 --- a/autolab/core/repository.py +++ b/autolab/core/repository.py @@ -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'] diff --git a/docs/gui/scanning.rst b/docs/gui/scanning.rst index a3da03d9..ca1bb05c 100644 --- a/docs/gui/scanning.rst +++ b/docs/gui/scanning.rst @@ -30,7 +30,7 @@ It is possible to set a custom array by right cliking on the parameter frame and 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 ----- @@ -56,6 +56,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 ############## diff --git a/docs/low_level/create_driver.rst b/docs/low_level/create_driver.rst index 9343bc5f..75412f70 100644 --- a/docs/low_level/create_driver.rst +++ b/docs/low_level/create_driver.rst @@ -450,7 +450,7 @@ Shared by the three elements (*Module*, *Variable*, *Action*): *Action*: - 'do': class attribute (argument type: function) - 'param_type': python type, exclusively in: int, float, bool, str, bytes, tuple, np.ndarray, pd.DataFrame, optional - - 'param_unit': unit of the variable, optionnal (argument type: string. Use special param_unit "filename" to open a open file dialog) + - 'param_unit': unit of the variable, optionnal (argument type: string. Use special param_unit 'open-file' to open a open file dialog, 'save-file' to open a save file dialog and 'user-input' to open an input dialog) @@ -463,7 +463,7 @@ Example code: model.append({'name':'line1', 'element':'module', 'object':self.slot1, 'help':'Simple help for line1 module'}) model.append({'name':'amplitude', 'element':'variable', 'type':float, 'read':self.get_amplitude, 'write':self.set_amplitude, 'unit':'V', 'help':'Simple help for amplitude variable'} model.append({'name':'go_home', 'element':'action', 'do':self.home, 'help':'Simple help for go_home action'}) - model.append({'name':'open', 'element':'action', 'do':self.open, 'param_type':str, 'param_unit':'filename', 'help':'Open data with the provided filename'}) + model.append({'name':'open', 'element':'action', 'do':self.open, 'param_type':str, 'param_unit':'open-file', 'help':'Open data with the provided filename'}) return model .. _name_driver_utilities.py: @@ -471,7 +471,7 @@ Example code: Driver utilities structure (*\_\_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: From 85793ed9a792ac5a1af8034bfb63bb8bb526ce69 Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Wed, 29 May 2024 10:34:32 +0200 Subject: [PATCH 10/29] add list_resources for VISA connections --- autolab/core/gui/controlcenter/main.py | 24 ++++++++++++++++++++++-- autolab/core/gui/scanning/data.py | 2 +- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/autolab/core/gui/controlcenter/main.py b/autolab/core/gui/controlcenter/main.py index 22eda0e0..ec9a88e3 100644 --- a/autolab/core/gui/controlcenter/main.py +++ b/autolab/core/gui/controlcenter/main.py @@ -609,6 +609,12 @@ def __init__(self, parent: QtWidgets.QMainWindow = None): 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 @@ -773,7 +779,7 @@ def driverChanged(self): for i in reversed(range(self.layoutOptionalArg.count())): layout = self.layoutOptionalArg.itemAt(i).layout() - for j in reversed(range(3)): + for j in reversed(range(layout.count())): layout.itemAt(j).widget().setParent(None) layout.setParent(None) @@ -813,7 +819,7 @@ def driverChanged(self): # reset layoutOptionalArg for i in reversed(range(self.layoutOptionalArg.count())): layout = self.layoutOptionalArg.itemAt(i).layout() - for j in reversed(range(3)): + for j in reversed(range(layout.count())): layout.itemAt(j).widget().setParent(None) layout.setParent(None) @@ -849,6 +855,20 @@ def connectionChanged(self): 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) + 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) diff --git a/autolab/core/gui/scanning/data.py b/autolab/core/gui/scanning/data.py index b9d36186..7b56d2fc 100644 --- a/autolab/core/gui/scanning/data.py +++ b/autolab/core/gui/scanning/data.py @@ -41,7 +41,7 @@ def __init__(self, gui: QtWidgets.QMainWindow): self.timer.timeout.connect(self.sync) def getData(self, nbDataset: int, varList: list, - selectedData: int = 0, data_name: str = "Scan"): + selectedData: int = 0, data_name: str = "Scan") -> List[pd.DataFrame]: """ This function returns to the figure manager the required data """ dataList = [] recipe_name = self.gui.scan_recipe_comboBox.currentText() From a782d60db14a1ff5c7354294310967b623f06f4d Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Thu, 30 May 2024 09:08:52 +0200 Subject: [PATCH 11/29] Can plot scan in 2D image - Feature not finished, need rework --- autolab/core/gui/plotting/main.py | 4 +- autolab/core/gui/scanning/data.py | 11 ++- autolab/core/gui/scanning/figure.py | 124 +++++++++++++++++++++++-- autolab/core/gui/scanning/interface.ui | 74 ++++++++++++++- autolab/core/gui/scanning/main.py | 6 ++ autolab/core/gui/scanning/scan.py | 5 +- 6 files changed, 206 insertions(+), 18 deletions(-) diff --git a/autolab/core/gui/plotting/main.py b/autolab/core/gui/plotting/main.py index 33d59c0a..8b8dcfa2 100644 --- a/autolab/core/gui/plotting/main.py +++ b/autolab/core/gui/plotting/main.py @@ -103,9 +103,9 @@ 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}') diff --git a/autolab/core/gui/scanning/data.py b/autolab/core/gui/scanning/data.py index 7b56d2fc..fc914b7d 100644 --- a/autolab/core/gui/scanning/data.py +++ b/autolab/core/gui/scanning/data.py @@ -223,12 +223,14 @@ def updateDisplayableResults(self): isinstance(data, np.ndarray) and not ( len(data.T.shape) == 1 or 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: # 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() return None @@ -255,8 +257,9 @@ def updateDisplayableResults(self): if resultNamesList != AllItems: # 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_x2_comboBox.clear() self.gui.variable_x_comboBox.addItems(resultNamesList) # parameter first - + self.gui.variable_x2_comboBox.addItems(resultNamesList) if resultNamesList: name = resultNamesList.pop(0) resultNamesList.append(name) @@ -267,6 +270,7 @@ def updateDisplayableResults(self): if data_name == "Scan": 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 @@ -330,8 +334,9 @@ def getData(self, varList: list, data_name: str = "Scan", 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] + data2 = data.loc[:,~data.columns.duplicated()].copy() # unique data column + unique_varList = list(dict.fromkeys(varList)) # unique varList + return data2.loc[:,unique_varList] return None diff --git a/autolab/core/gui/scanning/figure.py b/autolab/core/gui/scanning/figure.py index d24feb8d..e8250aa3 100644 --- a/autolab/core/gui/scanning/figure.py +++ b/autolab/core/gui/scanning/figure.py @@ -35,11 +35,14 @@ def __init__(self, gui: QtWidgets.QMainWindow): 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) + self.gui.variable_x2_checkBox.stateChanged.connect(self.reloadData) # Number of traces self.nbtraces = 5 self.gui.nbTraces_lineEdit.setText(f'{self.nbtraces:g}') @@ -218,6 +221,7 @@ 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() def reloadData(self): ''' This function removes any plotted curves and reload all required curves from @@ -228,6 +232,7 @@ def reloadData(self): # 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) @@ -240,33 +245,133 @@ def reloadData(self): self.setLabel('x', variable_x) self.setLabel('y', variable_y) - 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 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 + # 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 + if self.displayed_as_image: + var_to_display = [variable_x, variable_x2, variable_y] + else: + var_to_display = [variable_x, variable_y] + data: pd.DataFrame = self.gui.dataManager.getData( - nbtraces_temp, [variable_x, variable_y], + nbtraces_temp, var_to_display, selectedData=selectedData, data_name=data_name) - # update displayScan - self.refreshDisplayScanData() - # 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 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() + + if not self.figMap.isVisible(): + self.figMap.show() + self.fig.hide() + + # 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) + + x = subdata.loc[:,variable_x].values + x2 = subdata.loc[:,variable_x2].values + y = subdata.loc[:,variable_y].values + + # # Attempt with interpolation + # unique_x = np.sort(np.unique(x)) + # unique_x2 = np.sort(np.unique(x2)) + + # from scipy.interpolate import griddata + # xgrid, x2grid = np.meshgrid(unique_x, + # unique_x2) + + # shape_x = np.unique(x).shape[0] + # shape_x2 = np.unique(x2).shape[0] + + # img = np.empty(shape_x*shape_x2) + # img[:] = np.nan + + # if len(y) < len(img): + # img[: len(y)] = y + # else: + # img = y + + # shape_img = np.shape(img)[0] + + # if shape_x*shape_x2 == shape_img: + + # # give too much errors + # ygrid = griddata((x, x2), y, (xgrid, x2grid)) + + # pw = pg.PlotWidget() + # img = pg.ImageItem(ygrid) + # self.fig.addItem(img) + # self.fig.show() + # self.figMap.hide() + + # self.fig.setImage(img) + # return None + + # Attempt without x and y values. Currently display the wrong img if label x and y are not in the creation order (plotting y, x vs z instead of x, y vs z) + shape_x = np.unique(x).shape[0] + shape_x2 = np.unique(x2).shape[0] + + # Define img using x and x2 shape and set nan where no data exists yet, to have rectangular grid + img = np.empty(shape_x*shape_x2) + img[:] = np.nan + + if len(y) < len(img): + img[: len(y)] = y + else: + img = y + + shape_img = np.shape(img)[0] + + if shape_x*shape_x2 == shape_img: + img = np.reshape(img, (shape_x, shape_x2)) + img = np.rot90(img) + + if self.gui.variable_x_comboBox.currentIndex() > self.gui.variable_x2_comboBox.currentIndex(): + print('Not in correct order!') + + self.figMap.setImage(img) + else: + self.figMap.clear() + + return None + + if self.gui.variable_x2_comboBox.isVisible(): + self.gui.variable_x2_comboBox.hide() + self.gui.label_scan_2D.hide() + if len(data) != 0 and isinstance(data[0], np.ndarray): # to avoid errors image_data = np.empty((len(data), *temp_data.shape)) @@ -307,8 +412,10 @@ def reloadData(self): self.figMap.hide() if not self.gui.frame_axis.isVisible(): self.gui.frame_axis.show() + x = subdata.loc[:,variable_x] y = subdata.loc[:,variable_y] + if isinstance(x, pd.DataFrame): print(f"Warning: At least two variables have the same name. Data plotted is incorrect for {variable_x}!") if isinstance(y, pd.DataFrame): @@ -361,7 +468,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: diff --git a/autolab/core/gui/scanning/interface.ui b/autolab/core/gui/scanning/interface.ui index f3ae081e..a74dac4d 100644 --- a/autolab/core/gui/scanning/interface.ui +++ b/autolab/core/gui/scanning/interface.ui @@ -69,7 +69,7 @@ 0 0 - 513 + 452 553 @@ -336,7 +336,7 @@ - Take start and end value, and type of scale, from figure. + Display scan data in tabular format Scan data @@ -418,6 +418,52 @@ + + + + Qt::Horizontal + + + QSizePolicy::Maximum + + + + 10 + 20 + + + + + + + + 0 + + + + + + 75 + true + + + + X2 axis + + + Qt::AlignCenter + + + + + + + QComboBox::AdjustToContents + + + + + @@ -431,6 +477,30 @@ + + + + Display +as image + + + + + + + Qt::Horizontal + + + QSizePolicy::Maximum + + + + 10 + 20 + + + + diff --git a/autolab/core/gui/scanning/main.py b/autolab/core/gui/scanning/main.py index 6984d668..18c65baa 100644 --- a/autolab/core/gui/scanning/main.py +++ b/autolab/core/gui/scanning/main.py @@ -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() @@ -146,7 +149,10 @@ def clear(self): self.frame_axis.show() self.toolButton.hide() self.variable_x_comboBox.clear() + self.variable_x2_comboBox.clear() self.variable_y_comboBox.clear() + self.variable_x2_comboBox.hide() + self.label_scan_2D.hide() self.data_comboBox.clear() self.data_comboBox.hide() self.save_pushButton.setEnabled(False) diff --git a/autolab/core/gui/scanning/scan.py b/autolab/core/gui/scanning/scan.py index ab193680..727c56fc 100644 --- a/autolab/core/gui/scanning/scan.py +++ b/autolab/core/gui/scanning/scan.py @@ -393,14 +393,15 @@ 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, element.type( paramValue) if element is not None else paramValue) From ae66784ea4b046b8893545f008f6d29ce7e3ba49 Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Fri, 31 May 2024 14:58:37 +0200 Subject: [PATCH 12/29] Better scan 2D image - Changed ImageView to PColorMeshItem to plot scan as 2D image. - Now z data is plotted according to x and y values. Issues remaining: - Setting log scale in plot doesn't display the correct axes values - NaN values are converted to low values to avoid error, need to see how not to display nan values instead --- autolab/core/gui/GUI_utilities.py | 139 +++++++++++++++++++------ autolab/core/gui/scanning/figure.py | 110 ++++++++----------- autolab/core/gui/scanning/interface.ui | 10 +- 3 files changed, 155 insertions(+), 104 deletions(-) diff --git a/autolab/core/gui/GUI_utilities.py b/autolab/core/gui/GUI_utilities.py index 77f3c46e..6a36e75d 100644 --- a/autolab/core/gui/GUI_utilities.py +++ b/autolab/core/gui/GUI_utilities.py @@ -68,45 +68,119 @@ def qt_object_exists(QtObject) -> bool: return True -def pyqtgraph_fig_ax() -> Tuple[pg.PlotWidget, pg.PlotItem]: - """ Return a formated fig and ax pyqtgraph for a basic plot """ +class MyGraphicsLayoutWidget(pg.GraphicsLayoutWidget): + + def __init__(self): + super().__init__() + + # for plotting 1D + ax = self.addPlot() + + # for plotting 2D + img = pg.PColorMeshItem() + img.edgecolors = None + + ax_img = self.addPlot() + ax_img.addItem(img) + ax_img.hide() + + # for plotting 2D colorbar + colorbar = pg.ColorBarItem() + colorbar.setColorMap('viridis') + colorbar.hide() + self.addItem(colorbar) + + # Used in GUI + self.ax = ax + self.img = img + self.ax_img = ax_img + self.colorbar = colorbar + + for ax_i in (ax, ax_img): + ax_i.setLabel("bottom", ' ', **{'color':0.4, 'font-size': '12pt'}) + ax_i.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_i.getAxis("bottom").label.setFont(my_font) + ax_i.getAxis("left").label.setFont(my_font) + ax_i.getAxis("bottom").setTickFont(my_font_tick) + ax_i.getAxis("left").setTickFont(my_font_tick) + ax_i.showGrid(x=True, y=True) + ax_i.setContentsMargins(10., 10., 10., 10.) + + vb = ax_i.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)) + + dataLabel_img = pg.LabelItem(color='k', parent=ax_img.getAxis('bottom')) + dataLabel_img.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) + + def mouseMoved_img(point): + """ This function marks the position of the cursor in data coordinates""" + vb = ax_img.getViewBox() + mousePoint = vb.mapSceneToView(point) + l = f'x = {mousePoint.x():g}, y = {mousePoint.y():g}' + dataLabel_img.setText(l) + + # data reader signal connection + ax.scene().sigMouseMoved.connect(mouseMoved) + ax_img.scene().sigMouseMoved.connect(mouseMoved_img) + + def update_img(self, x, y, z): + + 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])) - # Configure and initialize the figure in the GUI - fig = pg.PlotWidget() - ax = fig.getPlotItem() + if len(y) == 1: + y = np.append(y, y[-1]+1e-99) + else: + y = np.append(y, y[-1] + (y[-1] - y[-2])) - ax.setLabel("bottom", ' ', **{'color':0.4, 'font-size': '12pt'}) - ax.setLabel("left", ' ', **{'color':0.4, 'font-size': '12pt'}) + xv, yv = np.meshgrid(y, x) - # 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.) + img = pg.PColorMeshItem() + img.edgecolors = None + img.setData(xv, yv, z.T) + # OPTIMIZE: Changing log scale doesn't display correct axes - vb = ax.getViewBox() - vb.enableAutoRange(enable=True) - vb.setBorder(pg.mkPen(color=0.4)) + # for plotting 2D colorbar + self.colorbar.setImageItem(img) + self.colorbar.setLevels((z_no_nan.min(), z_no_nan.max())) - ## 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)) + # remove previous img and add new one (can't just refresh -> error if setData with nan and diff shape) + self.ax_img.removeItem(self.img) + self.img = img + self.ax_img.addItem(self.img) - 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) + def deleteLater(self, *args): + try: self.clear() + except: pass + super().deleteLater(*args) - # data reader signal connection - ax.scene().sigMouseMoved.connect(mouseMoved) - return fig, ax +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): @@ -220,7 +294,6 @@ def close(self): 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/scanning/figure.py b/autolab/core/gui/scanning/figure.py index e8250aa3..d5037609 100644 --- a/autolab/core/gui/scanning/figure.py +++ b/autolab/core/gui/scanning/figure.py @@ -206,11 +206,12 @@ def resetCheckBoxMenuID(self): # AXE LABEL ########################################################################### - def setLabel(self, axe: str, value: str): + def setLabel(self, axe: str, value: str, ax=None): """ This function changes the label of the given axis """ + if ax is None: ax = self.ax axes = {'x':'bottom', 'y':'left'} if value == '': value = ' ' - self.ax.setLabel(axes[axe], value, **{'color':0.4, 'font-size': '12pt'}) + ax.setLabel(axes[axe], value, **{'color':0.4, 'font-size': '12pt'}) # PLOT DATA ########################################################################### @@ -222,6 +223,7 @@ def clearData(self): except: pass self.curves = [] self.figMap.clear() + 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 @@ -283,94 +285,68 @@ def reloadData(self): if temp_data is not None: break else: return None + # 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.figMap.isVisible(): - self.figMap.show() - self.fig.hide() + if not self.fig.isVisible(): + self.figMap.hide() + self.fig.show() - # 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) - - x = subdata.loc[:,variable_x].values - x2 = subdata.loc[:,variable_x2].values - y = subdata.loc[:,variable_y].values - - # # Attempt with interpolation - # unique_x = np.sort(np.unique(x)) - # unique_x2 = np.sort(np.unique(x2)) - - # from scipy.interpolate import griddata - # xgrid, x2grid = np.meshgrid(unique_x, - # unique_x2) - - # shape_x = np.unique(x).shape[0] - # shape_x2 = np.unique(x2).shape[0] - - # img = np.empty(shape_x*shape_x2) - # img[:] = np.nan + if self.ax.isVisible(): + self.fig.colorbar.show() + self.fig.ax_img.show() + self.ax.hide() - # if len(y) < len(img): - # img[: len(y)] = y - # else: - # img = y + self.setLabel('x', variable_x, ax=self.fig.ax_img) + self.setLabel('y', variable_x2, ax=self.fig.ax_img) - # shape_img = np.shape(img)[0] + if variable_x == variable_x2: + self.fig.img.hide() + return None - # if shape_x*shape_x2 == shape_img: - - # # give too much errors - # ygrid = griddata((x, x2), y, (xgrid, x2grid)) - - # pw = pg.PlotWidget() - # img = pg.ImageItem(ygrid) - # self.fig.addItem(img) - # self.fig.show() - # self.figMap.hide() - - # self.fig.setImage(img) - # return None + # Data + if len(data) == 0: + self.fig.img.hide() + return None - # Attempt without x and y values. Currently display the wrong img if label x and y are not in the creation order (plotting y, x vs z instead of x, y vs z) - shape_x = np.unique(x).shape[0] - shape_x2 = np.unique(x2).shape[0] + subdata: pd.DataFrame = data[-1] # Only plot last scan - # Define img using x and x2 shape and set nan where no data exists yet, to have rectangular grid - img = np.empty(shape_x*shape_x2) - img[:] = np.nan + if subdata is None: + self.fig.img.hide() + return None - if len(y) < len(img): - img[: len(y)] = y - else: - img = y + subdata = subdata.astype(float) - shape_img = np.shape(img)[0] + pivot_table = subdata.pivot( + index=variable_x, columns=variable_x2, values=variable_y) - if shape_x*shape_x2 == shape_img: - img = np.reshape(img, (shape_x, shape_x2)) - img = np.rot90(img) + # Extract data for plotting + x = np.array(pivot_table.columns) + y = np.array(pivot_table.index) + z = np.array(pivot_table) - if self.gui.variable_x_comboBox.currentIndex() > self.gui.variable_x2_comboBox.currentIndex(): - print('Not in correct order!') + self.fig.update_img(x, y, z) - self.figMap.setImage(img) - else: - self.figMap.clear() + 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 not self.ax.isVisible(): + self.fig.colorbar.hide() + self.fig.ax_img.hide() + self.ax.show() if len(data) != 0 and isinstance(data[0], np.ndarray): # to avoid errors image_data = np.empty((len(data), *temp_data.shape)) diff --git a/autolab/core/gui/scanning/interface.ui b/autolab/core/gui/scanning/interface.ui index a74dac4d..736526ce 100644 --- a/autolab/core/gui/scanning/interface.ui +++ b/autolab/core/gui/scanning/interface.ui @@ -448,7 +448,7 @@ - X2 axis + Y axis Qt::AlignCenter @@ -479,9 +479,11 @@ + + Show scan data as 2D colormesh + - Display -as image + 2D plot @@ -562,7 +564,7 @@ as image 0 - + 75 From 73a8e3024cbb5f09641eb9739314a1b71f78ed5c Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Tue, 4 Jun 2024 10:18:29 +0200 Subject: [PATCH 13/29] 2D scan image fixes - Manage version compatibility with pyqtgraph - Simplify axis - Compatibility py 3.6: reversed(dict) -> reversed(list(dict)) - Display default parameter address as Variable name --- autolab/core/gui/GUI_utilities.py | 128 ++++++++++++++----------- autolab/core/gui/monitoring/figure.py | 2 +- autolab/core/gui/plotting/figure.py | 2 +- autolab/core/gui/scanning/figure.py | 66 +++++++++---- autolab/core/gui/scanning/main.py | 7 +- autolab/core/gui/scanning/parameter.py | 2 +- 6 files changed, 120 insertions(+), 87 deletions(-) diff --git a/autolab/core/gui/GUI_utilities.py b/autolab/core/gui/GUI_utilities.py index 6a36e75d..86946de6 100644 --- a/autolab/core/gui/GUI_utilities.py +++ b/autolab/core/gui/GUI_utilities.py @@ -7,6 +7,7 @@ from typing import Tuple import os +import sys import numpy as np from qtpy import QtWidgets, QtCore, QtGui @@ -14,6 +15,7 @@ from ..config import get_GUI_config +ONCE = False def get_font_size() -> int: GUI_config = get_GUI_config() @@ -69,58 +71,38 @@ def qt_object_exists(QtObject) -> bool: 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 - # for plotting 2D - img = pg.PColorMeshItem() - img.edgecolors = None + ax.setLabel("bottom", ' ', **{'color':0.4, 'font-size': '12pt'}) + ax.setLabel("left", ' ', **{'color':0.4, 'font-size': '12pt'}) - ax_img = self.addPlot() - ax_img.addItem(img) - ax_img.hide() + # 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.) - # for plotting 2D colorbar - colorbar = pg.ColorBarItem() - colorbar.setColorMap('viridis') - colorbar.hide() - self.addItem(colorbar) - - # Used in GUI - self.ax = ax - self.img = img - self.ax_img = ax_img - self.colorbar = colorbar - - for ax_i in (ax, ax_img): - ax_i.setLabel("bottom", ' ', **{'color':0.4, 'font-size': '12pt'}) - ax_i.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_i.getAxis("bottom").label.setFont(my_font) - ax_i.getAxis("left").label.setFont(my_font) - ax_i.getAxis("bottom").setTickFont(my_font_tick) - ax_i.getAxis("left").setTickFont(my_font_tick) - ax_i.showGrid(x=True, y=True) - ax_i.setContentsMargins(10., 10., 10., 10.) - - vb = ax_i.getViewBox() - vb.enableAutoRange(enable=True) - vb.setBorder(pg.mkPen(color=0.4)) + 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)) - dataLabel_img = pg.LabelItem(color='k', parent=ax_img.getAxis('bottom')) - dataLabel_img.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() @@ -128,19 +110,48 @@ def mouseMoved(point): l = f'x = {mousePoint.x():g}, y = {mousePoint.y():g}' dataLabel.setText(l) - def mouseMoved_img(point): - """ This function marks the position of the cursor in data coordinates""" - vb = ax_img.getViewBox() - mousePoint = vb.mapSceneToView(point) - l = f'x = {mousePoint.x():g}, y = {mousePoint.y():g}' - dataLabel_img.setText(l) - # data reader signal connection ax.scene().sigMouseMoved.connect(mouseMoved) - ax_img.scene().sigMouseMoved.connect(mouseMoved_img) - def update_img(self, x, y, z): + 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 @@ -161,21 +172,22 @@ def update_img(self, x, y, z): 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) - # for plotting 2D colorbar - self.colorbar.setImageItem(img) - self.colorbar.setLevels((z_no_nan.min(), z_no_nan.max())) + 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_img.removeItem(self.img) + self.ax.removeItem(self.img) self.img = img - self.ax_img.addItem(self.img) - - def deleteLater(self, *args): - try: self.clear() - except: pass - super().deleteLater(*args) - + self.ax.addItem(self.img) def pyqtgraph_fig_ax() -> Tuple[MyGraphicsLayoutWidget, pg.PlotItem]: """ Return a formated fig and ax pyqtgraph for a basic plot """ diff --git a/autolab/core/gui/monitoring/figure.py b/autolab/core/gui/monitoring/figure.py index f09bcb6d..487b334e 100644 --- a/autolab/core/gui/monitoring/figure.py +++ b/autolab/core/gui/monitoring/figure.py @@ -140,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/plotting/figure.py b/autolab/core/gui/plotting/figure.py index 5a649e05..bab3ff27 100644 --- a/autolab/core/gui/plotting/figure.py +++ b/autolab/core/gui/plotting/figure.py @@ -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/scanning/figure.py b/autolab/core/gui/scanning/figure.py index d5037609..16cac5c2 100644 --- a/autolab/core/gui/scanning/figure.py +++ b/autolab/core/gui/scanning/figure.py @@ -6,6 +6,7 @@ """ import os +from typing import List import numpy as np import pandas as pd @@ -42,7 +43,16 @@ def __init__(self, gui: QtWidgets.QMainWindow): self.gui.variable_y_comboBox.activated.connect( self.variableChanged) - self.gui.variable_x2_checkBox.stateChanged.connect(self.reloadData) + 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}') @@ -153,8 +163,10 @@ def dataframe_comboBoxCurrentChanged(self): 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 @@ -206,12 +218,11 @@ def resetCheckBoxMenuID(self): # AXE LABEL ########################################################################### - def setLabel(self, axe: str, value: str, ax=None): + def setLabel(self, axe: str, value: str): """ This function changes the label of the given axis """ - if ax is None: ax = self.ax axes = {'x':'bottom', 'y':'left'} if value == '': value = ' ' - ax.setLabel(axes[axe], value, **{'color':0.4, 'font-size': '12pt'}) + self.ax.setLabel(axes[axe], value, **{'color':0.4, 'font-size': '12pt'}) # PLOT DATA ########################################################################### @@ -223,7 +234,10 @@ def clearData(self): except: pass self.curves = [] self.figMap.clear() - self.fig.img.hide() # OPTIMIZE: would be better to erase data + + 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 @@ -267,7 +281,7 @@ def reloadData(self): else: var_to_display = [variable_x, variable_y] - data: pd.DataFrame = self.gui.dataManager.getData( + data: List[pd.DataFrame] = self.gui.dataManager.getData( nbtraces_temp, var_to_display, selectedData=selectedData, data_name=data_name) @@ -297,33 +311,41 @@ def reloadData(self): self.figMap.hide() self.fig.show() - if self.ax.isVisible(): + if not self.fig.colorbar.isVisible(): self.fig.colorbar.show() - self.fig.ax_img.show() - self.ax.hide() - self.setLabel('x', variable_x, ax=self.fig.ax_img) - self.setLabel('y', variable_x2, ax=self.fig.ax_img) + self.setLabel('x', variable_x) + self.setLabel('y', variable_x2) if variable_x == variable_x2: - self.fig.img.hide() return None # Data if len(data) == 0: - self.fig.img.hide() return None subdata: pd.DataFrame = data[-1] # Only plot last scan if subdata is None: - self.fig.img.hide() return None subdata = subdata.astype(float) - pivot_table = subdata.pivot( - index=variable_x, columns=variable_x2, values=variable_y) + try: + pivot_table = subdata.pivot( + index=variable_x, columns=variable_x2, values=variable_y) + except ValueError: # if more than 2 parameters + ## TODO: Solution is to set all the other parameters to a constant value + # data = self.gui.dataManager.getData( + # nbtraces_temp, var_to_display+['parameter_test',], + # selectedData=selectedData, data_name=data_name) + + # subdata: pd.DataFrame = data[-1] + # subdata = subdata[subdata['parameter_test'] == 0] + # pivot_table = subdata.pivot( + # index=variable_x, columns=variable_x2, values=variable_y) + # self.clearData() + return None # Extract data for plotting x = np.array(pivot_table.columns) @@ -343,10 +365,12 @@ def reloadData(self): self.gui.label_scan_2D.hide() self.gui.label_y_axis.setText('Y axis') - if not self.ax.isVisible(): - self.fig.colorbar.hide() - self.fig.ax_img.hide() - self.ax.show() + 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 image_data = np.empty((len(data), *temp_data.shape)) @@ -465,7 +489,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/main.py b/autolab/core/gui/scanning/main.py index 18c65baa..2d58398a 100644 --- a/autolab/core/gui/scanning/main.py +++ b/autolab/core/gui/scanning/main.py @@ -122,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) @@ -144,15 +144,12 @@ def clear(self): 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.variable_x_comboBox.clear() self.variable_x2_comboBox.clear() self.variable_y_comboBox.clear() - self.variable_x2_comboBox.hide() - self.label_scan_2D.hide() + self.variable_x2_checkBox.show() self.data_comboBox.clear() self.data_comboBox.hide() self.save_pushButton.setEnabled(False) diff --git a/autolab/core/gui/scanning/parameter.py b/autolab/core/gui/scanning/parameter.py index fd21af36..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) From f612a12bf4e83e9a0d840057ac2f6cc636f85a97 Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Tue, 4 Jun 2024 21:51:50 +0200 Subject: [PATCH 14/29] Add button to download drivers --- autolab/core/gui/controlcenter/main.py | 16 +++++- autolab/core/repository.py | 77 +++++++++++++++++--------- 2 files changed, 64 insertions(+), 29 deletions(-) diff --git a/autolab/core/gui/controlcenter/main.py b/autolab/core/gui/controlcenter/main.py index ec9a88e3..e5dbf68f 100644 --- a/autolab/core/gui/controlcenter/main.py +++ b/autolab/core/gui/controlcenter/main.py @@ -26,11 +26,12 @@ from ..scanning.main import Scanner from ..plotting.main import Plotter from ..variables import VARIABLES -from ..GUI_utilities import get_font_size, setLineEditBackground +from ..GUI_utilities import get_font_size from ..icons import icons from ... import devices, drivers, web, paths, config, utilities -from .... import __version__ +from ...repository import _install_drivers_custom from ...web import project_url, drivers_url, doc_url +from .... import __version__ class OutputWrapper(QtCore.QObject): @@ -173,11 +174,16 @@ def startDrag(self, event): devicesConfig.triggered.connect(self.openDevicesConfig) devicesConfig.setStatusTip("Open the devices configuration file") - addDeviceAction = settingsMenu.addAction('Add new device') + addDeviceAction = settingsMenu.addAction('Add device') addDeviceAction.setIcon(QtGui.QIcon(icons['add'])) addDeviceAction.triggered.connect(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') @@ -477,6 +483,10 @@ def openAddDevice(self): self.addDevice.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) self.addDevice.activateWindow() + def downloadDriver(self): + """ This function open the download driver window. """ + _install_drivers_custom(parent=self) + @staticmethod def openAutolabConfig(): """ Open the Autolab configuration file """ diff --git a/autolab/core/repository.py b/autolab/core/repository.py index f3f2811d..5f900662 100644 --- a/autolab/core/repository.py +++ b/autolab/core/repository.py @@ -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 @@ -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 - super().__init__() + 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,43 @@ 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] - - for driver in list_driver_to_download: - _download_driver(self.url, driver, self.OUTPUT_DIR, _print=_print) - print('Done!') - - 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) + 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 setStatus(self, message: str, timeout: int = 0, stdout: bool = True): + """ Modify the message displayed in the status bar and add error message to logger """ + self.statusBar.showMessage(message, timeout) + if not stdout: print(message, file=sys.stderr) + + if parent is None: + if _print: print("Open driver installer") + app = QtWidgets.QApplication.instance() + if app is None: app = QtWidgets.QApplication([]) + + driverInstaller = DriverInstaller( + official_url, list_driver, official_folder, parent=parent) driverInstaller.show() - app.exec_() + if parent is None: app.exec() # Update available drivers drivers.update_drivers_paths() From f185080c9718dd2b991e2ee05041575aae9b3d52 Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Wed, 5 Jun 2024 10:46:54 +0200 Subject: [PATCH 15/29] Can open plotter directly + fixes - Can open plotter using autolab.plotter() - Fixe bug introduced in f612a12 driver installer not closing correctly - Fixe bug introduced in ae66784 not displaying plotter cursors --- autolab/__init__.py | 3 +- autolab/_entry_script.py | 3 ++ autolab/core/gui/__init__.py | 24 ++++++++-- autolab/core/gui/controlcenter/main.py | 3 +- autolab/core/gui/plotting/interface.ui | 2 +- autolab/core/gui/plotting/main.py | 66 +++++++++++++++----------- autolab/core/repository.py | 7 +++ 7 files changed, 72 insertions(+), 36 deletions(-) diff --git a/autolab/__init__.py b/autolab/__init__.py index e614be45..9431cd13 100644 --- a/autolab/__init__.py +++ b/autolab/__init__.py @@ -54,7 +54,8 @@ from .core.server import Server as server # GUI -from .core.gui import start as gui +from .core.gui import gui +from .core.gui import plotter # Repository from .core.repository import install_drivers diff --git a/autolab/_entry_script.py b/autolab/_entry_script.py index 44e0d81f..dbc3b5e9 100644 --- a/autolab/_entry_script.py +++ b/autolab/_entry_script.py @@ -21,6 +21,7 @@ def print_help(): print() print('Commands:') print(' gui Start the Graphical User Interface') + print(' plotter Start the Plotter') print(' install_drivers Install drivers from GitHub') print(' driver Driver interface') print(' device Device interface') @@ -54,6 +55,8 @@ def main(): autolab.report() elif command == 'gui': # GUI autolab.gui() + elif command == 'plotter': # Plotter + autolab.plotter() elif command == 'infos': autolab.infos() elif command == 'install_drivers': diff --git a/autolab/core/gui/__init__.py b/autolab/core/gui/__init__.py index 60e26b5f..88903efe 100644 --- a/autolab/core/gui/__init__.py +++ b/autolab/core/gui/__init__.py @@ -20,8 +20,18 @@ # t.start() -def start(): +def gui(): """ Open the Autolab GUI """ + _start('main') + + +def plotter(): + """ Open the Autolab Plotter """ + _start('plotter') + + +def _start(gui: str = 'main'): + """ Open the Autolab GUI if gui='main' or the Autolab Plotter if gui='plotter' """ import os from ..config import get_GUI_config @@ -66,9 +76,15 @@ def start(): font.setPointSize(int(GUI_config['font_size'])) app.setFont(font) - from .controlcenter.main import ControlCenter - gui = ControlCenter() - gui.initialize() + if gui == 'main': + from .controlcenter.main import ControlCenter + gui = ControlCenter() + gui.initialize() + elif gui == 'plotter': + from .plotting.main import Plotter + gui = Plotter(None) + else: + raise ValueError(f"gui accept either 'main' or 'plotter', given {gui}") gui.show() app.exec() diff --git a/autolab/core/gui/controlcenter/main.py b/autolab/core/gui/controlcenter/main.py index e5dbf68f..7f88c6ec 100644 --- a/autolab/core/gui/controlcenter/main.py +++ b/autolab/core/gui/controlcenter/main.py @@ -18,7 +18,6 @@ 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 @@ -578,7 +577,7 @@ def closeEvent(self, event): 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 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 8b8dcfa2..2259fa66 100644 --- a/autolab/core/gui/plotting/main.py +++ b/autolab/core/gui/plotting/main.py @@ -108,31 +108,35 @@ def __init__(self, mainGui): 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 @@ -185,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: @@ -196,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 """ @@ -448,11 +452,17 @@ 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 """ diff --git a/autolab/core/repository.py b/autolab/core/repository.py index 5f900662..10377269 100644 --- a/autolab/core/repository.py +++ b/autolab/core/repository.py @@ -358,6 +358,13 @@ def installListDriver(self): # self.setStatus(e, 10000, False) self.setStatus('Finished!', 5000) + def closeEvent(self, event): + """ This function does some steps before the window is really killed """ + super().closeEvent(event) + + if self.gui is None: + QtWidgets.QApplication.quit() # close the app + def setStatus(self, message: str, timeout: int = 0, stdout: bool = True): """ Modify the message displayed in the status bar and add error message to logger """ self.statusBar.showMessage(message, timeout) From e793515c03faea11d9063a721cf5dea44c61b546 Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Wed, 5 Jun 2024 12:06:04 +0200 Subject: [PATCH 16/29] try default connection if do not exists --- autolab/core/drivers.py | 148 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 2 deletions(-) diff --git a/autolab/core/drivers.py b/autolab/core/drivers.py index ccae614d..7ff55693 100644 --- a/autolab/core/drivers.py +++ b/autolab/core/drivers.py @@ -14,6 +14,8 @@ from . import paths, server +known_connections = ['DEFAULT', 'VISA', 'GPIB', 'USB', 'SOCKET'] + # ============================================================================= # DRIVERS INSTANTIATION # ============================================================================= @@ -161,8 +163,150 @@ 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 ''' - 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}') + if connection in get_connection_names(driver_lib): + return getattr(driver_lib, f'Driver_{connection}') + elif connection in known_connections: + print(f'Warning, {connection} not find in {driver_lib.__name__} but will try to connect using default connection') + return create_default_driver_conn(driver_lib, connection) + else: + 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)}" + + +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 def get_module_class(driver_lib: ModuleType, module_name: str): From 0db66a480c4e3e987e4f1fb2042bc3baed31c9ea Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Thu, 6 Jun 2024 18:44:25 +0200 Subject: [PATCH 17/29] Add standalone Monitor - autolab.monitor(variable) will open a monitor, blocking the code execution so can't use multiple monitors :'( - minor code cleaning --- autolab/__init__.py | 3 +-- autolab/core/drivers.py | 9 +++++---- autolab/core/gui/__init__.py | 22 +++++++++++++++++++--- autolab/core/gui/controlcenter/main.py | 2 +- autolab/core/gui/monitoring/main.py | 17 +++++++++++++++-- 5 files changed, 41 insertions(+), 12 deletions(-) diff --git a/autolab/__init__.py b/autolab/__init__.py index 9431cd13..f65092ae 100644 --- a/autolab/__init__.py +++ b/autolab/__init__.py @@ -54,8 +54,7 @@ from .core.server import Server as server # GUI -from .core.gui import gui -from .core.gui import plotter +from .core.gui import gui, plotter, monitor # Repository from .core.repository import install_drivers diff --git a/autolab/core/drivers.py b/autolab/core/drivers.py index 7ff55693..f40cf922 100644 --- a/autolab/core/drivers.py +++ b/autolab/core/drivers.py @@ -165,11 +165,12 @@ 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}') - elif connection in known_connections: + + if connection in known_connections: print(f'Warning, {connection} not find in {driver_lib.__name__} but will try to connect using default connection') return create_default_driver_conn(driver_lib, connection) - else: - 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)}" + + 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)}" def create_default_driver_conn(driver_lib: ModuleType, connection: str) -> Type: @@ -288,7 +289,7 @@ def __init__(self, address='192.168.0.8', **kwargs): self.BUFFER_SIZE = 40000 self.controller = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.controller.connect((self.ADDRESS,int(self.PORT))) + self.controller.connect((self.ADDRESS, int(self.PORT))) Driver.__init__(self) diff --git a/autolab/core/gui/__init__.py b/autolab/core/gui/__init__.py index 88903efe..b4f87eb1 100644 --- a/autolab/core/gui/__init__.py +++ b/autolab/core/gui/__init__.py @@ -30,8 +30,20 @@ def plotter(): _start('plotter') -def _start(gui: str = 'main'): - """ Open the Autolab GUI if gui='main' or the Autolab Plotter if gui='plotter' """ +def monitor(var): + """ Open the Autolab Monitor for variable var """ + from .variables import Variable + + class temp: pass + name = var.name if isinstance(var, Variable) else 'Variable' + item = temp() + item.variable = Variable(name, var) + _start('monitor', item=item) + + +def _start(gui: str, **kwargs): + """ Open the Autolab GUI if gui='main', the Plotter if gui='plotter' + or the Monitor if gui='monitor' """ import os from ..config import get_GUI_config @@ -83,8 +95,12 @@ def _start(gui: str = 'main'): 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) else: - raise ValueError(f"gui accept either 'main' or 'plotter', given {gui}") + raise ValueError(f"gui accept either 'main', 'plotter' or 'monitor', given {gui}") gui.show() app.exec() diff --git a/autolab/core/gui/controlcenter/main.py b/autolab/core/gui/controlcenter/main.py index 7f88c6ec..b33f7887 100644 --- a/autolab/core/gui/controlcenter/main.py +++ b/autolab/core/gui/controlcenter/main.py @@ -377,7 +377,7 @@ def rightClick(self, position: QtCore.QPoint): def addDeviceMenu(self, position: QtCore.QPoint): """ Open menu to ask if want to add new device """ menu = QtWidgets.QMenu() - addDeviceChoice = menu.addAction('Add new device') + addDeviceChoice = menu.addAction('Add device') addDeviceChoice.setIcon(QtGui.QIcon(icons['add'])) choice = menu.exec_(self.tree.viewport().mapToGlobal(position)) diff --git a/autolab/core/gui/monitoring/main.py b/autolab/core/gui/monitoring/main.py index 84ac8320..77d19ccf 100644 --- a/autolab/core/gui/monitoring/main.py +++ b/autolab/core/gui/monitoring/main.py @@ -22,7 +22,7 @@ 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 @@ -179,14 +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() + 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 """ From 9c458ad7161b9db42dac4640449b9c15ff736819 Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Fri, 7 Jun 2024 09:38:14 +0200 Subject: [PATCH 18/29] Add standalone add_device autolab.add_device() opens the utility to add a device to the config file - fixe issue with add_device using visa (range) --- autolab/__init__.py | 2 +- autolab/core/gui/__init__.py | 10 +++++++++- autolab/core/gui/controlcenter/main.py | 9 ++++++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/autolab/__init__.py b/autolab/__init__.py index f65092ae..416a3308 100644 --- a/autolab/__init__.py +++ b/autolab/__init__.py @@ -54,7 +54,7 @@ from .core.server import Server as server # GUI -from .core.gui import gui, plotter, monitor +from .core.gui import gui, plotter, monitor, add_device # Repository from .core.repository import install_drivers diff --git a/autolab/core/gui/__init__.py b/autolab/core/gui/__init__.py index b4f87eb1..90f7acfb 100644 --- a/autolab/core/gui/__init__.py +++ b/autolab/core/gui/__init__.py @@ -41,6 +41,11 @@ class temp: pass _start('monitor', item=item) +def add_device(): + """ Open the utility to add a device """ + _start('add_device') + + def _start(gui: str, **kwargs): """ Open the Autolab GUI if gui='main', the Plotter if gui='plotter' or the Monitor if gui='monitor' """ @@ -99,8 +104,11 @@ def _start(gui: str, **kwargs): from .monitoring.main import Monitor item = kwargs.get('item') gui = Monitor(item) + elif gui == 'add_device': + from .controlcenter.main import addDeviceWindow + gui = addDeviceWindow() else: - raise ValueError(f"gui accept either 'main', 'plotter' or 'monitor', given {gui}") + raise ValueError(f"gui accept either 'main', 'plotter', 'monitor' or 'add_device', given {gui}") gui.show() app.exec() diff --git a/autolab/core/gui/controlcenter/main.py b/autolab/core/gui/controlcenter/main.py index b33f7887..5d44d788 100644 --- a/autolab/core/gui/controlcenter/main.py +++ b/autolab/core/gui/controlcenter/main.py @@ -746,7 +746,7 @@ def addButtonClicked(self): device_dict['connection'] = conn for layout in (self.layoutDriverArgs, self.layoutDriverOtherArgs): - for i in range(0, layout.count(), 2): + 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 @@ -763,7 +763,7 @@ def addButtonClicked(self): device_config.update(new_device) config.save_config('devices', device_config) - self.mainGui.initialize() + if hasattr(self.mainGui, 'initialize'): self.mainGui.initialize() self.close() @@ -886,7 +886,10 @@ def setStatus(self, message: str, timeout: int = 0, stdout: bool = True): def closeEvent(self, event): """ Does some steps before the window is really killed """ # Delete reference of this window in the control center - self.mainGui.clearAddDevice() + if hasattr(self.mainGui, 'clearAddDevice'): self.mainGui.clearAddDevice() + + if self.mainGui is None: + QtWidgets.QApplication.quit() # close the monitor app class AboutWindow(QtWidgets.QMainWindow): From c904e94ed995068c5c9939401128e76c11e8a7ec Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Fri, 7 Jun 2024 17:09:52 +0200 Subject: [PATCH 19/29] Can modify device connection - Use right click on device not instantiated to modify its connection - fixe bug in Variable when using name with special characters --- autolab/core/gui/controlcenter/main.py | 51 +++++++++++++++++-- autolab/core/gui/controlcenter/treewidgets.py | 8 +++ autolab/core/gui/variables.py | 5 +- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/autolab/core/gui/controlcenter/main.py b/autolab/core/gui/controlcenter/main.py index 5d44d788..c183d0bc 100644 --- a/autolab/core/gui/controlcenter/main.py +++ b/autolab/core/gui/controlcenter/main.py @@ -175,7 +175,7 @@ def startDrag(self, event): addDeviceAction = settingsMenu.addAction('Add device') addDeviceAction.setIcon(QtGui.QIcon(icons['add'])) - addDeviceAction.triggered.connect(self.openAddDevice) + addDeviceAction.triggered.connect(lambda state: self.openAddDevice()) addDeviceAction.setStatusTip("Open the utility to add a device") downloadDriverAction = settingsMenu.addAction('Download drivers') @@ -469,7 +469,7 @@ def openAbout(self): self.about.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) self.about.activateWindow() - def openAddDevice(self): + 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: @@ -482,6 +482,12 @@ def openAddDevice(self): self.addDevice.windowState() & ~QtCore.Qt.WindowMinimized | QtCore.Qt.WindowActive) self.addDevice.activateWindow() + # Modify existing device + if item is not None: + name = item.name + conf = devices.get_final_device_config(item.name) + self.addDevice.modify(name, conf) + def downloadDriver(self): """ This function open the download driver window. """ _install_drivers_custom(parent=self) @@ -705,10 +711,10 @@ def __init__(self, parent: QtWidgets.QMainWindow = None): layoutButton = QtWidgets.QHBoxLayout() layoutWindow.addLayout(layoutButton) - addButton = QtWidgets.QPushButton('Add device') - addButton.clicked.connect(self.addButtonClicked) + self.addButton = QtWidgets.QPushButton('Add device') + self.addButton.clicked.connect(self.addButtonClicked) - layoutButton.addWidget(addButton) + layoutButton.addWidget(self.addButton) # update driver name combobox self.driverChanged() @@ -767,6 +773,41 @@ def addButtonClicked(self): self.close() + def modify(self, nickname: str, conf: dict): + """ Modify existing driver (not optimized) """ + + self.setWindowTitle(f'Autolab - Modify device {nickname}') + self.addButton.setText('Modify device') + + self.deviceNickname.setText(nickname) + self.deviceNickname.setEnabled(False) + driv = conf.pop('driver') + conn = conf.pop('connection') + index = self.driversComboBox.findText(driv) + self.driversComboBox.setCurrentIndex(index) + self.driverChanged() + index = self.connectionComboBox.findText(conn) + self.connectionComboBox.setCurrentIndex(index) + self.connectionChanged() + + 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) + + # OPTIMIZE: Calling driverChanged will add slot1 even if not in config (same for optional args) + for i in range(self.layoutOptionalArg.count()): + layout = self.layoutOptionalArg.itemAt(i).layout() + key = layout.itemAt(0).widget().text() + if key in conf: + layout.itemAt(1).widget().setText(conf[key]) + conf.pop(key) + + for key, val in conf.items(): + self.addOptionalArgClicked(key, val) + def driverChanged(self): """ Update driver information """ driver_name = self.driversComboBox.currentText() diff --git a/autolab/core/gui/controlcenter/treewidgets.py b/autolab/core/gui/controlcenter/treewidgets.py index eb9a5865..fb9e9cd4 100644 --- a/autolab/core/gui/controlcenter/treewidgets.py +++ b/autolab/core/gui/controlcenter/treewidgets.py @@ -98,7 +98,15 @@ def menu(self, position: QtCore.QPoint): 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): """ This class represents an action in an item of the tree """ diff --git a/autolab/core/gui/variables.py b/autolab/core/gui/variables.py index 1360aab8..2240da59 100644 --- a/autolab/core/gui/variables.py +++ b/autolab/core/gui/variables.py @@ -18,7 +18,7 @@ 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 @@ -130,8 +130,7 @@ def rename_variable(name, new_name): def set_variable(name: str, value: Any): ''' Create or modify a Variable with provided name and value ''' - 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}'" + name = clean_string(name) if is_Variable(value): var = value From 0717f5997864b0e7dee76bd41af1e8e4c801d9a6 Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Mon, 10 Jun 2024 20:32:18 +0200 Subject: [PATCH 20/29] Standalone about, slider, variables_menu - add standalone gui: autolab.about, autolab.slider, autolab.variables_menu - Now don't add default slot_config when modifying device with slots - Avoid error if try to modify device that has been removed from config file inbetween - Avoid error if try to add device with VISA but no address arg --- autolab/__init__.py | 2 +- autolab/_entry_script.py | 24 +++++----- autolab/core/gui/__init__.py | 59 +++++++++++++++++++----- autolab/core/gui/controlcenter/main.py | 63 +++++++++++++++++++------- autolab/core/gui/slider.py | 6 ++- autolab/core/gui/variables.py | 8 +++- 6 files changed, 118 insertions(+), 44 deletions(-) diff --git a/autolab/__init__.py b/autolab/__init__.py index 416a3308..88835ea5 100644 --- a/autolab/__init__.py +++ b/autolab/__init__.py @@ -54,7 +54,7 @@ from .core.server import Server as server # GUI -from .core.gui import gui, plotter, monitor, add_device +from .core.gui import gui, plotter, monitor, slider, add_device, about, variables_menu # Repository from .core.repository import install_drivers diff --git a/autolab/_entry_script.py b/autolab/_entry_script.py index dbc3b5e9..590cfd4e 100644 --- a/autolab/_entry_script.py +++ b/autolab/_entry_script.py @@ -22,6 +22,7 @@ def print_help(): 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') @@ -49,22 +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 == 'plotter': # Plotter - autolab.plotter() - 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 ^^") diff --git a/autolab/core/gui/__init__.py b/autolab/core/gui/__init__.py index 90f7acfb..90febcdb 100644 --- a/autolab/core/gui/__init__.py +++ b/autolab/core/gui/__init__.py @@ -20,9 +20,24 @@ # t.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('main') + _start('gui') def plotter(): @@ -32,22 +47,32 @@ def plotter(): def monitor(var): """ Open the Autolab Monitor for variable var """ - from .variables import Variable - - class temp: pass - name = var.name if isinstance(var, Variable) else 'Variable' - item = temp() - item.variable = Variable(name, var) + item = _create_item(var) _start('monitor', item=item) -def add_device(): +def slider(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='main', the Plotter if gui='plotter' + """ Open the Autolab GUI if gui='gui', the Plotter if gui='plotter' or the Monitor if gui='monitor' """ import os @@ -93,7 +118,7 @@ def _start(gui: str, **kwargs): font.setPointSize(int(GUI_config['font_size'])) app.setFont(font) - if gui == 'main': + if gui == 'gui': from .controlcenter.main import ControlCenter gui = ControlCenter() gui.initialize() @@ -104,11 +129,23 @@ def _start(gui: str, **kwargs): 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) 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(f"gui accept either 'main', 'plotter', 'monitor' or 'add_device', given {gui}") + 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 c183d0bc..70999f7f 100644 --- a/autolab/core/gui/controlcenter/main.py +++ b/autolab/core/gui/controlcenter/main.py @@ -485,8 +485,12 @@ def openAddDevice(self, item: QtWidgets.QTreeWidgetItem = None): # Modify existing device if item is not None: name = item.name - conf = devices.get_final_device_config(item.name) - self.addDevice.modify(name, conf) + 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. """ @@ -618,6 +622,7 @@ 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() @@ -776,20 +781,31 @@ def addButtonClicked(self): def modify(self, nickname: str, conf: dict): """ Modify existing driver (not optimized) """ - self.setWindowTitle(f'Autolab - Modify device {nickname}') + self.setWindowTitle('Autolab - Modify device') self.addButton.setText('Modify device') self.deviceNickname.setText(nickname) self.deviceNickname.setEnabled(False) - driv = conf.pop('driver') + driver_name = conf.pop('driver') conn = conf.pop('connection') - index = self.driversComboBox.findText(driv) + index = self.driversComboBox.findText(driver_name) self.driversComboBox.setCurrentIndex(index) self.driverChanged() 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() @@ -797,14 +813,22 @@ def modify(self, nickname: str, conf: dict): layout.itemAt(i+1).widget().setText(conf[key]) conf.pop(key) - # OPTIMIZE: Calling driverChanged will add slot1 even if not in config (same for optional args) - for i in range(self.layoutOptionalArg.count()): + # Update optional args + for i in reversed(range(self.layoutOptionalArg.count())): layout = self.layoutOptionalArg.itemAt(i).layout() key = layout.itemAt(0).widget().text() - if key in conf: + 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) @@ -856,7 +880,8 @@ def driverChanged(self): drivers.get_connection_class(driver_lib, conn)) # populate layoutDriverOtherArgs - other_args = drivers.get_class_args(drivers.get_driver_class(driver_lib)) + 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() @@ -874,8 +899,8 @@ def driverChanged(self): layout.setParent(None) # populate layoutOptionalArg - if hasattr(drivers.get_driver_class(driver_lib), 'slot_config'): - self.addOptionalArgClicked('slot1', f'{drivers.get_driver_class(driver_lib).slot_config}') + if hasattr(driver_class, 'slot_config'): + self.addOptionalArgClicked('slot1', f'{driver_class.slot_config}') self.addOptionalArgClicked('slot1_name', 'my_') def connectionChanged(self): @@ -895,6 +920,7 @@ def connectionChanged(self): 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() @@ -913,12 +939,13 @@ def connectionChanged(self): widget.clear() conn_list = ('Available connections',) + tuple(self.rm.list_resources()) widget.addItems(conn_list) - widget.activated.connect( - lambda item, conn_widget=conn_widget: conn_widget.setText( - widget.currentText()) if widget.currentText() != 'Available connections' else conn_widget.text()) + 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) @@ -940,6 +967,7 @@ 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() @@ -1030,7 +1058,10 @@ def __init__(self, parent: QtWidgets.QMainWindow = None): def closeEvent(self, event): """ Does some steps before the window is really killed """ # Delete reference of this window in the control center - self.mainGui.clearAbout() + if hasattr(self.mainGui, 'clearAbout'): self.mainGui.clearAbout() + + if self.mainGui is None: + QtWidgets.QApplication.quit() # close the about app def get_versions() -> dict: diff --git a/autolab/core/gui/slider.py b/autolab/core/gui/slider.py index a7a0f8c1..9c639f90 100644 --- a/autolab/core/gui/slider.py +++ b/autolab/core/gui/slider.py @@ -18,6 +18,8 @@ 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 """ + + self.gui = item if isinstance(item, QtWidgets.QTreeWidgetItem) else None super().__init__() self.item = item self.resize(self.minimumSizeHint()) @@ -268,8 +270,10 @@ def badType(self): def closeEvent(self, event): """ This function does some steps before the window is really killed """ - self.item.clearSlider() + if hasattr(self.item, 'clearSlider'): self.item.clearSlider() + if self.gui is None: + QtWidgets.QApplication.quit() # close the slider app class ProxyStyle(QtWidgets.QProxyStyle): diff --git a/autolab/core/gui/variables.py b/autolab/core/gui/variables.py index 2240da59..103bb7ad 100644 --- a/autolab/core/gui/variables.py +++ b/autolab/core/gui/variables.py @@ -72,9 +72,10 @@ def update_allowed_dict() -> dict: # TODO: refresh menu display by looking if has eval (no -> can refresh) # TODO add read signal to update gui (seperate class for event and use it on itemwidget creation to change setText with new value) class Variable(): + """ Class used to control basic variable """ def __init__(self, name: str, var: Any): - + """ name: name of the variable, var: value of the variable """ self.refresh(name, var) def refresh(self, name: str, var: Any): @@ -310,6 +311,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() @@ -486,7 +488,7 @@ 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 monitor in list(self.monitors.values()): @@ -500,6 +502,8 @@ def closeEvent(self, event): super().closeEvent(event) + if self.gui is None: + QtWidgets.QApplication.quit() # close the variable app class MyQTreeWidgetItem(QtWidgets.QTreeWidgetItem): From 0e9dd321c231ae7bc761f8c205c4cd979bdaa5ec Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Wed, 12 Jun 2024 22:11:00 +0200 Subject: [PATCH 21/29] Fixe thread issue, add doc - fixe issue introduced in 198d633 preventing to have multiple threads coming from the same tree item at the same time - remove signals from Variable on device closure (avoid c++ errors) - now check qt_object_exists(statusbar) in thread (avoid c++ errors) - add docs --- autolab/core/devices.py | 18 +++++++++-- autolab/core/drivers.py | 2 +- autolab/core/gui/__init__.py | 1 + autolab/core/gui/controlcenter/main.py | 9 +++--- autolab/core/gui/controlcenter/thread.py | 32 ++++++++++++++----- autolab/core/gui/controlcenter/treewidgets.py | 2 +- autolab/core/gui/scanning/config.py | 4 +-- docs/about.rst | 1 + docs/gui/scanning.rst | 12 ++++--- docs/high_level.rst | 4 +-- docs/local_config.rst | 6 ++++ docs/low_level/index.rst | 2 +- docs/low_level/open_and_use.rst | 2 +- 13 files changed, 67 insertions(+), 28 deletions(-) diff --git a/autolab/core/devices.py b/autolab/core/devices.py index d7cde188..fed3d6e1 100644 --- a/autolab/core/devices.py +++ b/autolab/core/devices.py @@ -9,7 +9,7 @@ from . import drivers from . import config -from .elements import Module +from .elements import Module, Element # Storage of the devices DEVICES = {} @@ -32,8 +32,20 @@ def __init__(self, device_name: str, instance, device_config: dict): 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,8 +58,8 @@ def __dir__(self): # DEVICE GET FUNCTION # ============================================================================= -def get_device_by_address(address: str) -> Union[Device, None]: - """ Returns the Device located at the provided address if exists """ +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]) diff --git a/autolab/core/drivers.py b/autolab/core/drivers.py index f40cf922..6e69de71 100644 --- a/autolab/core/drivers.py +++ b/autolab/core/drivers.py @@ -139,7 +139,7 @@ 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): try: diff --git a/autolab/core/gui/__init__.py b/autolab/core/gui/__init__.py index 90febcdb..0980bd79 100644 --- a/autolab/core/gui/__init__.py +++ b/autolab/core/gui/__init__.py @@ -52,6 +52,7 @@ def monitor(var): def slider(var): + """ Open a slider for variable var """ item = _create_item(var) _start('slider', item=item) diff --git a/autolab/core/gui/controlcenter/main.py b/autolab/core/gui/controlcenter/main.py index 70999f7f..a79490eb 100644 --- a/autolab/core/gui/controlcenter/main.py +++ b/autolab/core/gui/controlcenter/main.py @@ -389,16 +389,17 @@ 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: - self.threadManager.threads[id(item)].endSignal.emit(f'Cancel loading device {item.name}') - self.threadManager.threads[id(item)].terminate() + 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. diff --git a/autolab/core/gui/controlcenter/thread.py b/autolab/core/gui/controlcenter/thread.py index 416c5b54..3fdffb93 100644 --- a/autolab/core/gui/controlcenter/thread.py +++ b/autolab/core/gui/controlcenter/thread.py @@ -5,6 +5,7 @@ @author: qchat """ +import sys import inspect from typing import Any @@ -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,10 +55,14 @@ def start(self, item: QtWidgets.QTreeWidgetItem, intType: str, value = None): self.gui.setStatus(status) # Thread configuration - tid = id(item) - assert tid not in self.threads + 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)) @@ -68,15 +74,22 @@ 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 corresponding item """ if error: - self.gui.setStatus(str(error), 10000, False) - - if tid in self.gui.threadItemDict: - self.gui.threadItemDict.pop(tid) + 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: - self.gui.clearStatus() + 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): @@ -91,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) diff --git a/autolab/core/gui/controlcenter/treewidgets.py b/autolab/core/gui/controlcenter/treewidgets.py index fb9e9cd4..7fa4eb0f 100644 --- a/autolab/core/gui/controlcenter/treewidgets.py +++ b/autolab/core/gui/controlcenter/treewidgets.py @@ -89,7 +89,7 @@ def menu(self, position: QtCore.QPoint): self.removeChild(self.child(0)) self.loaded = False - elif id(self) in self.gui.threadManager.threads: + elif id(self) in self.gui.threadManager.threads_conn: menu = QtWidgets.QMenu() cancelDevice = menu.addAction(f"Cancel loading") cancelDevice.setIcon(QtGui.QIcon(icons['disconnect'])) diff --git a/autolab/core/gui/scanning/config.py b/autolab/core/gui/scanning/config.py index fc749e3c..faf16eef 100644 --- a/autolab/core/gui/scanning/config.py +++ b/autolab/core/gui/scanning/config.py @@ -930,7 +930,7 @@ def load_configPars(self, configPars: dict, append: bool = False): assert 'address' in param_pars, f"Missing address to {param_pars}" if param_pars['address'] == "None": element = None else: - element = devices.get_device_by_address(param_pars['address']) + element = devices.get_element_by_address(param_pars['address']) assert element is not None, f"Parameter {param_pars['address']} not found." param['element'] = element @@ -982,7 +982,7 @@ def load_configPars(self, configPars: dict, append: bool = False): assert step['stepType'] != 'recipe', "Removed the recipe in recipe feature!" element = address else: - element = devices.get_device_by_address(address) + element = devices.get_element_by_address(address) assert element is not None, f"Address {address} not found for step {i} ({name})." step['element'] = element diff --git a/docs/about.rst b/docs/about.rst index eab3d464..bf63660d 100644 --- a/docs/about.rst +++ b/docs/about.rst @@ -8,6 +8,7 @@ This Python package has been created in 2019 by `Quentin Chateiller `_. In 2023, Mathieu Jeannin from the `Odin team `_. joined the adventure. diff --git a/docs/gui/scanning.rst b/docs/gui/scanning.rst index ca1bb05c..18e941c9 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 selecting the **Log** option. It is also possible to use a custom array for the parameter using the **Custom** option. +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. 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/local_config.rst b/docs/local_config.rst index aa7354cc..6017d728 100644 --- a/docs/local_config.rst +++ b/docs/local_config.rst @@ -65,3 +65,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 + + >>> laserSource = autolab.add_device() 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 From 6303fc3db7dc53bc755a0a1cc1443f92aaca65a1 Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Mon, 17 Jun 2024 23:18:25 +0200 Subject: [PATCH 22/29] Can filter data in scanner + minors changes - Add filter option to scanner: can filter data of a scan to plot only relevant values (can plot a line from a 2D scan for a specific value) - Add TEST connection to driver for debugging purposes - Avoid error if modify device with bad connection - Avoid error if no data in 2D plot --- autolab/core/drivers.py | 39 ++++- autolab/core/gui/controlcenter/main.py | 24 ++- autolab/core/gui/scanning/data.py | 27 +++- autolab/core/gui/scanning/figure.py | 111 ++++++++++++- autolab/core/gui/scanning/interface.ui | 207 ++++++++++++++++++++++--- 5 files changed, 369 insertions(+), 39 deletions(-) diff --git a/autolab/core/drivers.py b/autolab/core/drivers.py index 6e69de71..351495d4 100644 --- a/autolab/core/drivers.py +++ b/autolab/core/drivers.py @@ -14,8 +14,6 @@ from . import paths, server -known_connections = ['DEFAULT', 'VISA', 'GPIB', 'USB', 'SOCKET'] - # ============================================================================= # DRIVERS INSTANTIATION # ============================================================================= @@ -117,7 +115,7 @@ def list_drivers() -> List[str]: # 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) @@ -166,9 +164,10 @@ def get_connection_class(driver_lib: ModuleType, connection: str) -> Type: if connection in get_connection_names(driver_lib): return getattr(driver_lib, f'Driver_{connection}') - if connection in known_connections: + 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 create_default_driver_conn(driver_lib, 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)}" @@ -309,8 +308,34 @@ def close(self): 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): +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}') diff --git a/autolab/core/gui/controlcenter/main.py b/autolab/core/gui/controlcenter/main.py index a79490eb..acb9ba0f 100644 --- a/autolab/core/gui/controlcenter/main.py +++ b/autolab/core/gui/controlcenter/main.py @@ -743,7 +743,7 @@ def addOptionalArgClicked(self, key: str = None, val: str = None): def removeOptionalArgClicked(self, layout): """ Remove optional argument layout """ - for j in reversed(range(3)): + for j in reversed(range(layout.count())): layout.itemAt(j).widget().setParent(None) layout.setParent(None) @@ -792,6 +792,20 @@ def modify(self, nickname: str, conf: dict): 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() @@ -877,8 +891,12 @@ def driverChanged(self): # used to skip doublon key conn = self.connectionComboBox.currentText() - connection_args = drivers.get_class_args( - drivers.get_connection_class(driver_lib, conn)) + 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) diff --git a/autolab/core/gui/scanning/data.py b/autolab/core/gui/scanning/data.py index fc914b7d..82b193a7 100644 --- a/autolab/core/gui/scanning/data.py +++ b/autolab/core/gui/scanning/data.py @@ -41,7 +41,8 @@ def __init__(self, gui: QtWidgets.QMainWindow): self.timer.timeout.connect(self.sync) def getData(self, nbDataset: int, varList: list, - selectedData: int = 0, data_name: str = "Scan") -> List[pd.DataFrame]: + selectedData: int = 0, data_name: str = "Scan", + filter_condition: List[dict] = []) -> List[pd.DataFrame]: """ This function returns to the figure manager the required data """ dataList = [] recipe_name = self.gui.scan_recipe_comboBox.currentText() @@ -55,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(varList, 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: @@ -318,7 +320,7 @@ def __init__(self, tempFolderPath: str, recipe_name: str, config: dict, self.data = pd.DataFrame(columns=self.header) def getData(self, varList: list, data_name: str = "Scan", - dataID: int = 0) -> pd.DataFrame: + 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": @@ -333,10 +335,25 @@ def getData(self, varList: list, data_name: str = "Scan", else: # Image return data + # Add var for filtering + for var_filter in filter_condition: + if var_filter['enable'] and var_filter['name'] not in varList and var_filter['name'] != '': + varList.append(var_filter['name']) + if any(map(lambda v: v in varList, list(data.columns))): - data2 = data.loc[:,~data.columns.duplicated()].copy() # unique data column + data = data.loc[:,~data.columns.duplicated()].copy() # unique data column unique_varList = list(dict.fromkeys(varList)) # unique varList - return data2.loc[:,unique_varList] + # Filter data + for var_filter in filter_condition: + if var_filter['enable'] and var_filter['name'] in data: + # Alternative could be: data.query('3 > var_x > 1') + 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] + + return data.loc[:,unique_varList] return None diff --git a/autolab/core/gui/scanning/figure.py b/autolab/core/gui/scanning/figure.py index 16cac5c2..c97896d4 100644 --- a/autolab/core/gui/scanning/figure.py +++ b/autolab/core/gui/scanning/figure.py @@ -11,7 +11,7 @@ import numpy as np import pandas as pd import pyqtgraph as pg -from qtpy import QtWidgets, QtGui +from qtpy import QtWidgets, QtGui, QtCore from .display import DisplayValues from ..GUI_utilities import (get_font_size, setLineEditBackground, @@ -26,6 +26,7 @@ def __init__(self, gui: QtWidgets.QMainWindow): self.gui = gui self.curves = [] + self.filter_condition = [] self._font_size = get_font_size() + 1 @@ -83,6 +84,105 @@ def __init__(self, gui: QtWidgets.QMainWindow): self.gui.toolButton.hide() self.clearMenuID() + # Filter widgets + self.gui.frameFilter.hide() + self.gui.checkBoxFilter.stateChanged.connect(self.checkBoxFilterChanged) + self.gui.addFilterPushButton.clicked.connect(self.addFilterClicked) + self.gui.splitterGraph.setSizes([9000, 1000]) # fixe wrong proportion + + def refreshFilters(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() + + enable = bool(layout.itemAt(0).widget().isChecked()) + name = layout.itemAt(1).widget().currentText() + condition_raw = layout.itemAt(2).widget().currentText() + value = float(layout.itemAt(3).widget().text()) + + convert_condition = { + '==': np.equal, '!=': np.not_equal, + '<': np.less, '<=': np.less_equal, + '>=': np.greater_equal, '>': np.greater + } + condition = convert_condition[condition_raw] + + filter_i = {'name': name, 'condition': condition, 'value': value, 'enable': enable} + + self.filter_condition.append(filter_i) + + self.reloadData() + + if self.gui.layoutFilter.count() <= 3: + min_size = 65 + self.gui.layoutFilter.count()*25 + else: + min_size = 65 + 3*25 + self.gui.frame_axis.setMinimumSize(0, min_size) + + def addFilterClicked(self): + """ Add filter condition """ + conditionLayout = QtWidgets.QHBoxLayout() + + filterCheckBox = QtWidgets.QCheckBox() + filterCheckBox.setMinimumSize(0, 21) + filterCheckBox.setToolTip('Toggle filter') + filterCheckBox.setCheckState(QtCore.Qt.Checked) + filterCheckBox.stateChanged.connect(self.refreshFilters) + conditionLayout.addWidget(filterCheckBox) + + VariablecomboBox = QtWidgets.QComboBox() + VariablecomboBox.setMinimumSize(0, 21) + AllItems = [self.gui.variable_x_comboBox.itemText(i) for i in range(self.gui.variable_x_comboBox.count())] + VariablecomboBox.addItems(AllItems) + VariablecomboBox.activated.connect(self.refreshFilters) + conditionLayout.addWidget(VariablecomboBox) + + FiltercomboBox = QtWidgets.QComboBox() + FiltercomboBox.setMinimumSize(0, 21) + AllItems = ['==', '!=', '<', '<=', '>=', '>'] + FiltercomboBox.addItems(AllItems) + FiltercomboBox.activated.connect(self.refreshFilters) + conditionLayout.addWidget(FiltercomboBox) + + # OPTIMIZE: would be nice to have a slider for value + valueWidget = QtWidgets.QLineEdit() + valueWidget.setMinimumSize(0, 21) + valueWidget.setText('0') + valueWidget.returnPressed.connect(self.refreshFilters) + conditionLayout.addWidget(valueWidget) + + removePushButton = QtWidgets.QPushButton() + removePushButton.setMinimumSize(0, 21) + removePushButton.setIcon(QtGui.QIcon(icons['remove'])) + removePushButton.clicked.connect( + lambda state, layout=conditionLayout: self.removeFilter(layout)) + conditionLayout.addWidget(removePushButton) + + self.gui.layoutFilter.insertLayout( + self.gui.layoutFilter.count()-1, conditionLayout) + self.refreshFilters() + + def removeFilter(self, layout): + """ Remove filter condition """ + for j in reversed(range(layout.count())): + layout.itemAt(j).widget().setParent(None) + layout.setParent(None) + self.refreshFilters() + + def checkBoxFilterChanged(self): + """ Show/hide filters frame and refresh filters """ + if self.gui.checkBoxFilter.isChecked(): + if not self.gui.frameFilter.isVisible(): + self.gui.frameFilter.show() + else: + if self.gui.frameFilter.isVisible(): + self.gui.frameFilter.hide() + + self.refreshFilters() + def clearMenuID(self): self.gui.toolButton.setText("Parameter") self.gui.toolButton.setPopupMode(QtWidgets.QToolButton.MenuButtonPopup) @@ -283,7 +383,8 @@ def reloadData(self): data: List[pd.DataFrame] = self.gui.dataManager.getData( nbtraces_temp, var_to_display, - selectedData=selectedData, data_name=data_name) + selectedData=selectedData, data_name=data_name, + filter_condition=self.filter_condition) # Plot data if data is not None: @@ -352,6 +453,9 @@ def reloadData(self): 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(): @@ -431,7 +535,8 @@ def reloadData(self): # Plot # OPTIMIZE: known issue but from pyqtgraph, error if use FFT on one point - 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) diff --git a/autolab/core/gui/scanning/interface.ui b/autolab/core/gui/scanning/interface.ui index 736526ce..c25b9615 100644 --- a/autolab/core/gui/scanning/interface.ui +++ b/autolab/core/gui/scanning/interface.ui @@ -7,7 +7,7 @@ 0 0 1200 - 750 + 740 @@ -69,8 +69,8 @@ 0 0 - 452 - 553 + 380 + 543 @@ -359,7 +359,7 @@ - + Qt::Vertical @@ -373,16 +373,23 @@ 65 - - - 16777215 - 65 - - QFrame::StyledPanel + + + + Qt::Vertical + + + + 20 + 40 + + + + @@ -405,7 +412,10 @@ X axis - Qt::AlignCenter + Qt::AlignBottom|Qt::AlignHCenter + + + 5 @@ -451,7 +461,10 @@ Y axis - Qt::AlignCenter + Qt::AlignBottom|Qt::AlignHCenter + + + 5 @@ -478,14 +491,144 @@ - - - Show scan data as 2D colormesh + + + Qt::Horizontal - - 2D plot + + QSizePolicy::Minimum - + + + 5 + 20 + + + + + + + + + + Filter data + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 8 + + + + + + + + + + + QFrame::StyledPanel + + + QFrame::Sunken + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + Add filter + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + Qt::Horizontal + + + QSizePolicy::Minimum + + + + 5 + 20 + + + + + + + + + + Show scan data as 2D colormesh + + + 2D plot + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 8 + + + + + @@ -497,7 +640,7 @@ - 10 + 20 20 @@ -514,7 +657,10 @@ Nb traces - Qt::AlignCenter + Qt::AlignBottom|Qt::AlignHCenter + + + 5 @@ -545,6 +691,22 @@ + + + + Qt::Horizontal + + + QSizePolicy::Minimum + + + + 5 + 20 + + + + @@ -575,7 +737,10 @@ Y axis - Qt::AlignCenter + Qt::AlignBottom|Qt::AlignHCenter + + + 5 From 8125d9c567e97da6bff2c012a73820f1e8a5e87a Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Wed, 19 Jun 2024 23:45:06 +0200 Subject: [PATCH 23/29] Add filter option + can filter arrays - add different filter options: slider and custom (I know the slider is ugly but functionality before beauty) - can filter arrays (removed list of combobox to select arrays, use filter instead) - modify slider so it can be used as a widget - code cleaning (mostly variables renaming) --- autolab/core/gui/__init__.py | 2 +- autolab/core/gui/controlcenter/treewidgets.py | 10 +- autolab/core/gui/scanning/config.py | 4 +- autolab/core/gui/scanning/data.py | 179 +++++----- autolab/core/gui/scanning/figure.py | 316 ++++++++++-------- autolab/core/gui/scanning/interface.ui | 258 +++++++++----- autolab/core/gui/scanning/main.py | 9 +- autolab/core/gui/slider.py | 83 +++-- autolab/core/gui/variables.py | 3 +- 9 files changed, 509 insertions(+), 355 deletions(-) diff --git a/autolab/core/gui/__init__.py b/autolab/core/gui/__init__.py index 0980bd79..6dd8afaa 100644 --- a/autolab/core/gui/__init__.py +++ b/autolab/core/gui/__init__.py @@ -133,7 +133,7 @@ def _start(gui: str, **kwargs): elif gui == 'slider': from .slider import Slider item = kwargs.get('item') - gui = Slider(item) + gui = Slider(item.variable, item) elif gui == 'add_device': from .controlcenter.main import addDeviceWindow gui = addDeviceWindow() diff --git a/autolab/core/gui/controlcenter/treewidgets.py b/autolab/core/gui/controlcenter/treewidgets.py index 7fa4eb0f..a95906ef 100644 --- a/autolab/core/gui/controlcenter/treewidgets.py +++ b/autolab/core/gui/controlcenter/treewidgets.py @@ -405,8 +405,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]) @@ -451,8 +451,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): @@ -590,7 +590,7 @@ 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: diff --git a/autolab/core/gui/scanning/config.py b/autolab/core/gui/scanning/config.py index faf16eef..5291bd8b 100644 --- a/autolab/core/gui/scanning/config.py +++ b/autolab/core/gui/scanning/config.py @@ -436,8 +436,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': diff --git a/autolab/core/gui/scanning/data.py b/autolab/core/gui/scanning/data.py index 82b193a7..2660718c 100644 --- a/autolab/core/gui/scanning/data.py +++ b/autolab/core/gui/scanning/data.py @@ -40,10 +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, + def getData(self, nbDataset: int, var_list: list, selectedData: int = 0, data_name: str = "Scan", filter_condition: List[dict] = []) -> List[pd.DataFrame]: - """ This function returns to the figure manager the required data """ + """ Returns the required data """ dataList = [] recipe_name = self.gui.scan_recipe_comboBox.currentText() @@ -56,7 +56,7 @@ def getData(self, nbDataset: int, varList: list, if data_name == "Scan": try: - data = dataset.getData(varList, data_name=data_name, + 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 @@ -65,16 +65,20 @@ 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}", @@ -90,32 +94,32 @@ def getData(self, nbDataset: int, varList: list, return dataList def getLastDataset(self) -> Union[dict, None]: - """ This return the last created dataset """ + """ Returns the last created dataset """ return self.datasets[-1] if len(self.datasets) > 0 else None def getLastSelectedDataset(self) -> Union[dict, None]: - """ This return the last selected dataset """ + """ Returns the last selected dataset """ index = self.gui.data_comboBox.currentIndex() if index != -1 and index < len(self.datasets): return self.datasets[index] 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 = {} 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: - tempFolderPath = str(random.random()) + folder_dataset_temp = str(random.random()) 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, @@ -216,8 +220,8 @@ def updateDisplayableResults(self): 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 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 @@ -236,38 +240,38 @@ def updateDisplayableResults(self): self.gui.variable_y_comboBox.clear() 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(f"Warning: At least two variables have the same name. 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( + 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_x2_comboBox.clear() - self.gui.variable_x_comboBox.addItems(resultNamesList) # parameter first - self.gui.variable_x2_comboBox.addItems(resultNamesList) - if resultNamesList: - name = resultNamesList.pop(0) - resultNamesList.append(name) + 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: @@ -281,13 +285,13 @@ def updateDisplayableResults(self): 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 = [] + self._data_temp = [] self.recipe_name = recipe_name - self.list_array = [] - self.dictListDataFrame = {} - self.tempFolderPath = tempFolderPath + self.folders = [] + self.data_arrays = {} + self.folder_dataset_temp = folder_dataset_temp self.new = True self.save_temp = save_temp @@ -296,7 +300,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 @@ -319,14 +323,14 @@ def __init__(self, tempFolderPath: str, recipe_name: str, config: dict, 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", + 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 not isinstance(data, str) @@ -337,39 +341,48 @@ def getData(self, varList: list, data_name: str = "Scan", # Add var for filtering for var_filter in filter_condition: - if var_filter['enable'] and var_filter['name'] not in varList and var_filter['name'] != '': - varList.append(var_filter['name']) - - if any(map(lambda v: v in varList, list(data.columns))): + 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_varList = list(dict.fromkeys(varList)) # unique varList + unique_var_list = list(dict.fromkeys(var_list)) # unique var_list # Filter data for var_filter in filter_condition: - if var_filter['enable'] and var_filter['name'] in data: - # Alternative could be: data.query('3 > var_x > 1') - 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] - - return data.loc[:,unique_varList] + 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: + data = data.query(filter_cond) + + return data.loc[:,unique_var_list] return None def save(self, filename: str): """ This function saved the dataset in the provided path """ dataset_folder = os.path.splitext(filename)[0] - data_name = os.path.join(self.tempFolderPath, 'data.txt') + 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) @@ -379,8 +392,8 @@ 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") @@ -401,51 +414,47 @@ def addPoint(self, dataPoint: OrderedDict): simpledata = OrderedDict() simpledata['id'] = ID - for resultName, result in dataPoint.items(): + for result_name, result in dataPoint.items(): - if resultName == 0: continue # skip first result which is recipe_name + if result_name == 0: continue # skip first result which is recipe_name - 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 + 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'), + 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.tempFolderPath, 'data.txt'), + os.path.join(self.folder_dataset_temp, 'data.txt'), index=False, mode='a', header=False) def __len__(self): diff --git a/autolab/core/gui/scanning/figure.py b/autolab/core/gui/scanning/figure.py index c97896d4..6e28cb73 100644 --- a/autolab/core/gui/scanning/figure.py +++ b/autolab/core/gui/scanning/figure.py @@ -16,6 +16,8 @@ from .display import DisplayValues 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 @@ -81,16 +83,15 @@ def __init__(self, gui: QtWidgets.QMainWindow): self.gui.dataframe_comboBox.addItem("Scan") self.gui.dataframe_comboBox.hide() - self.gui.toolButton.hide() - self.clearMenuID() - # Filter widgets - self.gui.frameFilter.hide() + self.gui.scrollArea_filter.hide() self.gui.checkBoxFilter.stateChanged.connect(self.checkBoxFilterChanged) - self.gui.addFilterPushButton.clicked.connect(self.addFilterClicked) + 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 refreshFilters(self): + def refresh_filters(self): """ Apply filters to data """ self.filter_condition.clear() @@ -98,127 +99,195 @@ def refreshFilters(self): for i in range(self.gui.layoutFilter.count()-1): # last is buttons layout = self.gui.layoutFilter.itemAt(i).layout() - enable = bool(layout.itemAt(0).widget().isChecked()) - name = layout.itemAt(1).widget().currentText() - condition_raw = layout.itemAt(2).widget().currentText() - value = float(layout.itemAt(3).widget().text()) + 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: + value = float(valueWidget.text()) # for editline + 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] - 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} - filter_i = {'name': name, 'condition': condition, 'value': value, 'enable': enable} + elif layout.count() == 3: + enable = bool(layout.itemAt(0).widget().isChecked()) + customConditionWidget = layout.itemAt(1).widget() + condition_txt = customConditionWidget.text() + setLineEditBackground( + customConditionWidget, 'synced', self._font_size) + + filter_i = {'enable': enable, 'condition': condition_txt, 'name': None, 'value': None} self.filter_condition.append(filter_i) - self.reloadData() + # 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 - if self.gui.layoutFilter.count() <= 3: - min_size = 65 + self.gui.layoutFilter.count()*25 + 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: - min_size = 65 + 3*25 - self.gui.frame_axis.setMinimumSize(0, min_size) + self.gui.frameAxis.setMinimumHeight(65) + self.gui.scrollArea_filter.setMinimumWidth(0) + + self.reloadData() - def addFilterClicked(self): + 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.refreshFilters) + filterCheckBox.stateChanged.connect(self.refresh_filters) conditionLayout.addWidget(filterCheckBox) - VariablecomboBox = QtWidgets.QComboBox() - VariablecomboBox.setMinimumSize(0, 21) - AllItems = [self.gui.variable_x_comboBox.itemText(i) for i in range(self.gui.variable_x_comboBox.count())] - VariablecomboBox.addItems(AllItems) - VariablecomboBox.activated.connect(self.refreshFilters) - conditionLayout.addWidget(VariablecomboBox) - - FiltercomboBox = QtWidgets.QComboBox() - FiltercomboBox.setMinimumSize(0, 21) - AllItems = ['==', '!=', '<', '<=', '>=', '>'] - FiltercomboBox.addItems(AllItems) - FiltercomboBox.activated.connect(self.refreshFilters) - conditionLayout.addWidget(FiltercomboBox) - - # OPTIMIZE: would be nice to have a slider for value - valueWidget = QtWidgets.QLineEdit() - valueWidget.setMinimumSize(0, 21) - valueWidget.setText('0') - valueWidget.returnPressed.connect(self.refreshFilters) - conditionLayout.addWidget(valueWidget) + 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) + valueWidget.setMinimumSize(valueWidget.minimumSizeHint().width(), valueWidget.minimumSizeHint().height()) # Will hide it if too small + valueWidget.setMaximumSize(valueWidget.minimumSizeHint().width(), valueWidget.minimumSizeHint().height()) + valueWidget.sliderWidget.setValue(1) + 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 state, layout=conditionLayout: self.removeFilter(layout)) + lambda state, layout=conditionLayout: self.remove_filter(layout)) conditionLayout.addWidget(removePushButton) self.gui.layoutFilter.insertLayout( self.gui.layoutFilter.count()-1, conditionLayout) - self.refreshFilters() + self.refresh_filters() - def removeFilter(self, layout): + 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.refreshFilters() + self.refresh_filters() def checkBoxFilterChanged(self): """ Show/hide filters frame and refresh filters """ if self.gui.checkBoxFilter.isChecked(): - if not self.gui.frameFilter.isVisible(): - self.gui.frameFilter.show() + if not self.gui.scrollArea_filter.isVisible(): + self.gui.scrollArea_filter.show() else: - if self.gui.frameFilter.isVisible(): - self.gui.frameFilter.hide() - - self.refreshFilters() - - 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 = [] # 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 = [] - self.menuActionList = [] - 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() + if self.gui.scrollArea_filter.isVisible(): + self.gui.scrollArea_filter.hide() + + self.refresh_filters() def data_comboBoxClicked(self): """ This function select a dataset """ @@ -227,13 +296,13 @@ def data_comboBoxClicked(self): dataset = self.gui.dataManager.getLastSelectedDataset() index = self.gui.scan_recipe_comboBox.currentIndex() - resultNamesList = list(dataset) - 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: @@ -254,7 +323,6 @@ def scan_recipe_comboBoxCurrentChanged(self): def dataframe_comboBoxCurrentChanged(self): self.updateDataframe_comboBox() - self.resetCheckBoxMenuID() self.gui.dataManager.updateDisplayableResults() self.reloadData() @@ -262,10 +330,8 @@ 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): @@ -278,43 +344,23 @@ def updateDataframe_comboBox(self): sub_dataset = dataset[recipe_name] - resultNamesList = ["Scan"] + [ - i for i, val in sub_dataset.dictListDataFrame.items() if not isinstance( + 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 ########################################################################### @@ -436,16 +482,6 @@ def reloadData(self): pivot_table = subdata.pivot( index=variable_x, columns=variable_x2, values=variable_y) except ValueError: # if more than 2 parameters - ## TODO: Solution is to set all the other parameters to a constant value - # data = self.gui.dataManager.getData( - # nbtraces_temp, var_to_display+['parameter_test',], - # selectedData=selectedData, data_name=data_name) - - # subdata: pd.DataFrame = data[-1] - # subdata = subdata[subdata['parameter_test'] == 0] - # pivot_table = subdata.pivot( - # index=variable_x, columns=variable_x2, values=variable_y) - # self.clearData() return None # Extract data for plotting @@ -498,8 +534,8 @@ def reloadData(self): if self.fig.isVisible(): self.fig.hide() self.figMap.show() - if self.gui.frame_axis.isVisible(): - self.gui.frame_axis.hide() + if self.gui.frameAxis.isVisible(): + self.gui.frameAxis.hide() image_data[i] = subdata if i == len(data)-1: if image_data.ndim == 3: @@ -514,8 +550,8 @@ def reloadData(self): if not self.fig.isVisible(): self.fig.show() self.figMap.hide() - if not self.gui.frame_axis.isVisible(): - self.gui.frame_axis.show() + if not self.gui.frameAxis.isVisible(): + self.gui.frameAxis.show() x = subdata.loc[:,variable_x] y = subdata.loc[:,variable_y] diff --git a/autolab/core/gui/scanning/interface.ui b/autolab/core/gui/scanning/interface.ui index c25b9615..e558fbab 100644 --- a/autolab/core/gui/scanning/interface.ui +++ b/autolab/core/gui/scanning/interface.ui @@ -6,7 +6,7 @@ 0 0 - 1200 + 1154 740 @@ -55,12 +55,6 @@ - - Qt::ScrollBarAlwaysOn - - - Qt::ScrollBarAlwaysOff - true @@ -69,7 +63,7 @@ 0 0 - 380 + 362 543 @@ -133,7 +127,7 @@ - 40 + 0 20 @@ -326,13 +320,6 @@ - - - - ... - - - @@ -350,7 +337,7 @@ - 40 + 0 20 @@ -366,7 +353,7 @@ - + 0 @@ -377,19 +364,6 @@ QFrame::StyledPanel - - - - Qt::Vertical - - - - 20 - 40 - - - - @@ -484,7 +458,7 @@ - 40 + 0 20 @@ -508,6 +482,19 @@ + + + + Qt::Vertical + + + + 20 + 5 + + + + @@ -532,54 +519,140 @@ - - - + + + + 2 + 2 + QFrame::StyledPanel - - QFrame::Sunken - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - Add filter - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - + + 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 + + + + + + + + + + + + + @@ -600,8 +673,37 @@ + + + + Qt::Horizontal + + + QSizePolicy::Maximum + + + + 15 + 20 + + + + + + + + Qt::Vertical + + + + 20 + 0 + + + + @@ -714,7 +816,7 @@ - 40 + 0 20 @@ -775,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 2d58398a..5795d860 100644 --- a/autolab/core/gui/scanning/main.py +++ b/autolab/core/gui/scanning/main.py @@ -139,13 +139,11 @@ def clear(self): """ This reset any recorded data, and the GUI accordingly """ 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.frame_axis.show() - self.toolButton.hide() + self.frameAxis.show() self.variable_x_comboBox.clear() self.variable_x2_comboBox.clear() self.variable_y_comboBox.clear() @@ -402,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) @@ -419,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() diff --git a/autolab/core/gui/slider.py b/autolab/core/gui/slider.py index 9c639f90..0fbddcc8 100644 --- a/autolab/core/gui/slider.py +++ b/autolab/core/gui/slider.py @@ -16,14 +16,18 @@ class Slider(QtWidgets.QMainWindow): - def __init__(self, item: QtWidgets.QTreeWidgetItem): + 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.gui = item if isinstance(item, QtWidgets.QTreeWidgetItem) else None + 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.item.variable.address()) + self.setWindowTitle(self.variable.address()) self.setWindowIcon(QtGui.QIcon(icons['slider'])) # Load configuration @@ -35,9 +39,9 @@ def __init__(self, item: QtWidgets.QTreeWidgetItem): # 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) + 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() @@ -128,10 +132,10 @@ def __init__(self, item: QtWidgets.QTreeWidgetItem): def updateStep(self): - if self.item.variable.type in (int, float): + 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.item.variable.type( + self.true_max = self.variable.type( self.true_step*(slider_points - 1) + self.true_min) self.minWidget.setText(f'{self.true_min}') @@ -152,7 +156,7 @@ def updateStep(self): def updateTrueValue(self, old_true_value: Any): - if self.item.variable.type in (int, float): + if self.variable.type in (int, float): new_cursor_step = round( (old_true_value - self.true_min) / self.true_step) slider_points = 1 + int( @@ -167,7 +171,7 @@ def updateTrueValue(self, old_true_value: Any): self.sliderWidget.setSliderPosition(new_cursor_step) self.slider_instantaneous = temp - true_value = self.item.variable.type( + 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) @@ -175,15 +179,17 @@ def updateTrueValue(self, old_true_value: Any): def stepWidgetValueChanged(self): - if self.item.variable.type in (int, float): - old_true_value = self.item.variable.type(self.valueWidget.text()) + if self.variable.type in (int, float): + old_true_value = self.variable.type(self.valueWidget.text()) try: - true_step = self.item.variable.type(self.stepWidget.text()) + 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: - self.item.gui.setStatus(f"Variable {self.item.variable.name}: {e}", - 10000, False) + 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) @@ -191,13 +197,14 @@ def stepWidgetValueChanged(self): def minWidgetValueChanged(self): - if self.item.variable.type in (int, float): - old_true_value = self.item.variable.type(self.valueWidget.text()) + if self.variable.type in (int, float): + old_true_value = self.variable.type(self.valueWidget.text()) try: - self.true_min = self.item.variable.type(self.minWidget.text()) + self.true_min = self.variable.type(self.minWidget.text()) except Exception as e: - self.item.gui.setStatus(f"Variable {self.item.variable.name}: {e}", - 10000, False) + if self.main_gui: + self.main_gui.setStatus( + f"Variable {self.variable.name}: {e}", 10000, False) else: self.updateStep() self.updateTrueValue(old_true_value) @@ -205,13 +212,14 @@ def minWidgetValueChanged(self): def maxWidgetValueChanged(self): - if self.item.variable.type in (int, float): - old_true_value = self.item.variable.type(self.valueWidget.text()) + if self.variable.type in (int, float): + old_true_value = self.variable.type(self.valueWidget.text()) try: - self.true_max = self.item.variable.type(self.maxWidget.text()) + self.true_max = self.variable.type(self.maxWidget.text()) except Exception as e: - self.item.gui.setStatus(f"Variable {self.item.variable.name}: {e}", - 10000, False) + if self.main_gui: + self.main_gui.setStatus( + f"Variable {self.variable.name}: {e}", 10000, False) else: self.updateStep() self.updateTrueValue(old_true_value) @@ -219,33 +227,36 @@ def maxWidgetValueChanged(self): def sliderReleased(self): """ Do something when the cursor is released """ - if self.item.variable.type in (int, float): + if self.variable.type in (int, float): value = self.sliderWidget.value() - true_value = self.item.variable.type( + 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 hasattr(self.item.gui, 'threadManager'): - self.item.gui.threadManager.start( + if self.main_gui and hasattr(self.main_gui, 'threadManager'): + self.main_gui.threadManager.start( self.item, 'write', value=true_value) else: - self.item.variable(true_value) + 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.item.variable.type in (int, float): - true_value = self.item.variable.type( + 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 hasattr(self.item.gui, 'threadManager'): - self.item.gui.threadManager.start( + if self.main_gui and hasattr(self.main_gui, 'threadManager'): + self.main_gui.threadManager.start( self.item, 'write', value=true_value) else: - self.item.variable(true_value) + 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 @@ -272,7 +283,7 @@ 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.gui is None: + if self.is_main: QtWidgets.QApplication.quit() # close the slider app diff --git a/autolab/core/gui/variables.py b/autolab/core/gui/variables.py index 103bb7ad..44edbaaf 100644 --- a/autolab/core/gui/variables.py +++ b/autolab/core/gui/variables.py @@ -102,7 +102,6 @@ def __call__(self, value: Any = None) -> Any: self.refresh(self.name, value) return None - def evaluate(self): if has_eval(self.raw): value = str(self.raw)[len(EVAL): ] @@ -588,7 +587,7 @@ 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) + 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: From 05e0f9f3bc8a94ed189f2498de26a9dcd6f804d0 Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Fri, 21 Jun 2024 08:46:09 +0200 Subject: [PATCH 24/29] Changes to filter + fixes - Now don't filter images - in scan filter, now sliders start with id=1 and can handle floats - Fixe bug when setting arrays and dataframes in a scan using $eval converting to string any inputs and blocking the config importation - various error handling: if save array with bad shape, if plot images with different shapes, if plot empty arrays or dataframes --- autolab/core/elements.py | 8 +++-- autolab/core/gui/scanning/config.py | 28 ++++++++++------- autolab/core/gui/scanning/data.py | 6 ++-- autolab/core/gui/scanning/figure.py | 30 +++++++++++++----- autolab/core/gui/scanning/recipe.py | 49 ++++++++++++++++------------- 5 files changed, 76 insertions(+), 45 deletions(-) diff --git a/autolab/core/elements.py b/autolab/core/elements.py index dbb6fb29..386b03f7 100644 --- a/autolab/core/elements.py +++ b/autolab/core/elements.py @@ -101,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) ... occurs in GUI scan if do $eval:[1] instead of $eval:np.array([1]) + print(f"Warning, can't save {value}") elif self.type == pd.DataFrame: value.to_csv(path, index=False) else: diff --git a/autolab/core/gui/scanning/config.py b/autolab/core/gui/scanning/config.py index 5291bd8b..a2b72919 100644 --- a/autolab/core/gui/scanning/config.py +++ b/autolab/core/gui/scanning/config.py @@ -450,8 +450,9 @@ 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 @@ -774,16 +775,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 diff --git a/autolab/core/gui/scanning/data.py b/autolab/core/gui/scanning/data.py index 2660718c..bfff37f0 100644 --- a/autolab/core/gui/scanning/data.py +++ b/autolab/core/gui/scanning/data.py @@ -227,7 +227,8 @@ def updateDisplayableResults(self): # if text or if image of type ndarray return if isinstance(data, str) or ( isinstance(data, np.ndarray) and not ( - len(data.T.shape) == 1 or data.T.shape[0] == 2)): + 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() @@ -334,7 +335,8 @@ def getData(self, var_list: list, data_name: str = "Scan", if (data is not None and not isinstance(data, str) - and (len(data.T.shape) == 1 or data.T.shape[0] == 2)): + 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 diff --git a/autolab/core/gui/scanning/figure.py b/autolab/core/gui/scanning/figure.py index 6e28cb73..6718119b 100644 --- a/autolab/core/gui/scanning/figure.py +++ b/autolab/core/gui/scanning/figure.py @@ -237,11 +237,12 @@ def addFilterClicked(self, filter_type): setLineEditBackground(valueWidget, 'synced', self._font_size) conditionLayout.addWidget(valueWidget) elif filter_type == 'slider': - var = Variable('temp', 1) + var = Variable('temp', 1.) valueWidget = Slider(var) valueWidget.setMinimumSize(valueWidget.minimumSizeHint().width(), valueWidget.minimumSizeHint().height()) # Will hide it if too small valueWidget.setMaximumSize(valueWidget.minimumSizeHint().width(), valueWidget.minimumSizeHint().height()) - valueWidget.sliderWidget.setValue(1) + valueWidget.minWidget.setText('1.0') + valueWidget.minWidgetValueChanged() valueWidget.changed.connect(self.refresh_filters) conditionLayout.addWidget(valueWidget) @@ -427,10 +428,14 @@ def reloadData(self): else: var_to_display = [variable_x, variable_y] + can_filter = var_to_display != ['', ''] # Allows to differentiate images from scan or arrays. Works only because on dataframe_comboBoxCurrentChanged, updateDisplayableResults is called + 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=self.filter_condition) + filter_condition=filter_condition) # Plot data if data is not None: @@ -536,14 +541,22 @@ def reloadData(self): self.figMap.show() if self.gui.frameAxis.isVisible(): self.gui.frameAxis.hide() - image_data[i] = subdata + try: + image_data[i] = subdata + except Exception as e: + print(f"Warning can't plot image: {e}") + continue if i == len(data)-1: if image_data.ndim == 3: x,y = (0, 1) if self.figMap.imageItem.axisOrder == 'col-major' else (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 + try: + 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 + except Exception as e: + print(f"Warning can't plot image: {e}") + continue self.figMap.setCurrentIndex(len(self.figMap.tVals)) else: # not an image (is pd.DataFrame) @@ -553,8 +566,11 @@ def reloadData(self): if not self.gui.frameAxis.isVisible(): self.gui.frameAxis.show() - x = subdata.loc[:,variable_x] - y = subdata.loc[:,variable_y] + 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}!") diff --git a/autolab/core/gui/scanning/recipe.py b/autolab/core/gui/scanning/recipe.py index bad231c8..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 @@ -273,15 +275,18 @@ 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() From 0978b529d933319d7384623076d3e4ae27a5f0a3 Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Sat, 22 Jun 2024 19:11:45 +0200 Subject: [PATCH 25/29] filter condition and plot images fixes - avoid error if plot images with different shapes (now plot last image and print warning message) - check syntax of filter condition - avoid error if bad filter condition - check type of variable in slider of scan filter to avoid error - compatibility pyside2 for filter and add device menu - compatibility pyside6 for sliders - code cleaning --- autolab/core/gui/controlcenter/main.py | 4 +- autolab/core/gui/scanning/data.py | 31 ++++-- autolab/core/gui/scanning/figure.py | 125 +++++++++++++++++-------- autolab/core/gui/slider.py | 10 +- 4 files changed, 121 insertions(+), 49 deletions(-) diff --git a/autolab/core/gui/controlcenter/main.py b/autolab/core/gui/controlcenter/main.py index acb9ba0f..72aac684 100644 --- a/autolab/core/gui/controlcenter/main.py +++ b/autolab/core/gui/controlcenter/main.py @@ -175,7 +175,7 @@ def startDrag(self, event): addDeviceAction = settingsMenu.addAction('Add device') addDeviceAction.setIcon(QtGui.QIcon(icons['add'])) - addDeviceAction.triggered.connect(lambda state: self.openAddDevice()) + addDeviceAction.triggered.connect(lambda: self.openAddDevice()) addDeviceAction.setStatusTip("Open the utility to add a device") downloadDriverAction = settingsMenu.addAction('Download drivers') @@ -738,7 +738,7 @@ def addOptionalArgClicked(self, key: str = None, val: str = None): layout.addWidget(widget) widget = QtWidgets.QPushButton() widget.setIcon(QtGui.QIcon(icons['remove'])) - widget.clicked.connect(lambda state, layout=layout: self.removeOptionalArgClicked(layout)) + widget.clicked.connect(lambda: self.removeOptionalArgClicked(layout)) layout.addWidget(widget) def removeOptionalArgClicked(self, layout): diff --git a/autolab/core/gui/scanning/data.py b/autolab/core/gui/scanning/data.py index bfff37f0..d45f55c5 100644 --- a/autolab/core/gui/scanning/data.py +++ b/autolab/core/gui/scanning/data.py @@ -81,7 +81,8 @@ def getData(self, nbDataset: int, var_list: list, 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) @@ -112,7 +113,8 @@ def newDataset(self, config: dict): if self.save_temp: 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')) + self.gui.configManager.export( + os.path.join(folder_dataset_temp, 'config.conf')) else: folder_dataset_temp = str(random.random()) @@ -134,7 +136,8 @@ 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;}""") + self.gui.progressBar.setStyleSheet( + "QProgressBar::chunk {background-color: orange;}") else: values = utilities.create_array(values) nbpts *= len(values) @@ -248,7 +251,9 @@ def updateDisplayableResults(self): try: 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 {result_name}!", 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) @@ -321,7 +326,12 @@ def __init__(self, folder_dataset_temp: 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, var_list: list, data_name: str = "Scan", @@ -344,7 +354,9 @@ def getData(self, var_list: list, data_name: str = "Scan", # 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: + 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: @@ -366,7 +378,12 @@ def getData(self, var_list: list, data_name: str = "Scan", elif isinstance(var_filter['condition'], str): filter_cond = var_filter['condition'] if filter_cond: - data = data.query(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] diff --git a/autolab/core/gui/scanning/figure.py b/autolab/core/gui/scanning/figure.py index 6718119b..aab6d0cf 100644 --- a/autolab/core/gui/scanning/figure.py +++ b/autolab/core/gui/scanning/figure.py @@ -21,6 +21,14 @@ from ..icons import icons +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: """ Manage the figure of the scanner """ @@ -49,9 +57,11 @@ def __init__(self, gui: QtWidgets.QMainWindow): 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.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) + "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) @@ -60,12 +70,15 @@ def __init__(self, gui: QtWidgets.QMainWindow): 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'])) @@ -75,20 +88,25 @@ 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() # 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.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): @@ -111,11 +129,13 @@ def refresh_filters(self): setLineEditBackground( valueWidget.valueWidget, 'synced', self._font_size) else: - value = float(valueWidget.text()) # for editline + 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, @@ -123,16 +143,24 @@ def refresh_filters(self): } condition = convert_condition[condition_raw] - filter_i = {'enable': enable, 'condition': condition, 'name': name, 'value': value} + 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} + filter_i = {'enable': enable, 'condition': condition_txt, + 'name': None, 'value': None} self.filter_condition.append(filter_i) @@ -215,7 +243,8 @@ def addFilterClicked(self, filter_type): self.refresh_filter_combobox(variableComboBox) variableComboBox.activated.connect(self.refresh_filters) - filterCheckBox.stateChanged.connect(lambda: self.refresh_filter_combobox(variableComboBox)) + filterCheckBox.stateChanged.connect( + lambda: self.refresh_filter_combobox(variableComboBox)) conditionLayout.addWidget(variableComboBox) filterComboBox = QtWidgets.QComboBox() @@ -239,8 +268,9 @@ def addFilterClicked(self, filter_type): elif filter_type == 'slider': var = Variable('temp', 1.) valueWidget = Slider(var) - valueWidget.setMinimumSize(valueWidget.minimumSizeHint().width(), valueWidget.minimumSizeHint().height()) # Will hide it if too small - valueWidget.setMaximumSize(valueWidget.minimumSizeHint().width(), valueWidget.minimumSizeHint().height()) + 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) @@ -265,7 +295,7 @@ def addFilterClicked(self, filter_type): removePushButton.setMaximumSize(16777215, 21) removePushButton.setIcon(QtGui.QIcon(icons['remove'])) removePushButton.clicked.connect( - lambda state, layout=conditionLayout: self.remove_filter(layout)) + lambda: self.remove_filter(conditionLayout)) conditionLayout.addWidget(removePushButton) self.gui.layoutFilter.insertLayout( @@ -298,7 +328,8 @@ def data_comboBoxClicked(self): index = self.gui.scan_recipe_comboBox.currentIndex() result_names = list(dataset) - items = [self.gui.scan_recipe_comboBox.itemText(i) for i in range(self.gui.scan_recipe_comboBox.count())] + items = [self.gui.scan_recipe_comboBox.itemText(i) for i in range( + self.gui.scan_recipe_comboBox.count())] if items != result_names: self.gui.scan_recipe_comboBox.clear() @@ -316,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() @@ -349,7 +381,8 @@ def updateDataframe_comboBox(self): 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] - items = [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 result_names != items: # only refresh if change labels, to avoid gui refresh that prevent user to click on combobox self.gui.dataframe_comboBox.clear() @@ -387,8 +420,8 @@ def clearData(self): 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() @@ -518,7 +551,7 @@ def reloadData(self): self.fig.img.hide() if len(data) != 0 and isinstance(data[0], np.ndarray): # to avoid errors - image_data = np.empty((len(data), *temp_data.shape)) + images = np.empty((len(data), *temp_data.shape)) for i in range(len(data)): # Data @@ -541,21 +574,32 @@ def reloadData(self): self.figMap.show() if self.gui.frameAxis.isVisible(): self.gui.frameAxis.hide() - try: - image_data[i] = subdata - except Exception as e: - print(f"Warning can't plot image: {e}") - continue + # 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 + 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(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 + 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 image: {e}") + print(f"Warning can't plot {data_name}: {e}") continue self.figMap.setCurrentIndex(len(self.figMap.tVals)) @@ -573,9 +617,11 @@ def reloadData(self): 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): @@ -588,14 +634,17 @@ def reloadData(self): # Plot # OPTIMIZE: known issue but from pyqtgraph, error if use FFT on one point # 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 = 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() diff --git a/autolab/core/gui/slider.py b/autolab/core/gui/slider.py index 0fbddcc8..5feffe65 100644 --- a/autolab/core/gui/slider.py +++ b/autolab/core/gui/slider.py @@ -14,6 +14,12 @@ 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() @@ -291,6 +297,6 @@ 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 + if hint == QtWidgets.QStyle.SH_Slider_AbsoluteSetButtons: + res |= LeftButton return res From 58bd62be8157eb8a9fef6a79fb5b4ddef3971b82 Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Thu, 27 Jun 2024 23:23:06 +0200 Subject: [PATCH 26/29] Can select active recipe and parameter from controlpanel - Add sub-menu to select recipe and parameter in controlpanel actions - fixe access violation on GUI exit (bug introduced in 0e9dd32 due to get_element_by_address on device close) - doc --- autolab/core/devices.py | 7 +- autolab/core/gui/controlcenter/treewidgets.py | 193 +++++++++++++++--- docs/gui/scanning.rst | 2 + docs/installation.rst | 7 +- docs/local_config.rst | 9 +- docs/low_level/create_driver.rst | 12 +- docs/shell/connection.rst | 2 +- 7 files changed, 190 insertions(+), 42 deletions(-) diff --git a/autolab/core/devices.py b/autolab/core/devices.py index fed3d6e1..41a64343 100644 --- a/autolab/core/devices.py +++ b/autolab/core/devices.py @@ -62,7 +62,12 @@ 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 diff --git a/autolab/core/gui/controlcenter/treewidgets.py b/autolab/core/gui/controlcenter/treewidgets.py index a95906ef..eb01051c 100644 --- a/autolab/core/gui/controlcenter/treewidgets.py +++ b/autolab/core/gui/controlcenter/treewidgets.py @@ -7,7 +7,7 @@ import os -from typing import Any +from typing import Any, Union import pandas as pd import numpy as np @@ -25,6 +25,151 @@ dataframe_to_str, str_to_dataframe) +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.CUSTOM_ACTION = len(self.recipe_names) > 1 + + def addAnyAction(self, action_text='', icon_name='', + param_menu_active=False) -> Union[QtWidgets.QWidgetAction, + QtWidgets.QAction]: + if self.CUSTOM_ACTION: + 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): """ This class represents a module in an item of the tree """ @@ -91,7 +236,7 @@ def menu(self, position: QtCore.QPoint): self.loaded = False elif id(self) in self.gui.threadManager.threads_conn: menu = QtWidgets.QMenu() - cancelDevice = menu.addAction(f"Cancel loading") + cancelDevice = menu.addAction('Cancel loading') cancelDevice.setIcon(QtGui.QIcon(icons['disconnect'])) choice = menu.exec_(self.gui.tree.viewport().mapToGlobal(position)) @@ -108,6 +253,7 @@ def menu(self, position: QtCore.QPoint): if choice == modifyDeviceChoice: self.gui.openAddDevice(self) + class TreeWidgetItemAction(QtWidgets.QTreeWidgetItem): """ This class represents an action in an item of the tree """ @@ -227,11 +373,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) @@ -491,36 +639,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'])) @@ -539,6 +672,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: diff --git a/docs/gui/scanning.rst b/docs/gui/scanning.rst index 18e941c9..f20892b4 100644 --- a/docs/gui/scanning.rst +++ b/docs/gui/scanning.rst @@ -91,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/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 6017d728..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. @@ -70,4 +71,4 @@ You can also use Autolab's ``add_device`` function to open up a minimalist graph .. code-block:: python - >>> laserSource = autolab.add_device() + >>> autolab.add_device() diff --git a/docs/low_level/create_driver.rst b/docs/low_level/create_driver.rst index 75412f70..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 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/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: From 05f14c8a676e6e5affb0c4b9107ccc5192216e2e Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Fri, 28 Jun 2024 21:34:44 +0200 Subject: [PATCH 27/29] array format using $eval: + minor fixe - now force array with 1 dim using $eval on ndarray variables - fixe bug when trying to add a parameter without any recipe set --- autolab/core/elements.py | 2 +- autolab/core/gui/controlcenter/treewidgets.py | 3 ++- autolab/core/gui/scanning/config.py | 6 ++++++ autolab/core/gui/scanning/scan.py | 1 + docs/about.rst | 4 ++-- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/autolab/core/elements.py b/autolab/core/elements.py index 386b03f7..b9973f25 100644 --- a/autolab/core/elements.py +++ b/autolab/core/elements.py @@ -105,7 +105,7 @@ def save(self, path: str, value: Any = None): 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) ... occurs in GUI scan if do $eval:[1] instead of $eval:np.array([1]) + # 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) diff --git a/autolab/core/gui/controlcenter/treewidgets.py b/autolab/core/gui/controlcenter/treewidgets.py index eb01051c..137db86c 100644 --- a/autolab/core/gui/controlcenter/treewidgets.py +++ b/autolab/core/gui/controlcenter/treewidgets.py @@ -22,7 +22,7 @@ from ...devices import close 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): @@ -585,6 +585,7 @@ def readGui(self): value = variables.eval_variable(value) if self.variable.type in [np.ndarray]: if isinstance(value, str): value = str_to_array(value) + else: value = create_array(value) elif self.variable.type in [pd.DataFrame]: if isinstance(value, str): value = str_to_dataframe(value) else: diff --git a/autolab/core/gui/scanning/config.py b/autolab/core/gui/scanning/config.py index a2b72919..bd2cf47f 100644 --- a/autolab/core/gui/scanning/config.py +++ b/autolab/core/gui/scanning/config.py @@ -309,6 +309,12 @@ def setParameter(self, recipe_name: str, param_name: str, """ 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) diff --git a/autolab/core/gui/scanning/scan.py b/autolab/core/gui/scanning/scan.py index 727c56fc..c7a8f944 100644 --- a/autolab/core/gui/scanning/scan.py +++ b/autolab/core/gui/scanning/scan.py @@ -485,6 +485,7 @@ def processElement(self, recipe_name: str, stepInfos: dict, 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: diff --git a/docs/about.rst b/docs/about.rst index bf63660d..58168328 100644 --- a/docs/about.rst +++ b/docs/about.rst @@ -10,12 +10,12 @@ Bruno arrived in the team in 2019, providing a new set of Python drivers from it In order to propose a Python alternative for the automation of scientific experiments in our research team, we finally merged our works in a Python package based on a standardized and robust driver architecture, that makes drivers easy to use and to write by the community. From 2020 onwards, development was pursued by Jonathan Peltier (PhD Student) from the `Minaphot team `_. -In 2023, Mathieu Jeannin from the `Odin team `_. joined the adventure. +In 2023, Mathieu Jeannin from the `Odin team `_ joined the adventure. 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 From 01e4e6763e01e0658ea38e26c6d33e27cb2b2a44 Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Mon, 1 Jul 2024 11:59:07 +0200 Subject: [PATCH 28/29] fixe fft from pyqtgraph wrap _fourierTransform to fixe pyqtgraph/issues/3018 before patch pyqtgraph/pull/3070 --- autolab/core/gui/GUI_utilities.py | 17 +++++++++++++++++ autolab/core/gui/scanning/figure.py | 1 - 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/autolab/core/gui/GUI_utilities.py b/autolab/core/gui/GUI_utilities.py index 86946de6..4b2360a1 100644 --- a/autolab/core/gui/GUI_utilities.py +++ b/autolab/core/gui/GUI_utilities.py @@ -15,8 +15,24 @@ 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': @@ -189,6 +205,7 @@ def update_img(self, x, y, z): 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() diff --git a/autolab/core/gui/scanning/figure.py b/autolab/core/gui/scanning/figure.py index aab6d0cf..86e9a626 100644 --- a/autolab/core/gui/scanning/figure.py +++ b/autolab/core/gui/scanning/figure.py @@ -632,7 +632,6 @@ def reloadData(self): alpha = (true_nbtraces - (len(data) - 1 - i)) / true_nbtraces # Plot - # OPTIMIZE: known issue but from pyqtgraph, error if use FFT on one point # 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, From f0fef6d520641abb908b09f1897360d6593b3f18 Mon Sep 17 00:00:00 2001 From: Python-simulation <46069237+Python-simulation@users.noreply.github.com> Date: Fri, 5 Jul 2024 16:32:41 +0200 Subject: [PATCH 29/29] Small change to active recipe selection - Can now select different parameters in controlpanel even if only one recipe is defined - Update autolab.pdf to latest version --- autolab/autolab.pdf | Bin 831700 -> 769358 bytes autolab/core/gui/controlcenter/treewidgets.py | 7 +++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/autolab/autolab.pdf b/autolab/autolab.pdf index bceee4a19c24c717820cea7d2dcedf3dc831b404..d966177bb10685a42286e47d4893600bb010fcc3 100644 GIT binary patch delta 171311 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
b}lToavU=}1VG25#P_IgOLl+VLVHZ4 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%0AwKAjoN-R@tl@(4-*DMq*J=h_ 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 u#7=RPMyJ_GKd>{4>}lg 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 delta 233993 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%0A+$yUnzl;eKHeX&y-^FYBlFCSMm_c3NxY`LQ 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℘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 D(xrlZ diff --git a/autolab/core/gui/controlcenter/treewidgets.py b/autolab/core/gui/controlcenter/treewidgets.py index 137db86c..0f90c271 100644 --- a/autolab/core/gui/controlcenter/treewidgets.py +++ b/autolab/core/gui/controlcenter/treewidgets.py @@ -41,12 +41,15 @@ def __init__(self, gui): self.param_cb = self.gui.scanner.selectParameter_comboBox if ( self.gui.scanner) else None - self.CUSTOM_ACTION = len(self.recipe_names) > 1 + 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.CUSTOM_ACTION: + + 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: