Skip to content

Commit ad4200f

Browse files
authored
Merge pull request #85 from AllenNeuralDynamics/wip/move_stages
Wip/move stages
2 parents 303ca38 + c95c974 commit ad4200f

File tree

9 files changed

+792
-11
lines changed

9 files changed

+792
-11
lines changed

parallax/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import os
66

7-
__version__ = "0.37.26"
7+
__version__ = "0.37.27"
88

99
# allow multiple OpenMP instances
1010
os.environ["KMP_DUPLICATE_LIB_OK"] = "True"

parallax/calculator.py

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import os
22
import logging
33
import numpy as np
4-
from PyQt5.QtWidgets import QWidget, QGroupBox, QLineEdit, QPushButton, QLabel
4+
from PyQt5.QtWidgets import QWidget, QGroupBox, QLineEdit, QPushButton, QLabel, QMessageBox
55
from PyQt5.uic import loadUi
66
from PyQt5.QtCore import Qt
77

@@ -13,20 +13,22 @@
1313
ui_dir = os.path.join(os.path.dirname(package_dir), "ui")
1414

1515
class Calculator(QWidget):
16-
def __init__(self, model, reticle_selector):
16+
def __init__(self, model, reticle_selector, stage_controller):
1717
super().__init__()
1818
self.model = model
1919
self.reticle_selector = reticle_selector
2020
self.reticle = None
21+
self.stage_controller = stage_controller
2122

22-
self.ui = loadUi(os.path.join(ui_dir, "calc.ui"), self)
23+
self.ui = loadUi(os.path.join(ui_dir, "calc_move.ui"), self)
2324
self.setWindowTitle(f"Calculator")
2425
self.setWindowFlags(Qt.Window | Qt.WindowMinimizeButtonHint | \
2526
Qt.WindowMaximizeButtonHint | Qt.WindowCloseButtonHint)
2627

2728
# Create the number of GroupBox for the number of stages
2829
self._create_stage_groupboxes()
2930
self._connect_clear_buttons()
31+
self._connect_move_stage_buttons()
3032
self.reticle_selector.currentIndexChanged.connect(self._setCurrentReticle)
3133

3234
self.model.add_calc_instance(self)
@@ -249,7 +251,8 @@ def _create_stage_groupboxes(self):
249251
for sn in self.model.stages.keys():
250252
# Load the QGroupBox from the calc_QGroupBox.ui file
251253
group_box = QGroupBox(self)
252-
loadUi(os.path.join(ui_dir, "calc_QGroupBox.ui"), group_box)
254+
#loadUi(os.path.join(ui_dir, "calc_QGroupBox.ui"), group_box) # TODO
255+
loadUi(os.path.join(ui_dir, "calc_QGroupBox_move.ui"), group_box)
253256

254257
# Set the visible title of the QGroupBox to sn
255258
group_box.setTitle(f"{sn}")
@@ -258,9 +261,9 @@ def _create_stage_groupboxes(self):
258261
group_box.setObjectName(f"groupBox_{sn}")
259262

260263
# Find all QLineEdits and QPushButtons in the group_box and rename them
261-
# globalX -> globalX_{sn} ..
262-
# localX -> localX_{sn} ..
263-
# ClearBtn -> ClearBtn_{sn} ..
264+
# globalX -> globalX_{sn} / localX -> localX_{sn}
265+
# ClearBtn -> ClearBtn_{sn}
266+
# moveStageXY -> moveStageXY_{sn}
264267
for line_edit in group_box.findChildren(QLineEdit):
265268
line_edit.setObjectName(f"{line_edit.objectName()}_{sn}")
266269

@@ -269,7 +272,80 @@ def _create_stage_groupboxes(self):
269272
push_button.setObjectName(f"{push_button.objectName()}_{sn}")
270273

