Skip to content

Commit 4aef141

Browse files
Merge pull request #407 from MichaelPhilbin/feature/move-position-DataStream
Added a DataStream option for calling move_to. Issue #402
2 parents e994223 + 14052a6 commit 4aef141

3 files changed

Lines changed: 353 additions & 225 deletions

File tree

catkit2/services/physik_stage_controller/physik_stage_controller.py

Lines changed: 173 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,12 @@ def __init__(self):
1212

1313
self.pidevice = None
1414
self.is_initialized = False
15+
self.is_moving = False
16+
self.move_event = threading.Event()
1517

1618
# Create lock for device access
1719
self.mutex = threading.Lock()
1820

19-
# Store current positions
20-
self.current_positions = {}
21-
2221
def open(self):
2322
"""Initialize the PI device and connect."""
2423
controller_name = self.config.get('controller_name', 'E-727.3SDA')
@@ -33,17 +32,6 @@ def open(self):
3332
'y': 2
3433
})
3534

36-
# Get initial positions from config
37-
initial_pos = self.config.get('initial_position', None)
38-
39-
# only initialized positions when asked, otherwise keep last settings
40-
if initial_pos:
41-
# Initialize current positions
42-
for name, axis_num in self.axis_map.items():
43-
self.current_positions[axis_num] = initial_pos.get(name, 0.0)
44-
45-
self.log.info(f'Initial positions: {initial_pos}')
46-
4735
# Connect to device
4836
self.pidevice = GCSDevice(controller_name)
4937
device_found = False
@@ -61,17 +49,36 @@ def open(self):
6149
# Initialize stages
6250
pitools.startup(self.pidevice)
6351

64-
# Move to initial positions
65-
with self.mutex:
66-
self.pidevice.MOV(self.current_positions)
67-
pitools.waitontarget(self.pidevice)
52+
# Get initial positions from config
53+
initial_pos = self.config.get('initial_position', None)
6854

69-
self.is_initialized = True
55+
# Move to initial positions (if provided)
56+
if initial_pos:
57+
axis_positions = {
58+
self.axis_map[name]: float(value)
59+
for name, value in initial_pos.items()
60+
if name in self.axis_map
61+
}
62+
63+
if axis_positions:
64+
with self.mutex:
65+
self.pidevice.MOV(axis_positions)
66+
pitools.waitontarget(self.pidevice)
67+
68+
self.log.info(f'Initial positions: {initial_pos}')
7069

71-
# Create data streams for telemetry
70+
# Create data streams
7271
num_axes = len(self.axis_map)
7372
self.positions = self.make_data_stream('positions', 'float64', [num_axes], 20)
7473
self.target_positions = self.make_data_stream('target_positions', 'float64', [num_axes], 20)
74+
# self.target_positions_wait = self.make_data_stream('target_positions_wait', 'float64', [num_axes], 20)
75+
76+
# Precompute reverse map for speed
77+
self.axis_num_to_name = {num: name for name, num in self.axis_map.items()}
78+
79+
# Start the worker threads
80+
threading.Thread(target=self._target_positions_worker, daemon=True).start()
81+
self.log.info("Worker Target Positions started with 250ms timeout")
7582

7683
# Submit initial positions
7784
self._submit_positions()
@@ -81,54 +88,74 @@ def open(self):
8188
self._make_axis_property(name, axis_num)
8289

8390
# Create commands
84-
self.make_command('move_to', self.move_to)
91+
self.make_command('move_to', self.move_to_wrapper)
92+
self.make_command('move_and_wait', self.move_and_wait)
8593
self.make_command('move_relative', self.move_relative)
94+
self.make_command('move_relative_and_wait', self.move_relative_and_wait)
8695
self.make_command('get_positions', self.get_positions)
8796
self.make_command('stop_motion', self.stop_motion)
97+
self.make_command('wait_on_target', self.wait_on_target)
98+
99+
self.is_initialized = True
88100

89101
def _make_axis_property(self, name, axis_num):
90102
"""Create a property for an axis."""
91103
def getter():
92-
return self.current_positions[axis_num]
104+
with self.mutex:
105+
return self.pidevice.qPOS()[str(axis_num)]
93106

94107
def setter(value):
95-
self.move_to({name: float(value)})
108+
with self.mutex:
109+
actual = self.pidevice.qPOS()
110+
positions = {n: actual[str(num)] for n, num in self.axis_map.items()}
111+
positions[name] = float(value)
112+
self.move_to_wrapper(positions)
96113

97114
self.make_property(f'position_{name}', getter, setter)
98115

