From fceb085bd2681eade323650f04bfd5c055348ce8 Mon Sep 17 00:00:00 2001 From: Erik Everson Date: Wed, 15 Jan 2025 15:43:13 -0800 Subject: [PATCH] Enforce movement to excluded regions (#78) * add to benchtop_run.toml exclusion layers * perform an excluded check before motioning the motion group * add messages to the ValueErrors raised by DividerExclusion._validate_inputs() * mark any point outside the motion space as excluded * use the parent argument in __init__ instead of self.parent() * use the currentIndex() method, and not the property * create message box class MSpaceMessageBox * supply log entry if probe drive target position is not executed since the probe drive is still moving * fix log string * add clarifying comments and annotations * in MGWidget._validate_motion_group() handle case where motion group has yet to be defined * incorporate MSpaceMessageBox indo DriveControlWidget and AxisControlWidget --- bapsf_motion/actors/motion_group_.py | 9 ++ bapsf_motion/examples/benchtop_run.toml | 12 ++ bapsf_motion/gui/configure/drive_overlay.py | 12 +- .../gui/configure/motion_group_widget.py | 126 ++++++++++++++++-- bapsf_motion/motion_builder/core.py | 13 +- .../motion_builder/exclusions/divider.py | 35 ++++- 6 files changed, 187 insertions(+), 20 deletions(-) diff --git a/bapsf_motion/actors/motion_group_.py b/bapsf_motion/actors/motion_group_.py index 79f8bd11..3b2c04e3 100644 --- a/bapsf_motion/actors/motion_group_.py +++ b/bapsf_motion/actors/motion_group_.py @@ -942,6 +942,15 @@ def move_to(self, pos, axis: Optional[int] = None): if isinstance(pos, u.Quantity): pos = pos.value + if isinstance(self.mb, MotionBuilder): + if self.mb.is_excluded(pos): + self.logger.error( + f"The requested position {pos} for motion group " + f"'{self.name}' is in an excluded region of the " + f"motion space. NOT MOVEMENT PREFORMED!!" + ) + return + dr_pos = self.transform(pos, to_coords="drive").squeeze() return self.drive.move_to(pos=dr_pos, axis=axis) diff --git a/bapsf_motion/examples/benchtop_run.toml b/bapsf_motion/examples/benchtop_run.toml index 4d51add3..53b56504 100644 --- a/bapsf_motion/examples/benchtop_run.toml +++ b/bapsf_motion/examples/benchtop_run.toml @@ -25,6 +25,18 @@ space.1.num = 51 layer.0.type = "grid" layer.0.limits = [[-3, 3], [-3, 3]] layer.0.steps = [7, 7] +exclusion.0.type = "divider" +exclusion.0.mb = ["inf", -4.0] +exclusion.0.exclude = "-e0" +exclusion.1.type = "divider" +exclusion.1.mb = ["inf", 4.0] +exclusion.1.exclude = "+e0" +exclusion.2.type = "divider" +exclusion.2.mb = [0.0, -4.0] +exclusion.2.exclude = "-e1" +exclusion.3.type = "divider" +exclusion.3.mb = [0.0, 4.0] +exclusion.3.exclude = "+e1" [run.mg.transform] type = "identity" diff --git a/bapsf_motion/gui/configure/drive_overlay.py b/bapsf_motion/gui/configure/drive_overlay.py index f83f6d38..802dd8b1 100644 --- a/bapsf_motion/gui/configure/drive_overlay.py +++ b/bapsf_motion/gui/configure/drive_overlay.py @@ -389,15 +389,15 @@ def __init__(self, mg: MotionGroup, parent: "mgw.MGWidget" = None): if isinstance(self.mg, MotionGroup) and isinstance(self.mg.drive, Drive): self.mg.drive.terminate(delay_loop_stop=True) _drive_config = _deepcopy_dict(self.mg.drive.config) - elif not isinstance(self.parent(), mgw.MGWidget): + elif not isinstance(parent, mgw.MGWidget): pass - elif self.parent().drive_dropdown.currentText != "Custom Drive": - index = self.parent().drive_dropdown.currentIndex + elif parent.drive_dropdown.currentText != "Custom Drive": + index = parent.drive_dropdown.currentIndex() _drive_config = _deepcopy_dict( - self.parent().drive_defaults[index][1] + parent.drive_defaults[index][1] ) - elif "drive" in self.parent()._initial_mg_config: - _drive_config = _deepcopy_dict(self.parent()._initial_mg_config["drive"]) + elif "drive" in parent._initial_mg_config: + _drive_config = _deepcopy_dict(parent._initial_mg_config["drive"]) self.drive_config = _drive_config diff --git a/bapsf_motion/gui/configure/motion_group_widget.py b/bapsf_motion/gui/configure/motion_group_widget.py index 4e190b4c..3912fe84 100644 --- a/bapsf_motion/gui/configure/motion_group_widget.py +++ b/bapsf_motion/gui/configure/motion_group_widget.py @@ -7,10 +7,12 @@ from PySide6.QtCore import Qt, Signal, Slot, QSize from PySide6.QtGui import QDoubleValidator from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QHBoxLayout, QLabel, QLineEdit, + QMessageBox, QSizePolicy, QTextEdit, QVBoxLayout, @@ -37,6 +39,71 @@ from bapsf_motion.utils import units as u +class MSpaceMessageBox(QMessageBox): + """ + Modal warning dialog box to warn the user the motion space has yet + to be defined. Thus, there are no restrictions on probe drive + movement, and it is up to the user to prevent any collisions. + """ + def __init__(self, parent: QWidget): + super().__init__(parent) + + self._display_dialog = True + + self.setWindowTitle("Motion Space NOT Defined") + self.setText( + "Motion Space is NOT defined, so there are no restrictions " + "on probe drive motion. It is up to the user to avoid " + "collisions.\n\n" + "Proceed with movement?" + ) + self.setIcon(QMessageBox.Icon.Warning) + self.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Abort + ) + self.setDefaultButton(QMessageBox.StandardButton.Abort) + + _cb = QCheckBox("Suppress future warnings for this motion group.") + self.setCheckBox(_cb) + + self.checkBox().checkStateChanged.connect(self._update_display_dialog) + + @property + def display_dialog(self) -> bool: + return self._display_dialog + + @display_dialog.setter + def display_dialog(self, value: bool) -> None: + if not isinstance(value, bool): + return + + # ensure the display boolean (display_dialog) is in sync + # with the dialog check box ... these two values are supposed + # to be NOTs of each other + check_state = self.checkBox().checkState() + if check_state is Qt.CheckState.Checked is value: + self.checkBox().setChecked(not value) + + self._display_dialog = value + + @Slot(Qt.CheckState) + def _update_display_dialog(self, state: Qt.CheckState) -> None: + self.display_dialog = not (state is Qt.CheckState.Checked) + + def exec(self) -> bool: + if not self.display_dialog: + return True + + button = super().exec() + + if button == QMessageBox.StandardButton.Yes: + # Make sure the Abort button always remains the default choice + self.setDefaultButton(QMessageBox.StandardButton.Abort) + return True + elif button == QMessageBox.StandardButton.Abort: + return False + + class AxisControlWidget(QWidget): axisLinked = Signal() axisUnlinked = Signal() @@ -122,6 +189,10 @@ def __init__(self, parent=None): # Define ADVANCED WIDGETS + self.mspace_warning_dialog = None + if isinstance(parent, DriveControlWidget): + self.mspace_warning_dialog = parent.mspace_warning_dialog + self.setLayout(self._define_layout()) self._connect_signals() @@ -206,13 +277,22 @@ def _jog_backward(self): self._move_to(pos) def _move_to(self, target_ax_pos): + target_pos = self.mg.position.value + target_pos[self.axis_index] = target_ax_pos + if self.mg.drive.is_moving: + self.logger.info( + "Probe drive is currently moving. Did NOT perform move " + f"to {target_pos}." + ) return - position = self.mg.position.value - position[self.axis_index] = target_ax_pos + proceed = True + if not isinstance(self.mg.mb, MotionBuilder): + proceed = self.mspace_warning_dialog.exec() - self.mg.move_to(position) + if proceed: + self.mg.move_to(target_pos) def _update_display_of_axis_status(self): if self._mg.terminated: @@ -382,6 +462,8 @@ def __init__(self, parent=None): # Define TEXT WIDGETS # Define ADVANCED WIDGETS + self.mspace_warning_dialog = MSpaceMessageBox(parent=self) + self.setLayout(self._define_layout()) self._connect_signals() @@ -474,7 +556,20 @@ def _move_to(self): for acw in self._axis_control_widgets if not acw.isHidden() ] - self.mg.move_to(target_pos) + + if self.mg.drive.is_moving: + self.logger.info( + "Probe drive is currently moving. Did NOT perform move " + f"to {target_pos}." + ) + return + + proceed = True + if not isinstance(self.mg.mb, MotionBuilder): + proceed = self.mspace_warning_dialog.exec() + + if proceed: + self.mg.move_to(target_pos) def _stop_move(self): self.mg.stop() @@ -794,7 +889,7 @@ def __init__( if "name" not in self._mg_config or self._mg_config["name"] == "": self._mg_config["name"] = "A New MG" - self.logger.info(f"starting _mg_config: {self._mg_config}") + self.logger.info(f"starting mg_config:\n {self._mg_config}") self._update_mg_name_widget() self._spawn_motion_group() @@ -922,8 +1017,10 @@ def _define_central_builder_layout(self): def _define_mspace_display_layout(self): ... - def _build_drive_defaults(self): - + def _build_drive_defaults(self) -> List[Tuple[str, Dict[str, Any]]]: + # Returned _drive_defaults is a List of Tuple pairs + # - 1st Tuple element is the dropdown name + # - 2nd Tuple element is the dictionary configuration if self._defaults is None or "drive" not in self._defaults: self._drive_defaults = [("Custom Drive", {})] return self._drive_defaults @@ -1503,16 +1600,23 @@ def _spawn_motion_group(self): def _validate_motion_group(self): self.logger.info("Validating motion group") - vmg_name = self._validate_motion_group_name() + vmg_name = self._validate_motion_group_name() vdrive = self._validate_drive() - if not isinstance(self.mg.mb, MotionBuilder): + if not isinstance(self.mg, MotionGroup): + mb = None + transform = None + else: + mb = self.mg.mb + transform = self.mg.transform + + if not isinstance(mb, MotionBuilder): self.mb_btn.set_invalid() self.mb_btn.setToolTip("Motion space needs to be defined.") self.done_btn.setEnabled(False) else: - if "layer" not in self.mg.mb.config: + if "layer" not in mb.config: self.mb_btn.set_invalid() self.mb_btn.setToolTip( "A point layer needs to be defined to generate a motion list." @@ -1521,7 +1625,7 @@ def _validate_motion_group(self): self.mb_btn.set_valid() self.mb_btn.setToolTip("") - if not isinstance(self.mg.transform, BaseTransform): + if not isinstance(transform, BaseTransform): self.transform_btn.set_invalid() self.done_btn.setEnabled(False) diff --git a/bapsf_motion/motion_builder/core.py b/bapsf_motion/motion_builder/core.py index b31471c8..5a20f8e3 100644 --- a/bapsf_motion/motion_builder/core.py +++ b/bapsf_motion/motion_builder/core.py @@ -348,7 +348,18 @@ def is_excluded(self, point) -> bool: select = {} for ii, dim_name in enumerate(self.mspace_dims): - select[dim_name] = point[ii] + _res = self.mask_resolution[ii] + _coord = self.mspace_coords[dim_name] + _point = point[ii] + + if ( + _point < (np.min(_coord) - 0.5 * _res) + or _point > (np.max(_coord) + 0.5 * _res) + ): + # point is outside the motion space + return True + + select[dim_name] = _point return not bool(self.mask.sel(method="nearest", **select).data) diff --git a/bapsf_motion/motion_builder/exclusions/divider.py b/bapsf_motion/motion_builder/exclusions/divider.py index 5941627b..f97fc75c 100644 --- a/bapsf_motion/motion_builder/exclusions/divider.py +++ b/bapsf_motion/motion_builder/exclusions/divider.py @@ -7,12 +7,14 @@ import numbers import numpy as np import re +import warnings import xarray as xr from typing import Tuple from bapsf_motion.motion_builder.exclusions.base import BaseExclusion from bapsf_motion.motion_builder.exclusions.helpers import register_exclusion +from bapsf_motion.utils.exceptions import ConfigurationWarning @register_exclusion @@ -155,9 +157,38 @@ def _validate_inputs(self): sign, axis = self._exclude_sign_and_axis() if np.isinf(self.mb[0]) and axis == 1: - raise ValueError + warnings.warn( + f"Received an infinite slope and an excluded reference " + f"axis 1, assuming an excluded reference axis 0 was intended.", + category=ConfigurationWarning, + ) + self.inputs["exclude"] = f"{sign}e0" + + # double check correction was made + _s, _a = self._exclude_sign_and_axis() + if _s != sign and _a != 0: + raise ValueError( + "The excluded reference axis string is malformed. " + "Received an infinite slope and an excluded reference " + "axis 1, expected reference axis 0." + ) + elif self.mb[0] == 0 and axis == 0: - raise ValueError + warnings.warn( + f"Received a zero slope and an excluded reference " + f"axis 0, assuming an excluded reference axis 1 was intended.", + category=ConfigurationWarning, + ) + self.inputs["exclude"] = f"{sign}e1" + + # double check correction was made + _s, _a = self._exclude_sign_and_axis() + if _s != sign and _a != 1: + raise ValueError( + "The excluded reference axis string is malformed. " + "Received a zero slope and an excluded reference " + "axis 0, expected reference axis 1." + ) def _generate_exclusion(self): """