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