99116
def _submit_positions(self):
100-
"""Submit current positions to telemetry stream."""
101-
# Get positions in order of axis numbers
102-
pos_array = np.array([self.current_positions[i] for i in sorted(self.current_positions.keys())], dtype='float64')
117+
"""Submit current hardware positions to telemetry stream."""
118+
with self.mutex:
119+
actual = self.pidevice.qPOS()
120+
pos_array = np.array(
121+
[actual[str(num)] for num in sorted(self.axis_map.values())],
122+
dtype='float64'
123+
)
103124
self.positions.submit_data(pos_array)
104125

126+
def _target_positions_worker(self):
127+
"""Worker thread to handle move_to commands from the target_positions data stream."""
128+
while not self.should_shut_down:
129+
try:
130+
frame = self.target_positions.get_next_frame(wait_time_in_ms=250)
131+
data = frame.data
132+
positions = {
133+
self.axis_num_to_name[num]: float(data[idx])
134+
for idx, num in enumerate(sorted(self.axis_map.values()))
135+
}
136+
self.move_to(positions)
137+
self.sleep(0)
138+
except RuntimeError:
139+
pass
140+
105141
def main(self):
106-
"""Main loop - monitor positions."""
142+
"""Main loop - polls for move completion and updates telemetry when motion stops."""
107143
while not self.should_shut_down:
108-
if self.is_initialized:
109-
try:
110-
with self.mutex:
111-
# Query actual positions from device
112-
actual_positions = self.pidevice.qPOS()
113-
114-
# Update stored positions
115-
for axis_num in self.current_positions.keys():
116-
if axis_num in actual_positions:
117-
self.current_positions[axis_num] = actual_positions[axis_num]
118-
119-
# Submit telemetry
144+
if self.is_moving:
145+
with self.mutex:
146+
on_target = all(self.pidevice.qONT().values())
147+
if on_target:
148+
self.is_moving = False
149+
self.move_event.clear()
120150
self._submit_positions()
121-
122-
except Exception as e:
123-
self.log.error(f"Error reading positions: {e}")
124-
125-
self.sleep(0.1) # Update at 10 Hz
151+
self.sleep(0)
126152

127153
def close(self):
128154
"""Close the PI device connection."""
129155
if self.pidevice is not None:
130156
try:
131157
with self.mutex:
158+
self.pidevice.STP()
132159
self.pidevice.CloseConnection()
133160
self.log.info("PI device connection closed")
134161
except Exception as e:
@@ -137,9 +164,17 @@ def close(self):
137164
self.pidevice = None
138165
self.is_initialized = False
139166

167+
def move_to_wrapper(self, positions):
168+
"""Wrapper for move_to command to submit target positions to the data stream."""
169+
target_move = np.array([positions[name] for name in sorted(self.axis_map.keys())], dtype=np.float64)
170+
self.target_positions.submit_data(target_move)
171+
140172
def move_to(self, positions):
141173
"""
142-
Move to absolute positions.
174+
Move to absolute positions without waiting for completion.
175+
Telemetry is updated asynchronously by the main loop once the stage reaches target.
176+
177+
Keep in mind telemetry will update only when movement has stopped.
143178
144179
Parameters
145180
----------
@@ -149,7 +184,6 @@ def move_to(self, positions):
149184
if not self.is_initialized:
150185
raise RuntimeError("Controller not initialized")
151186

152-
# Convert named axes to axis numbers
153187
axis_positions = {}
154188
for name, value in positions.items():
155189
if name in self.axis_map:
@@ -161,21 +195,50 @@ def move_to(self, positions):
161195
if not axis_positions:
162196
return
163197

164-
# Command the device
165198
with self.mutex:
166199
self.pidevice.MOV(axis_positions)
167200

168-
# Update stored positions
169-
self.current_positions.update(axis_positions)
201+
self.is_moving = True
202+
self.move_event.set()
203+
204+
205+
def move_and_wait(self, positions):
206+
"""
207+
Move to absolute positions and wait for completion.
208+
Telemetry is updated synchronously before returning.
209+
210+
Parameters
211+
----------
212+
positions : dict
213+
Dictionary with axis names (e.g., {'x': 10.0, 'y': 20.0})
214+
"""
215+
if not self.is_initialized:
216+
raise RuntimeError("Controller not initialized")
217+
218+
axis_positions = {}
219+
for name, value in positions.items():
220+
if name in self.axis_map:
221+
axis_num = self.axis_map[name]
222+
axis_positions[axis_num] = float(value)
223+
else:
224+
self.log.warning(f"Unknown axis name: {name}")
225+
226+
if not axis_positions:
227+
return
228+
229+
with self.mutex:
230+
self.pidevice.MOV(axis_positions)
170231

171-
# Submit telemetry
172-
target_array = np.array([axis_positions.get(i, self.current_positions[i])
173-
for i in sorted(self.current_positions.keys())], dtype='float64')
174-
self.target_positions.submit_data(target_array)
232+
self.wait_on_target(timeout=5)
233+
self._submit_positions()
175234

176235
def move_relative(self, deltas):
177236
"""
178-
Move relative to current positions.
237+
Move relative to current positions without waiting for completion.
238+
Queries actual hardware position as the reference point for the relative move.
239+
Telemetry is updated asynchronously by the main loop once the stage reaches target.
240+
241+
Keep in mind telemetry will update only when movement has stopped.
179242
180243
Parameters
181244
----------
@@ -185,31 +248,70 @@ def move_relative(self, deltas):
185248
if not self.is_initialized:
186249
raise RuntimeError("Controller not initialized")
187250

