diff --git a/parallax/__init__.py b/parallax/__init__.py index 879ba9f..3f9c774 100644 --- a/parallax/__init__.py +++ b/parallax/__init__.py @@ -4,7 +4,7 @@ import os -__version__ = "0.37.26" +__version__ = "0.37.27" # allow multiple OpenMP instances os.environ["KMP_DUPLICATE_LIB_OK"] = "True" diff --git a/parallax/calculator.py b/parallax/calculator.py index f634aed..2b6ae8d 100644 --- a/parallax/calculator.py +++ b/parallax/calculator.py @@ -1,7 +1,7 @@ import os import logging import numpy as np -from PyQt5.QtWidgets import QWidget, QGroupBox, QLineEdit, QPushButton, QLabel +from PyQt5.QtWidgets import QWidget, QGroupBox, QLineEdit, QPushButton, QLabel, QMessageBox from PyQt5.uic import loadUi from PyQt5.QtCore import Qt @@ -13,13 +13,14 @@ ui_dir = os.path.join(os.path.dirname(package_dir), "ui") class Calculator(QWidget): - def __init__(self, model, reticle_selector): + def __init__(self, model, reticle_selector, stage_controller): super().__init__() self.model = model self.reticle_selector = reticle_selector self.reticle = None + self.stage_controller = stage_controller - self.ui = loadUi(os.path.join(ui_dir, "calc.ui"), self) + self.ui = loadUi(os.path.join(ui_dir, "calc_move.ui"), self) self.setWindowTitle(f"Calculator") self.setWindowFlags(Qt.Window | Qt.WindowMinimizeButtonHint | \ Qt.WindowMaximizeButtonHint | Qt.WindowCloseButtonHint) @@ -27,6 +28,7 @@ def __init__(self, model, reticle_selector): # Create the number of GroupBox for the number of stages self._create_stage_groupboxes() self._connect_clear_buttons() + self._connect_move_stage_buttons() self.reticle_selector.currentIndexChanged.connect(self._setCurrentReticle) self.model.add_calc_instance(self) @@ -249,7 +251,8 @@ def _create_stage_groupboxes(self): for sn in self.model.stages.keys(): # Load the QGroupBox from the calc_QGroupBox.ui file group_box = QGroupBox(self) - loadUi(os.path.join(ui_dir, "calc_QGroupBox.ui"), group_box) + #loadUi(os.path.join(ui_dir, "calc_QGroupBox.ui"), group_box) # TODO + loadUi(os.path.join(ui_dir, "calc_QGroupBox_move.ui"), group_box) # Set the visible title of the QGroupBox to sn group_box.setTitle(f"{sn}") @@ -258,9 +261,9 @@ def _create_stage_groupboxes(self): group_box.setObjectName(f"groupBox_{sn}") # Find all QLineEdits and QPushButtons in the group_box and rename them - # globalX -> globalX_{sn} .. - # localX -> localX_{sn} .. - # ClearBtn -> ClearBtn_{sn} .. + # globalX -> globalX_{sn} / localX -> localX_{sn} + # ClearBtn -> ClearBtn_{sn} + # moveStageXY -> moveStageXY_{sn} for line_edit in group_box.findChildren(QLineEdit): line_edit.setObjectName(f"{line_edit.objectName()}_{sn}") @@ -269,7 +272,80 @@ def _create_stage_groupboxes(self): push_button.setObjectName(f"{push_button.objectName()}_{sn}") # Add the newly created QGroupBox to the layout - self.ui.verticalLayout_QBox.addWidget(group_box) + widget_count = self.ui.verticalLayout_QBox.count() + self.ui.verticalLayout_QBox.insertWidget(widget_count - 1, group_box) + #self.ui.verticalLayout_QBox.addWidget(group_box) + + def _connect_move_stage_buttons(self): + stop_button = self.ui.findChild(QPushButton, f"stopAllStages") + if stop_button: + stop_button.clicked.connect(lambda: self._stop_stage("stopAll")) + + for stage_sn in self.model.stages.keys(): + moveXY_button = self.findChild(QPushButton, f"moveStageXY_{stage_sn}") + if moveXY_button: + moveXY_button.clicked.connect(self._create_stage_function(stage_sn, "moveXY")) + + def _stop_stage(self, move_type): + print(f"Stopping all stages.") + command = { + "move_type": move_type + } + self.stage_controller.stop_request(command) + + def _create_stage_function(self, stage_sn, move_type): + """Create a function that moves the stage to the given global coordinates.""" + return lambda: self._move_stage(stage_sn, move_type) + + def _move_stage(self, stage_sn, move_type): + try: + # Convert the text to float, round it, then cast to int + x = float(self.findChild(QLineEdit, f"localX_{stage_sn}").text())/1000 + y = float(self.findChild(QLineEdit, f"localY_{stage_sn}").text())/1000 + z = 15.0 + except ValueError as e: + logger.warning(f"Invalid input for stage {stage_sn}: {e}") + return # Optionally handle the error gracefully (e.g., show a message to the user) + + # Use the confirm_move_stage function to ask for confirmation + if self._confirm_move_stage(x, y): + # If the user confirms, proceed with moving the stage + print(f"Moving stage {stage_sn} to ({np.round(x*1000)}, {np.round(y*1000)}, 0)") + command = { + "stage_sn": stage_sn, + "move_type": move_type, + "x": x, + "y": y, + "z": z + } + self.stage_controller.move_request(command) + else: + # If the user cancels, do nothing + print("Stage move canceled by user.") + + def _confirm_move_stage(self, x, y): + """ + Displays a confirmation dialog asking the user if they are sure about moving the stage. + + Args: + x (float): The x-coordinate for stage movement. + y (float): The y-coordinate for stage movement. + + Returns: + bool: True if the user confirms the move, False otherwise. + """ + + x = round(x*1000) + y = round(y*1000) + message = f"Are you sure you want to move the stage to the local coords, ({x}, {y}, 0)?" + response = QMessageBox.warning( + self, + "Move Stage Confirmation", + message, + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + return response == QMessageBox.Yes def _connect_clear_buttons(self): for stage_sn in self.model.stages.keys(): diff --git a/parallax/probe_calibration.py b/parallax/probe_calibration.py index fd3be61..cf9f2ea 100644 --- a/parallax/probe_calibration.py +++ b/parallax/probe_calibration.py @@ -84,7 +84,7 @@ def __init__(self, model, stage_listener): [0.0, 0.0, 0.0, 0.0], ] ) - + self.model_LR, self.transM_LR, self.transM_LR_prev = None, None, None self.origin, self.R, self.scale = None, None, np.array([1, 1, 1]) self.avg_err = None diff --git a/parallax/stage_controller.py b/parallax/stage_controller.py new file mode 100644 index 0000000..ad51cd6 --- /dev/null +++ b/parallax/stage_controller.py @@ -0,0 +1,184 @@ +import logging +import requests +import json +from PyQt5.QtCore import QObject, QTimer + +# Set logger name +logger = logging.getLogger(__name__) +logger.setLevel(logging.WARNING) + +# Set the logging level for PyQt5.uic.uiparser/properties to WARNING to ignore DEBUG messages +logging.getLogger("PyQt5.uic.uiparser").setLevel(logging.WARNING) +logging.getLogger("PyQt5.uic.properties").setLevel(logging.WARNING) + +class StageController(QObject): + def __init__(self, model): + super().__init__() + self.model = model + self.url = self.model.stage_listener_url + self.timer_count = 0 + + # These commands will be updated dynamically based on the parsed probe index + self.probeStepMode_command = { + "PutId": "ProbeStepMode", + "Probe": 0, # Default value, will be updated dynamically + "StepMode": 0 # StepMode=0 (for Coarse), =1 (for Fine), =2 (for Insertion) + } + self.probeMotion_command = { + "PutId" : "ProbeMotion", + "Probe": 0, # Probe=0 (for probe A), =1 (for Probe B), etc. Default value, will be updated dynamically + "Absolute": 1, # Absolute=0 (for relative move) =1 (for absolute target) + "Stereotactic": 0, # Stereotactic=0 (for local [stage] coordinates) =1 (for stereotactic) + "AxisMask": 7 # AxisMask=1 (for X), =2 (for Y), =4 (for Z) or any combination (e.g. 7 for XYZ) + } + + self.probeStop_command = { + "PutId": "ProbeStop", + "Probe": 0 # Default value, will be updated dynamically + } + + def stop_request(self, command): + move_type = command["move_type"] + if move_type == "stopAll": + # Stop the timer if it's active + if hasattr(self, 'timer') and self.timer.isActive(): + self.timer.stop() + logger.info("Timer stopped. Outside SW may be interrupting.") + + # Get the status to retrieve all available probes + status = self._get_status() + if status is None: + logger.warning("Failed to retrieve status while trying to stop all probes.") + return + + # Iterate over all probes and send the stop command + probe_array = status.get("ProbeArray", []) + for i, probe in enumerate(probe_array): + self.probeStop_command["Probe"] = i # Set the correct probe index + self._send_command(self.probeStop_command) + logger.info(f"Sent stop command to probe {i}") + logger.info("Sent stop command to all available probes.") + + def move_request(self, command): + """ + input format: + command = { + "stage_sn": stage_sn, + "move_type": move_type # "moveXY" + "x": x, + "y": y, + "z": z + } + """ + move_type = command["move_type"] + stage_sn = command["stage_sn"] + # Get index of the probe based on the serial number + probe_index = self._get_probe_index(stage_sn) + if probe_index is None: + logger.warning(f"Failed to get probe index for stage: {stage_sn}") + return + + if move_type == "moveXY": + # update command to coarse and the command + self.probeStepMode_command["Probe"] = probe_index + self._send_command(self.probeStepMode_command) + + # update command to move z to 15 + self._update_move_command(probe_index, x=None, y=None, z=15.0) + # move the probe + self._send_command(self.probeMotion_command) + + # Reset timer_count for this new move command + self.timer_count = 0 + self.timer = QTimer(self) + self.timer.setInterval(500) # 500 ms + self.timer.timeout.connect(lambda: self._check_z_position(probe_index, 15.0, command)) + self.timer.start() + + def _check_z_position(self, probe_index, target_z, command): + """Check Z position and proceed with X, Y movement once target is reached.""" + self.timer_count += 1 + if self.timer_count > 30: # 30 * 500 ms = 15 seconds + if hasattr(self, 'timer') and self.timer.isActive(): + self.timer.stop() + logger.warning("Timer stopped due to timeout.") + return + + if self._is_z_at_target(probe_index, target_z): + if hasattr(self, 'timer') and self.timer.isActive(): + self.timer.stop() + logger.info("Timer stopped due to z is on the target.") + + # Update command to move (x, y, 0) + x = command["x"] + y = command["y"] + self._update_move_command(probe_index, x=x, y=y, z=None) + # Move the probe + self._send_command(self.probeMotion_command) + + def _is_z_at_target(self, probe_index, target_z): + """Check if the probe's Z coordinate has reached the target value.""" + status = self._get_status() + if status is None: + return False + + # Find the correct probe in the status by probe index + probe_array = status.get("ProbeArray", []) + if probe_index >= len(probe_array): + logger.warning(f"Invalid probe index: {probe_index}") + return False + + current_z = probe_array[probe_index].get("Stage_Z", None) + if current_z is None: + logger.warning(f"Failed to retrieve Z position for probe {probe_index}") + return False + + # Return whether the current Z value is close enough to the target + return abs(current_z - target_z) < 0.01 # Tolerance of 10 um + + def _update_move_command(self, probe_index, x=None, y=None, z=None): + self.probeMotion_command["Probe"] = probe_index + if x is not None: + self.probeMotion_command["X"] = x + if y is not None: + self.probeMotion_command["Y"] = y + if z is not None: + self.probeMotion_command["Z"] = z + + axis_mask = 0 + if x is not None: + axis_mask |= 1 # X-axis + if y is not None: + axis_mask |= 2 # Y-axis + if z is not None: + axis_mask |= 4 # Z-axis + self.probeMotion_command["AxisMask"] = axis_mask + + def _get_probe_index(self, stage_sn): + status = self._get_status() + if status is None: + return None + + # Find probe index based on serial number + probe_array = status.get("ProbeArray", []) + for i, probe in enumerate(probe_array): + if probe["SerialNumber"] == stage_sn: + return i # Set the corresponding probe index + + return None + + def _get_status(self): + response = requests.get(self.url) + if response.status_code == 200: + try: + return response.json() + except json.JSONDecodeError: + print("Response is not in JSON format:", response.text) + return None + else: + print(f"Failed to get status: {response.status_code}, {response.text}") + return None + + def _send_command(self, command): + headers = {'Content-Type': 'application/json'} + requests.put(self.url, data=json.dumps(command), headers=headers) diff --git a/parallax/stage_widget.py b/parallax/stage_widget.py index 977ac43..4920d60 100644 --- a/parallax/stage_widget.py +++ b/parallax/stage_widget.py @@ -22,6 +22,7 @@ from .calculator import Calculator from .reticle_metadata import ReticleMetadata from .screen_coords_mapper import ScreenCoordsMapper +from .stage_controller import StageController logger = logging.getLogger(__name__) logger.setLevel(logging.WARNING) @@ -170,9 +171,12 @@ def __init__(self, model, ui_dir, screen_widgets): self.filter = "no_filter" logger.debug(f"filter: {self.filter}") + # Stage controller + self.stage_controller = StageController(self.model) + # Calculator Button self.calculation_btn.hide() - self.calculator = Calculator(self.model, self.reticle_selector) + self.calculator = Calculator(self.model, self.reticle_selector, self.stage_controller) # Reticle Button self.reticle_metadata_btn.hide() diff --git a/ui/calc_QGroupBox_move.ui b/ui/calc_QGroupBox_move.ui new file mode 100644 index 0000000..0e27780 --- /dev/null +++ b/ui/calc_QGroupBox_move.ui @@ -0,0 +1,311 @@ + + + SN + + + + 0 + 0 + 914 + 123 + + + + + 750 + 100 + + + + GroupBox + + + QWidget{ +background-color: rgb(00,00,00); +color: #FFFFFF; +} +QPushButton{ + background-color: black; +} + QPushButton:pressed { + background-color: rgb(224, 0, 0); +} +QPushButton:hover { + background-color: rgb(100, 30, 30); +} +QPushButton#startButton:disabled:checked { + color: gray; +} +QPushButton#startButton:disabled:checked { + background-color: #ffaaaa; +} +QPushButton#startButton:disabled:!checked { + background-color: lightGreen; +} + +QMessageBox { + background-color: rgb(00,00,00); + color: #FFFFFF; +} +QMessageBox QLabel { + color: #FFFFFF; +} +QMessageBox QPushButton { + background-color: rgb(50,50,50); + color: #FFFFFF; +} +QMessageBox QPushButton:hover { + background-color: rgb(100, 30, 30); +} +QMessageBox QPushButton:pressed { + background-color: rgb(224, 0, 0); +} + + + + + 140 + 40 + 100 + 40 + + + + + 100 + 40 + + + + + 8 + + + + QLineEdit { + color: yellow; +} + + + + + + 250 + 40 + 100 + 40 + + + + + 100 + 40 + + + + + 8 + + + + QLineEdit { + color: yellow; +} + + + + + + 30 + 40 + 100 + 40 + + + + + 100 + 40 + + + + + 8 + + + + QLineEdit { + color: yellow; +} + + + + + + 410 + 40 + 100 + 40 + + + + + 100 + 40 + + + + + 8 + + + + + + + 360 + 50 + 40 + 21 + + + + + 40 + 0 + + + + + 40 + 16777215 + + + + + 12 + + + + + + + + + + 630 + 40 + 100 + 40 + + + + + 100 + 40 + + + + + 200 + 16777215 + + + + + 8 + + + + + + + 520 + 40 + 100 + 40 + + + + + 100 + 40 + + + + + 200 + 16777215 + + + + + 8 + + + + + + + 750 + 50 + 51 + 23 + + + + + 40 + 0 + + + + + 7 + + + + Clear + + + + + + 820 + 40 + 61 + 41 + + + + + 7 + + + + + + + + resources/move_xy0.pngresources/move_xy0.png + + + + 64 + 64 + + + + + + globalX + globalY + globalZ + convert + localX + localY + localZ + + + + diff --git a/ui/calc_move.ui b/ui/calc_move.ui new file mode 100644 index 0000000..02feeb3 --- /dev/null +++ b/ui/calc_move.ui @@ -0,0 +1,206 @@ + + + Form + + + + 0 + 0 + 953 + 638 + + + + Form + + + + resources/calc.pngresources/calc.png + + + QWidget{ +background-color: rgb(00,00,00); +color: #FFFFFF; +} +QPushButton{ + background-color: black; +} + QPushButton:pressed { + background-color: rgb(224, 0, 0); +} +QPushButton:hover { + background-color: rgb(100, 30, 30); +} +QPushButton#startButton:disabled:checked { + color: gray; +} +QPushButton#startButton:disabled:checked { + background-color: #ffaaaa; +} +QPushButton#startButton:disabled:!checked { + background-color: lightGreen; +} +QMessageBox { + background-color: rgb(00,00,00); + color: #FFFFFF; +} +QMessageBox QLabel { + color: #FFFFFF; +} +QMessageBox QPushButton { + background-color: rgb(50,50,50); + color: #FFFFFF; +} +QMessageBox QPushButton:hover { + background-color: rgb(100, 30, 30); +} +QMessageBox QPushButton:pressed { + background-color: rgb(224, 0, 0); +} + + + + + 10 + 10 + 921 + 601 + + + + + 10 + + + + + + + + 16777215 + 50 + + + + QLabel { + color: yellow; +} + + + Global + + + Qt::AlignCenter + + + + + + + + 16777215 + 50 + + + + Local + + + Qt::AlignCenter + + + + + + + + + + + QLabel { + color: yellow; +} + + + (x, y, z) + + + Qt::AlignCenter + + + + + + + (x, y, z) + + + Qt::AlignCenter + + + + + + + + + 20 + + + 10 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + resources/stop-sign.pngresources/stop-sign.png + + + + 64 + 64 + + + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + diff --git a/ui/resources/move_xy0.png b/ui/resources/move_xy0.png new file mode 100644 index 0000000..38898c6 Binary files /dev/null and b/ui/resources/move_xy0.png differ diff --git a/ui/resources/move_xy0_gray.png b/ui/resources/move_xy0_gray.png new file mode 100644 index 0000000..76eaac2 Binary files /dev/null and b/ui/resources/move_xy0_gray.png differ