Skip to content

Commit

Permalink
Merge pull request #85 from AllenNeuralDynamics/wip/move_stages
Browse files Browse the repository at this point in the history
Wip/move stages
  • Loading branch information
jsiegle authored Sep 23, 2024
2 parents 303ca38 + c95c974 commit ad4200f
Show file tree
Hide file tree
Showing 9 changed files with 792 additions and 11 deletions.
2 changes: 1 addition & 1 deletion parallax/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
92 changes: 84 additions & 8 deletions parallax/calculator.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -13,20 +13,22 @@
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)

# 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)
Expand Down Expand Up @@ -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}")
Expand All @@ -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}")

Expand All @@ -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():
Expand Down
2 changes: 1 addition & 1 deletion parallax/probe_calibration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
184 changes: 184 additions & 0 deletions parallax/stage_controller.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 5 additions & 1 deletion parallax/stage_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
Loading

0 comments on commit ad4200f

Please sign in to comment.