188-
# Convert to absolute positions
189251
absolute_positions = {}
252+
253+
with self.mutex:
254+
actual_positions = self.pidevice.qPOS()
255+
190256
for name, delta in deltas.items():
191257
if name in self.axis_map:
192258
axis_num = self.axis_map[name]
193-
absolute_positions[name] = self.current_positions[axis_num] + float(delta)
259+
absolute_positions[name] = actual_positions[str(axis_num)] + float(delta)
194260
else:
195261
self.log.warning(f"Unknown axis name: {name}")
196262

197263
if absolute_positions:
198-
self.move_to(absolute_positions)
264+
target_move = np.array([absolute_positions[name] for name in sorted(self.axis_map.keys())], dtype=np.float64)
265+
self.target_positions.submit_data(target_move)
266+
267+
def move_relative_and_wait(self, deltas):
268+
"""
269+
Move relative to current positions and wait for completion.
270+
Queries actual hardware position as the reference point for the relative move.
271+
Telemetry is updated synchronously before returning.
272+
273+
Parameters
274+
----------
275+
deltas : dict
276+
Dictionary with axis names and relative movements (e.g., {'x': 1.0, 'y': -0.5})
277+
"""
278+
if not self.is_initialized:
279+
raise RuntimeError("Controller not initialized")
280+
281+
absolute_positions = {}
282+
283+
with self.mutex:
284+
actual_positions = self.pidevice.qPOS()
285+
286+
for name, delta in deltas.items():
287+
if name in self.axis_map:
288+
axis_num = self.axis_map[name]
289+
absolute_positions[name] = actual_positions[str(axis_num)] + float(delta)
290+
else:
291+
self.log.warning(f"Unknown axis name: {name}")
292+
293+
if absolute_positions:
294+
self.move_and_wait(absolute_positions)
199295

200296
def get_positions(self):
201297
"""
202-
Get current positions.
298+
Get current positions from hardware.
203299
204300
Returns
205301
-------
206302
positions : dict
207-
Dictionary with axis names and positions
303+
Dictionary with axis names and actual hardware positions
208304
"""
209-
positions = {}
210-
for name, axis_num in self.axis_map.items():
211-
positions[name] = self.current_positions[axis_num]
212-
return positions
305+
if not self.is_initialized:
306+
raise RuntimeError("Controller not initialized")
307+
308+
with self.mutex:
309+
actual = self.pidevice.qPOS()
310+
311+
return {
312+
name: actual[str(axis_num)]
313+
for name, axis_num in self.axis_map.items()
314+
}
213315

214316
def stop_motion(self):
215317
"""Stop all motion immediately."""
@@ -219,13 +321,16 @@ def stop_motion(self):
219321
try:
220322
with self.mutex:
221323
self.pidevice.STP()
324+
self.is_moving = False
325+
self.move_event.clear()
222326
self.log.info("Motion stopped")
223327
except Exception as e:
224328
self.log.error(f"Error stopping motion: {e}")
225329

226-
def wait_on_target(self, timeout=None):
330+
def wait_on_target(self, timeout=5):
227331
"""
228332
Wait for all axes to reach their target positions.
333+
Default timeout is 5 seconds to prevent hanging indefinitely if something goes wrong.
229334
230335
Parameters
231336
----------
@@ -235,11 +340,10 @@ def wait_on_target(self, timeout=None):
235340
if not self.is_initialized:
236341
return
237342

238-
with self.mutex:
239-
if timeout is not None:
240-
pitools.waitontarget(self.pidevice, timeout=timeout)
241-
else:
242-
pitools.waitontarget(self.pidevice)
343+
if timeout is not None:
344+
pitools.waitontarget(self.pidevice, timeout=timeout)
345+
else:
346+
pitools.waitontarget(self.pidevice)
243347

244348

245349
if __name__ == '__main__':

0 commit comments

Comments
 (0)