271274
# Add the newly created QGroupBox to the layout
272-
self.ui.verticalLayout_QBox.addWidget(group_box)
275+
widget_count = self.ui.verticalLayout_QBox.count()
276+
self.ui.verticalLayout_QBox.insertWidget(widget_count - 1, group_box)
277+
#self.ui.verticalLayout_QBox.addWidget(group_box)
278+
279+
def _connect_move_stage_buttons(self):
280+
stop_button = self.ui.findChild(QPushButton, f"stopAllStages")
281+
if stop_button:
282+
stop_button.clicked.connect(lambda: self._stop_stage("stopAll"))
283+
284+
for stage_sn in self.model.stages.keys():
285+
moveXY_button = self.findChild(QPushButton, f"moveStageXY_{stage_sn}")
286+
if moveXY_button:
287+
moveXY_button.clicked.connect(self._create_stage_function(stage_sn, "moveXY"))
288+
289+
def _stop_stage(self, move_type):
290+
print(f"Stopping all stages.")
291+
command = {
292+
"move_type": move_type
293+
}
294+
self.stage_controller.stop_request(command)
295+
296+
def _create_stage_function(self, stage_sn, move_type):
297+
"""Create a function that moves the stage to the given global coordinates."""
298+
return lambda: self._move_stage(stage_sn, move_type)
299+
300+
def _move_stage(self, stage_sn, move_type):
301+
try:
302+
# Convert the text to float, round it, then cast to int
303+
x = float(self.findChild(QLineEdit, f"localX_{stage_sn}").text())/1000
304+
y = float(self.findChild(QLineEdit, f"localY_{stage_sn}").text())/1000
305+
z = 15.0
306+
except ValueError as e:
307+
logger.warning(f"Invalid input for stage {stage_sn}: {e}")
308+
return # Optionally handle the error gracefully (e.g., show a message to the user)
309+
310+
# Use the confirm_move_stage function to ask for confirmation
311+
if self._confirm_move_stage(x, y):
312+
# If the user confirms, proceed with moving the stage
313+
print(f"Moving stage {stage_sn} to ({np.round(x*1000)}, {np.round(y*1000)}, 0)")
314+
command = {
315+
"stage_sn": stage_sn,
316+
"move_type": move_type,
317+
"x": x,
318+
"y": y,
319+
"z": z
320+
}
321+
self.stage_controller.move_request(command)
322+
else:
323+
# If the user cancels, do nothing
324+
print("Stage move canceled by user.")
325+
326+
def _confirm_move_stage(self, x, y):
327+
"""
328+
Displays a confirmation dialog asking the user if they are sure about moving the stage.
329+
330+
Args:
331+
x (float): The x-coordinate for stage movement.
332+
y (float): The y-coordinate for stage movement.
333+
334+
Returns:
335+
bool: True if the user confirms the move, False otherwise.
336+
"""
337+
338+
x = round(x*1000)
339+
y = round(y*1000)
340+
message = f"Are you sure you want to move the stage to the local coords, ({x}, {y}, 0)?"
341+
response = QMessageBox.warning(
342+
self,
343+
"Move Stage Confirmation",
344+
message,
345+
QMessageBox.Yes | QMessageBox.No,
346+
QMessageBox.No,
347+
)
348+
return response == QMessageBox.Yes
273349

274350
def _connect_clear_buttons(self):
275351
for stage_sn in self.model.stages.keys():

parallax/probe_calibration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def __init__(self, model, stage_listener):
8484
[0.0, 0.0, 0.0, 0.0],
8585
]
8686
)
87-
87+
8888
self.model_LR, self.transM_LR, self.transM_LR_prev = None, None, None
8989
self.origin, self.R, self.scale = None, None, np.array([1, 1, 1])
9090
self.avg_err = None

