Skip to content

Commit 19e7ec5

Browse files
hannalee2kjy5
andauthored
Parallax (#445)
* Added parallax binding * No port option provided. Need to be updated * Update w to 15000-w in space conversion function * Update SERVER_DATA_UPDATE_RATE * Handle Parallax custom binding startup * Add HTTP port option to parallax binding * Upgrade aquarium to support parallax port * Make _manipulator_data raise error instead of return None * Update documentation to add Parallax as a platform under New Scale --------- Co-authored-by: Kenneth Yang <[email protected]> Co-authored-by: Kenneth Yang <[email protected]>
1 parent 0b86507 commit 19e7ec5

File tree

5 files changed

+300
-15
lines changed

5 files changed

+300
-15
lines changed

docs/home/supported_manipulators.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ This is a current list of planned and supported manipulators in Ephys Link. If y
44
here, we suggest reaching out to your manipulator's manufacturer to request support for Ephys Link. Direct them to
55
contact [Kenneth Yang and Daniel Birman](https://virtualbrainlab.org/about/overview.html)!
66

7-
| Manufacturer | Model |
8-
|--------------|--------------------------------------------------------|
9-
| Sensapex | <ul> <li>uMp-4</li> <li>uMp-3</li> </ul> |
10-
| New Scale | <ul> <li>Pathfinder MPM Control v2.8+</li> </ul> |
11-
| Scientifica | <ul> <li>InVivoStar (Coming Soon!)</li> </ul> |
12-
| PhenoSys | <ul> <li>(Coming Soon!)</li> </ul> |
7+
| Manufacturer | Model |
8+
|--------------|----------------------------------------------------------------------------------|
9+
| Sensapex | <ul> <li>uMp-4</li> <li>uMp-3</li> </ul> |
10+
| New Scale | <ul> <li>Pathfinder MPM Control v2.8+</li> <li>Parallax for New Scale</li> </ul> |
11+
| Scientifica | <ul> <li>InVivoStar (Coming Soon!)</li> </ul> |
12+
| PhenoSys | <ul> <li>(Coming Soon!)</li> </ul> |

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ dependencies = [
3535
"requests==2.32.5",
3636
"sensapex==1.400.4",
3737
"rich==14.2.0",
38-
"vbl-aquarium==1.0.1"
38+
"vbl-aquarium==1.1.0"
3939
]
4040

4141
[project.urls]
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
"""Bindings for Parallax for New Scale platform.
2+
3+
Usage: Instantiate ParallaxBinding to interact with the Parallax for New Scale Pathfinder platform.
4+
"""
5+
6+
from asyncio import get_running_loop, sleep
7+
from json import dumps
8+
from typing import Any, final, override
9+
10+
from requests import JSONDecodeError, get, put
11+
from vbl_aquarium.models.unity import Vector3, Vector4
12+
13+
from ephys_link.utils.base_binding import BaseBinding
14+
from ephys_link.utils.converters import scalar_mm_to_um, vector4_to_array
15+
16+
17+
@final
18+
class ParallaxBinding(BaseBinding):
19+
"""Bindings for Parallax for New Scale platform."""
20+
21+
# Server data update rate (30 FPS).
22+
SERVER_DATA_UPDATE_RATE = 1 / 30
23+
24+
# Movement polling preferences.
25+
UNCHANGED_COUNTER_LIMIT = 10
26+
27+
# Speed preferences (mm/s to use coarse mode).
28+
COARSE_SPEED_THRESHOLD = 0.1
29+
INSERTION_SPEED_LIMIT = 9_000
30+
31+
def __init__(self, port: int = 8081) -> None:
32+
"""Initialize connection to MPM HTTP server.
33+
34+
Args:
35+
port: Port number for MPM HTTP server.
36+
"""
37+
self._url = f"http://localhost:{port}"
38+
self._movement_stopped = False
39+
40+
# Data cache.
41+
self.cache: dict[str, Any] = {} # pyright: ignore [reportExplicitAny]
42+
self.cache_time = 0
43+
44+
@staticmethod
45+
@override
46+
def get_display_name() -> str:
47+
return "Parallax for New Scale"
48+
49+
@staticmethod
50+
@override
51+
def get_cli_name() -> str:
52+
return "parallax"
53+
54+
@override
55+
async def get_manipulators(self) -> list[str]:
56+
data = await self._query_data()
57+
return list(data.keys())
58+
59+
@override
60+
async def get_axes_count(self) -> int:
61+
return 3
62+
63+
@override
64+
def get_dimensions(self) -> Vector4:
65+
return Vector4(x=15, y=15, z=15, w=15)
66+
67+
@override
68+
async def get_position(self, manipulator_id: str) -> Vector4:
69+
manipulator_data: dict[str, Any] = await self._manipulator_data(manipulator_id) # pyright: ignore [reportExplicitAny]
70+
global_z = float(manipulator_data.get("global_Z", 0.0) or 0.0)
71+
72+
await sleep(self.SERVER_DATA_UPDATE_RATE) # Wait for the stage to stabilize.
73+
74+
global_x = float(manipulator_data.get("global_X", 0.0) or 0.0)
75+
global_y = float(manipulator_data.get("global_Y", 0.0) or 0.0)
76+
77+
return Vector4(x=global_x, y=global_y, z=global_z, w=global_z)
78+
79+
@override
80+
async def get_angles(self, manipulator_id: str) -> Vector3:
81+
manipulator_data: dict[str, Any] = await self._manipulator_data(manipulator_id) # pyright: ignore [reportExplicitAny]
82+
83+
yaw = int(manipulator_data.get("yaw", 0) or 0)
84+
pitch = int(manipulator_data.get("pitch", 90) or 90)
85+
roll = int(manipulator_data.get("roll", 0) or 0)
86+
87+
return Vector3(x=yaw, y=pitch, z=roll)
88+
89+
@override
90+
async def get_shank_count(self, manipulator_id: str) -> int:
91+
manipulator_data: dict[str, Any] = await self._manipulator_data(manipulator_id) # pyright: ignore [reportExplicitAny]
92+
return int(manipulator_data.get("shank_cnt", 1) or 1)
93+
94+
@staticmethod
95+
@override
96+
def get_movement_tolerance() -> float:
97+
return 0.01
98+
99+
@override
100+
async def set_position(self, manipulator_id: str, position: Vector4, speed: float) -> Vector4:
101+
# Keep track of the previous position to check if the manipulator stopped advancing.
102+
current_position = await self.get_position(manipulator_id)
103+
previous_position = current_position
104+
unchanged_counter = 0
105+
106+
# Set step mode based on speed.
107+
await self._put_request(
108+
{
109+
"move_type": "stepMode",
110+
"stage_sn": manipulator_id,
111+
"step_mode": 0 if speed > self.COARSE_SPEED_THRESHOLD else 1,
112+
}
113+
)
114+
115+
# Send move request.
116+
await self._put_request(
117+
{
118+
"move_type": "moveXYZ",
119+
"world": "global", # Use global coordinates
120+
"stage_sn": manipulator_id,
121+
"Absolute": 1,
122+
"Stereotactic": 0,
123+
"AxisMask": 7,
124+
"x": position.x,
125+
"y": position.y,
126+
"z": position.z,
127+
}
128+
)
129+
# Wait for the manipulator to reach the target position or be stopped or stuck.
130+
while (
131+
not self._movement_stopped
132+
and not self._is_vector_close(current_position, position)
133+
and unchanged_counter < self.UNCHANGED_COUNTER_LIMIT
134+
):
135+
# Wait for a short time before checking again.
136+
await sleep(self.SERVER_DATA_UPDATE_RATE)
137+
138+
# Update current position.
139+
current_position = await self.get_position(manipulator_id)
140+
141+
# Check if manipulator is not moving.
142+
if self._is_vector_close(previous_position, current_position):
143+
# Position did not change.
144+
unchanged_counter += 1
145+
else:
146+
# Position changed.
147+
unchanged_counter = 0
148+
previous_position = current_position
149+
150+
# Reset movement stopped flag.
151+
self._movement_stopped = False
152+
153+
# Return the final position.
154+
return await self.get_position(manipulator_id)
155+
156+
@override
157+
async def set_depth(self, manipulator_id: str, depth: float, speed: float) -> float:
158+
# Keep track of the previous depth to check if the manipulator stopped advancing unexpectedly.
159+
current_depth = (await self.get_position(manipulator_id)).w
160+
previous_depth = current_depth
161+
unchanged_counter = 0
162+
163+
# Send move request.
164+
# Convert mm/s to um/min and cap speed at the limit.
165+
await self._put_request(
166+
{
167+
"move_type": "insertion",
168+
"stage_sn": manipulator_id,
169+
"world": "global", # distance in global space
170+
"distance": scalar_mm_to_um(current_depth - depth),
171+
"rate": min(scalar_mm_to_um(speed) * 60, self.INSERTION_SPEED_LIMIT),
172+
}
173+
)
174+
175+
# Wait for the manipulator to reach the target depth or be stopped or get stuck.
176+
while (
177+
not self._movement_stopped
178+
and not abs(current_depth - depth) <= self.get_movement_tolerance()
179+
and unchanged_counter < self.UNCHANGED_COUNTER_LIMIT
180+
):
181+
# Wait for a short time before checking again.
182+
await sleep(self.SERVER_DATA_UPDATE_RATE)
183+
184+
# Get the current depth.
185+
current_depth = (await self.get_position(manipulator_id)).w
186+
187+
# Check if manipulator is not moving.
188+
if abs(previous_depth - current_depth) <= self.get_movement_tolerance():
189+
# Depth did not change.
190+
unchanged_counter += 1
191+
else:
192+
# Depth changed.
193+
unchanged_counter = 0
194+
previous_depth = current_depth
195+
196+
# Reset movement stopped flag.
197+
self._movement_stopped = False
198+
199+
# Return the final depth.
200+
return float((await self.get_position(manipulator_id)).w)
201+
202+
@override
203+
async def stop(self, manipulator_id: str) -> None:
204+
request: dict[str, str | int | float] = {
205+
"PutId": "stop",
206+
"Probe": manipulator_id,
207+
}
208+
await self._put_request(request)
209+
self._movement_stopped = True
210+
211+
@override
212+
def platform_space_to_unified_space(self, platform_space: Vector4) -> Vector4:
213+
# unified <- platform
214+
# +x <- +x
215+
# +y <- +z
216+
# +z <- +y
217+
# +w <- +w
218+
219+
return Vector4(
220+
x=platform_space.x,
221+
y=platform_space.z,
222+
z=platform_space.y,
223+
w=self.get_dimensions().w - platform_space.w,
224+
)
225+
226+
@override
227+
def unified_space_to_platform_space(self, unified_space: Vector4) -> Vector4:
228+
# platform <- unified
229+
# +x <- +x
230+
# +y <- +z
231+
# +z <- +y
232+
# +w <- -w
233+
234+
return Vector4(
235+
x=unified_space.x,
236+
y=unified_space.z,
237+
z=unified_space.y,
238+
w=self.get_dimensions().w - unified_space.w,
239+
)
240+
241+
# Helper functions.
242+
async def _query_data(self) -> dict[str, Any]: # pyright: ignore [reportExplicitAny]
243+
try:
244+
# Update cache if it's expired.
245+
if get_running_loop().time() - self.cache_time > self.SERVER_DATA_UPDATE_RATE:
246+
# noinspection PyTypeChecker
247+
self.cache = (await get_running_loop().run_in_executor(None, get, self._url)).json()
248+
self.cache_time = get_running_loop().time()
249+
except ConnectionError as connectionError:
250+
error_message = f"Unable to connect to MPM HTTP server: {connectionError}"
251+
raise RuntimeError(error_message) from connectionError
252+
except JSONDecodeError as jsonDecodeError:
253+
error_message = f"Unable to decode JSON response from MPM HTTP server: {jsonDecodeError}"
254+
raise ValueError(error_message) from jsonDecodeError
255+
else:
256+
# Return cached data.
257+
return self.cache
258+
259+
async def _manipulator_data(self, manipulator_id: str) -> dict[str, Any]: # pyright: ignore [reportExplicitAny]
260+
"""Retrieve data for a specific manipulator (probe) using its serial number."""
261+
data = await self._query_data()
262+
263+
if manipulator_id in data:
264+
return data[manipulator_id] # pyright: ignore [reportAny]
265+
266+
# If we get here, that means the manipulator doesn't exist.
267+
error_message = f"Manipulator {manipulator_id} not found."
268+
raise ValueError(error_message)
269+
270+
async def _put_request(self, request: dict[str, Any]) -> None: # pyright: ignore [reportExplicitAny]
271+
_ = await get_running_loop().run_in_executor(None, put, self._url, dumps(request))
272+
273+
def _is_vector_close(self, target: Vector4, current: Vector4) -> bool:
274+
return all(abs(axis) <= self.get_movement_tolerance() for axis in vector4_to_array(target - current)[:3])

src/ephys_link/front_end/cli.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def __init__(self) -> None:
4747
type=str,
4848
dest="type",
4949
default="ump",
50-
help='Manipulator type (i.e. "ump", "pathfinder-mpm", "fake"). Default: "ump".',
50+
help='Manipulator type ("ump", "pathfinder-mpm", "parallax", "fake"). Default: "ump".',
5151
)
5252
_ = self._parser.add_argument(
5353
"-d",
@@ -76,7 +76,14 @@ def __init__(self) -> None:
7676
type=int,
7777
default=8080,
7878
dest="mpm_port",
79-
help="Port New Scale Pathfinder MPM's server is on. Default: 8080.",
79+
help="HTTP port New Scale Pathfinder MPM's server is on. Default: 8080.",
80+
)
81+
_ = self._parser.add_argument(
82+
"--parallax-port",
83+
type=int,
84+
default=8081,
85+
dest="parallax_port",
86+
help="HTTP port Parallax's server is on. Default: 8081.",
8087
)
8188
_ = self._parser.add_argument(
8289
"-s",

src/ephys_link/utils/startup.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from ephys_link.__about__ import __version__
1212
from ephys_link.bindings.mpm_binding import MPMBinding
13+
from ephys_link.bindings.parallax_binding import ParallaxBinding
1314
from ephys_link.front_end.console import Console
1415
from ephys_link.utils.base_binding import BaseBinding
1516
from ephys_link.utils.constants import (
@@ -89,12 +90,15 @@ def get_binding_instance(options: EphysLinkOptions, console: Console) -> BaseBin
8990
selected_type = "ump"
9091

9192
if binding_cli_name == selected_type:
92-
# Pass in HTTP port for Pathfinder MPM.
93-
if binding_cli_name == "pathfinder-mpm":
94-
return MPMBinding(options.mpm_port)
95-
96-
# Otherwise just return the binding.
97-
return binding_type()
93+
# Pass in HTTP port for Pathfinder MPM and Parallax.
94+
match binding_cli_name:
95+
case "pathfinder-mpm":
96+
return MPMBinding(options.mpm_port)
97+
case "parallax":
98+
return ParallaxBinding(options.parallax_port)
99+
case _:
100+
# Otherwise just return the binding.
101+
return binding_type()
98102

99103
# Raise an error if the platform type is not recognized.
100104
error_message = unrecognized_platform_type_error(selected_type)

0 commit comments

Comments
 (0)