parallax/stage_controller.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import logging
2+
import requests
3+
import json
4+
from PyQt5.QtCore import QObject, QTimer
5+
6+
# Set logger name
7+
logger = logging.getLogger(__name__)
8+
logger.setLevel(logging.WARNING)
9+
10+
# Set the logging level for PyQt5.uic.uiparser/properties to WARNING to ignore DEBUG messages
11+
logging.getLogger("PyQt5.uic.uiparser").setLevel(logging.WARNING)
12+
logging.getLogger("PyQt5.uic.properties").setLevel(logging.WARNING)
13+
14+
class StageController(QObject):
15+
def __init__(self, model):
16+
super().__init__()
17+
self.model = model
18+
self.url = self.model.stage_listener_url
19+
self.timer_count = 0
20+
21+
# These commands will be updated dynamically based on the parsed probe index
22+
self.probeStepMode_command = {
23+
"PutId": "ProbeStepMode",
24+
"Probe": 0, # Default value, will be updated dynamically
25+
"StepMode": 0 # StepMode=0 (for Coarse), =1 (for Fine), =2 (for Insertion)
26+
}
27+
self.probeMotion_command = {
28+
"PutId" : "ProbeMotion",
29+
"Probe": 0, # Probe=0 (for probe A), =1 (for Probe B), etc. Default value, will be updated dynamically
30+
"Absolute": 1, # Absolute=0 (for relative move) =1 (for absolute target)
31+
"Stereotactic": 0, # Stereotactic=0 (for local [stage] coordinates) =1 (for stereotactic)
32+
"AxisMask": 7 # AxisMask=1 (for X), =2 (for Y), =4 (for Z) or any combination (e.g. 7 for XYZ)
33+
}
34+
35+
self.probeStop_command = {
36+
"PutId": "ProbeStop",
37+
"Probe": 0 # Default value, will be updated dynamically
38+
}
39+
40+
def stop_request(self, command):
41+
move_type = command["move_type"]
42+
if move_type == "stopAll":
43+
# Stop the timer if it's active
44+
if hasattr(self, 'timer') and self.timer.isActive():
45+
self.timer.stop()
46+
logger.info("Timer stopped. Outside SW may be interrupting.")
47+
48+
# Get the status to retrieve all available probes
49+
status = self._get_status()
50+
if status is None:
51+
logger.warning("Failed to retrieve status while trying to stop all probes.")
52+
return
53+
54+
# Iterate over all probes and send the stop command
55+
probe_array = status.get("ProbeArray", [])
56+
for i, probe in enumerate(probe_array):
57+
self.probeStop_command["Probe"] = i # Set the correct probe index
58+
self._send_command(self.probeStop_command)
59+
logger.info(f"Sent stop command to probe {i}")
60+
logger.info("Sent stop command to all available probes.")
61+
62+
def move_request(self, command):
63+
"""
64+
input format:
65+
command = {
66+
"stage_sn": stage_sn,
67+
"move_type": move_type # "moveXY"
68+
"x": x,
69+
"y": y,
70+
"z": z
71+
}
72+
"""
73+
move_type = command["move_type"]
74+
stage_sn = command["stage_sn"]
75+
# Get index of the probe based on the serial number
76+
probe_index = self._get_probe_index(stage_sn)
77+
if probe_index is None:
78+
logger.warning(f"Failed to get probe index for stage: {stage_sn}")
79+
return
80+
81+
if move_type == "moveXY":
82+
# update command to coarse and the command
83+
self.probeStepMode_command["Probe"] = probe_index
84+
self._send_command(self.probeStepMode_command)
85+
86+
# update command to move z to 15
87+
self._update_move_command(probe_index, x=None, y=None, z=15.0)
88+
# move the probe
89+
self._send_command(self.probeMotion_command)
90+
91+
# Reset timer_count for this new move command
92+
self.timer_count = 0
93+
self.timer = QTimer(self)
94+
self.timer.setInterval(500) # 500 ms
95+
self.timer.timeout.connect(lambda: self._check_z_position(probe_index, 15.0, command))
96+
self.timer.start()
97+
98+
def _check_z_position(self, probe_index, target_z, command):
99+
"""Check Z position and proceed with X, Y movement once target is reached."""
100+
self.timer_count += 1
101+
if self.timer_count > 30: # 30 * 500 ms = 15 seconds
102+
if hasattr(self, 'timer') and self.timer.isActive():
103+
self.timer.stop()
104+
logger.warning("Timer stopped due to timeout.")
105+
return
106+
107+
if self._is_z_at_target(probe_index, target_z):
108+
if hasattr(self, 'timer') and self.timer.isActive():
109+
self.timer.stop()
110+
logger.info("Timer stopped due to z is on the target.")
111+
112+
# Update command to move (x, y, 0)
113+
x = command["x"]
114+
y = command["y"]
115+
self._update_move_command(probe_index, x=x, y=y, z=None)
116+
# Move the probe
117+
self._send_command(self.probeMotion_command)
118+
119+
def _is_z_at_target(self, probe_index, target_z):
120+
"""Check if the probe's Z coordinate has reached the target value."""
121+
status = self._get_status()
122+
if status is None:
123+
return False
124+
125+
# Find the correct probe in the status by probe index
126+
probe_array = status.get("ProbeArray", [])
127+
if probe_index >= len(probe_array):
128+
logger.warning(f"Invalid probe index: {probe_index}")
129+
return False
130+
131+
current_z = probe_array[probe_index].get("Stage_Z", None)
132+
if current_z is None:
133+
logger.warning(f"Failed to retrieve Z position for probe {probe_index}")
134+
return False
135+
136+
# Return whether the current Z value is close enough to the target
137+
return abs(current_z - target_z) < 0.01 # Tolerance of 10 um
138+
139+
def _update_move_command(self, probe_index, x=None, y=None, z=None):
140+
self.probeMotion_command["Probe"] = probe_index
141+
if x is not None:
142+
self.probeMotion_command["X"] = x
143+
if y is not None:
144+
self.probeMotion_command["Y"] = y
145+
if z is not None:
146+
self.probeMotion_command["Z"] = z
147+
148+
axis_mask = 0
149+
if x is not None:
150+
axis_mask |= 1 # X-axis
151+
if y is not None:
152+
axis_mask |= 2 # Y-axis
153+
if z is not None:
154+
axis_mask |= 4 # Z-axis
155+
self.probeMotion_command["AxisMask"] = axis_mask
156+
157+
def _get_probe_index(self, stage_sn):
158+
status = self._get_status()
159+
if status is None:
160+
return None
161+
162+
# Find probe index based on serial number
163+
probe_array = status.get("ProbeArray", [])
164+
for i, probe in enumerate(probe_array):
165+
if probe["SerialNumber"] == stage_sn:
166+
return i # Set the corresponding probe index
167+
168+
return None
169+
170+
def _get_status(self):
171+
response = requests.get(self.url)
172+
if response.status_code == 200:
173+
try:
174+
return response.json()
175+
except json.JSONDecodeError:
176+
print("Response is not in JSON format:", response.text)
177+
return None
178+
else:
179+
print(f"Failed to get status: {response.status_code}, {response.text}")
180+
return None
181+
182+
def _send_command(self, command):
183+
headers = {'Content-Type': 'application/json'}
184+
requests.put(self.url, data=json.dumps(command), headers=headers)

parallax/stage_widget.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from .calculator import Calculator
2323
from .reticle_metadata import ReticleMetadata
2424
from .screen_coords_mapper import ScreenCoordsMapper
25+
from .stage_controller import StageController
2526

2627
logger = logging.getLogger(__name__)
2728
logger.setLevel(logging.WARNING)
@@ -170,9 +171,12 @@ def __init__(self, model, ui_dir, screen_widgets):
170171
self.filter = "no_filter"
171172
logger.debug(f"filter: {self.filter}")
172173

174+
# Stage controller
175+
self.stage_controller = StageController(self.model)
176+
173177
# Calculator Button
174178
self.calculation_btn.hide()
175-
self.calculator = Calculator(self.model, self.reticle_selector)
179+
self.calculator = Calculator(self.model, self.reticle_selector, self.stage_controller)
176180

177181
# Reticle Button
178182
self.reticle_metadata_btn.hide()

0 commit comments

Comments
 (0)