diff --git a/docs/home/supported_manipulators.md b/docs/home/supported_manipulators.md index ead9ca7..6632777 100644 --- a/docs/home/supported_manipulators.md +++ b/docs/home/supported_manipulators.md @@ -4,9 +4,9 @@ This is a current list of planned and supported manipulators in Ephys Link. If y here, we suggest reaching out to your manipulator's manufacturer to request support for Ephys Link. Direct them to contact [Kenneth Yang and Daniel Birman](https://virtualbrainlab.org/about/overview.html)! -| Manufacturer | Model | -|--------------|--------------------------------------------------------| -| Sensapex | | -| New Scale | | -| Scientifica | | -| PhenoSys | | +| Manufacturer | Model | +|--------------|----------------------------------------------------------------------------------| +| Sensapex | | +| New Scale | | +| Scientifica | | +| PhenoSys | | diff --git a/pyproject.toml b/pyproject.toml index e005fa7..45bfbf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ "requests==2.32.5", "sensapex==1.400.4", "rich==14.2.0", - "vbl-aquarium==1.0.1" + "vbl-aquarium==1.1.0" ] [project.urls] diff --git a/src/ephys_link/bindings/parallax_binding.py b/src/ephys_link/bindings/parallax_binding.py new file mode 100644 index 0000000..a296eca --- /dev/null +++ b/src/ephys_link/bindings/parallax_binding.py @@ -0,0 +1,274 @@ +"""Bindings for Parallax for New Scale platform. + +Usage: Instantiate ParallaxBinding to interact with the Parallax for New Scale Pathfinder platform. +""" + +from asyncio import get_running_loop, sleep +from json import dumps +from typing import Any, final, override + +from requests import JSONDecodeError, get, put +from vbl_aquarium.models.unity import Vector3, Vector4 + +from ephys_link.utils.base_binding import BaseBinding +from ephys_link.utils.converters import scalar_mm_to_um, vector4_to_array + + +@final +class ParallaxBinding(BaseBinding): + """Bindings for Parallax for New Scale platform.""" + + # Server data update rate (30 FPS). + SERVER_DATA_UPDATE_RATE = 1 / 30 + + # Movement polling preferences. + UNCHANGED_COUNTER_LIMIT = 10 + + # Speed preferences (mm/s to use coarse mode). + COARSE_SPEED_THRESHOLD = 0.1 + INSERTION_SPEED_LIMIT = 9_000 + + def __init__(self, port: int = 8081) -> None: + """Initialize connection to MPM HTTP server. + + Args: + port: Port number for MPM HTTP server. + """ + self._url = f"http://localhost:{port}" + self._movement_stopped = False + + # Data cache. + self.cache: dict[str, Any] = {} # pyright: ignore [reportExplicitAny] + self.cache_time = 0 + + @staticmethod + @override + def get_display_name() -> str: + return "Parallax for New Scale" + + @staticmethod + @override + def get_cli_name() -> str: + return "parallax" + + @override + async def get_manipulators(self) -> list[str]: + data = await self._query_data() + return list(data.keys()) + + @override + async def get_axes_count(self) -> int: + return 3 + + @override + def get_dimensions(self) -> Vector4: + return Vector4(x=15, y=15, z=15, w=15) + + @override + async def get_position(self, manipulator_id: str) -> Vector4: + manipulator_data: dict[str, Any] = await self._manipulator_data(manipulator_id) # pyright: ignore [reportExplicitAny] + global_z = float(manipulator_data.get("global_Z", 0.0) or 0.0) + + await sleep(self.SERVER_DATA_UPDATE_RATE) # Wait for the stage to stabilize. + + global_x = float(manipulator_data.get("global_X", 0.0) or 0.0) + global_y = float(manipulator_data.get("global_Y", 0.0) or 0.0) + + return Vector4(x=global_x, y=global_y, z=global_z, w=global_z) + + @override + async def get_angles(self, manipulator_id: str) -> Vector3: + manipulator_data: dict[str, Any] = await self._manipulator_data(manipulator_id) # pyright: ignore [reportExplicitAny] + + yaw = int(manipulator_data.get("yaw", 0) or 0) + pitch = int(manipulator_data.get("pitch", 90) or 90) + roll = int(manipulator_data.get("roll", 0) or 0) + + return Vector3(x=yaw, y=pitch, z=roll) + + @override + async def get_shank_count(self, manipulator_id: str) -> int: + manipulator_data: dict[str, Any] = await self._manipulator_data(manipulator_id) # pyright: ignore [reportExplicitAny] + return int(manipulator_data.get("shank_cnt", 1) or 1) + + @staticmethod + @override + def get_movement_tolerance() -> float: + return 0.01 + + @override + async def set_position(self, manipulator_id: str, position: Vector4, speed: float) -> Vector4: + # Keep track of the previous position to check if the manipulator stopped advancing. + current_position = await self.get_position(manipulator_id) + previous_position = current_position + unchanged_counter = 0 + + # Set step mode based on speed. + await self._put_request( + { + "move_type": "stepMode", + "stage_sn": manipulator_id, + "step_mode": 0 if speed > self.COARSE_SPEED_THRESHOLD else 1, + } + ) + + # Send move request. + await self._put_request( + { + "move_type": "moveXYZ", + "world": "global", # Use global coordinates + "stage_sn": manipulator_id, + "Absolute": 1, + "Stereotactic": 0, + "AxisMask": 7, + "x": position.x, + "y": position.y, + "z": position.z, + } + ) + # Wait for the manipulator to reach the target position or be stopped or stuck. + while ( + not self._movement_stopped + and not self._is_vector_close(current_position, position) + and unchanged_counter < self.UNCHANGED_COUNTER_LIMIT + ): + # Wait for a short time before checking again. + await sleep(self.SERVER_DATA_UPDATE_RATE) + + # Update current position. + current_position = await self.get_position(manipulator_id) + + # Check if manipulator is not moving. + if self._is_vector_close(previous_position, current_position): + # Position did not change. + unchanged_counter += 1 + else: + # Position changed. + unchanged_counter = 0 + previous_position = current_position + + # Reset movement stopped flag. + self._movement_stopped = False + + # Return the final position. + return await self.get_position(manipulator_id) + + @override + async def set_depth(self, manipulator_id: str, depth: float, speed: float) -> float: + # Keep track of the previous depth to check if the manipulator stopped advancing unexpectedly. + current_depth = (await self.get_position(manipulator_id)).w + previous_depth = current_depth + unchanged_counter = 0 + + # Send move request. + # Convert mm/s to um/min and cap speed at the limit. + await self._put_request( + { + "move_type": "insertion", + "stage_sn": manipulator_id, + "world": "global", # distance in global space + "distance": scalar_mm_to_um(current_depth - depth), + "rate": min(scalar_mm_to_um(speed) * 60, self.INSERTION_SPEED_LIMIT), + } + ) + + # Wait for the manipulator to reach the target depth or be stopped or get stuck. + while ( + not self._movement_stopped + and not abs(current_depth - depth) <= self.get_movement_tolerance() + and unchanged_counter < self.UNCHANGED_COUNTER_LIMIT + ): + # Wait for a short time before checking again. + await sleep(self.SERVER_DATA_UPDATE_RATE) + + # Get the current depth. + current_depth = (await self.get_position(manipulator_id)).w + + # Check if manipulator is not moving. + if abs(previous_depth - current_depth) <= self.get_movement_tolerance(): + # Depth did not change. + unchanged_counter += 1 + else: + # Depth changed. + unchanged_counter = 0 + previous_depth = current_depth + + # Reset movement stopped flag. + self._movement_stopped = False + + # Return the final depth. + return float((await self.get_position(manipulator_id)).w) + + @override + async def stop(self, manipulator_id: str) -> None: + request: dict[str, str | int | float] = { + "PutId": "stop", + "Probe": manipulator_id, + } + await self._put_request(request) + self._movement_stopped = True + + @override + def platform_space_to_unified_space(self, platform_space: Vector4) -> Vector4: + # unified <- platform + # +x <- +x + # +y <- +z + # +z <- +y + # +w <- +w + + return Vector4( + x=platform_space.x, + y=platform_space.z, + z=platform_space.y, + w=self.get_dimensions().w - platform_space.w, + ) + + @override + def unified_space_to_platform_space(self, unified_space: Vector4) -> Vector4: + # platform <- unified + # +x <- +x + # +y <- +z + # +z <- +y + # +w <- -w + + return Vector4( + x=unified_space.x, + y=unified_space.z, + z=unified_space.y, + w=self.get_dimensions().w - unified_space.w, + ) + + # Helper functions. + async def _query_data(self) -> dict[str, Any]: # pyright: ignore [reportExplicitAny] + try: + # Update cache if it's expired. + if get_running_loop().time() - self.cache_time > self.SERVER_DATA_UPDATE_RATE: + # noinspection PyTypeChecker + self.cache = (await get_running_loop().run_in_executor(None, get, self._url)).json() + self.cache_time = get_running_loop().time() + except ConnectionError as connectionError: + error_message = f"Unable to connect to MPM HTTP server: {connectionError}" + raise RuntimeError(error_message) from connectionError + except JSONDecodeError as jsonDecodeError: + error_message = f"Unable to decode JSON response from MPM HTTP server: {jsonDecodeError}" + raise ValueError(error_message) from jsonDecodeError + else: + # Return cached data. + return self.cache + + async def _manipulator_data(self, manipulator_id: str) -> dict[str, Any]: # pyright: ignore [reportExplicitAny] + """Retrieve data for a specific manipulator (probe) using its serial number.""" + data = await self._query_data() + + if manipulator_id in data: + return data[manipulator_id] # pyright: ignore [reportAny] + + # If we get here, that means the manipulator doesn't exist. + error_message = f"Manipulator {manipulator_id} not found." + raise ValueError(error_message) + + async def _put_request(self, request: dict[str, Any]) -> None: # pyright: ignore [reportExplicitAny] + _ = await get_running_loop().run_in_executor(None, put, self._url, dumps(request)) + + def _is_vector_close(self, target: Vector4, current: Vector4) -> bool: + return all(abs(axis) <= self.get_movement_tolerance() for axis in vector4_to_array(target - current)[:3]) diff --git a/src/ephys_link/front_end/cli.py b/src/ephys_link/front_end/cli.py index 7e90979..7fb46d0 100644 --- a/src/ephys_link/front_end/cli.py +++ b/src/ephys_link/front_end/cli.py @@ -47,7 +47,7 @@ def __init__(self) -> None: type=str, dest="type", default="ump", - help='Manipulator type (i.e. "ump", "pathfinder-mpm", "fake"). Default: "ump".', + help='Manipulator type ("ump", "pathfinder-mpm", "parallax", "fake"). Default: "ump".', ) _ = self._parser.add_argument( "-d", @@ -76,7 +76,14 @@ def __init__(self) -> None: type=int, default=8080, dest="mpm_port", - help="Port New Scale Pathfinder MPM's server is on. Default: 8080.", + help="HTTP port New Scale Pathfinder MPM's server is on. Default: 8080.", + ) + _ = self._parser.add_argument( + "--parallax-port", + type=int, + default=8081, + dest="parallax_port", + help="HTTP port Parallax's server is on. Default: 8081.", ) _ = self._parser.add_argument( "-s", diff --git a/src/ephys_link/utils/startup.py b/src/ephys_link/utils/startup.py index 51353d1..b08aff0 100644 --- a/src/ephys_link/utils/startup.py +++ b/src/ephys_link/utils/startup.py @@ -10,6 +10,7 @@ from ephys_link.__about__ import __version__ from ephys_link.bindings.mpm_binding import MPMBinding +from ephys_link.bindings.parallax_binding import ParallaxBinding from ephys_link.front_end.console import Console from ephys_link.utils.base_binding import BaseBinding from ephys_link.utils.constants import ( @@ -89,12 +90,15 @@ def get_binding_instance(options: EphysLinkOptions, console: Console) -> BaseBin selected_type = "ump" if binding_cli_name == selected_type: - # Pass in HTTP port for Pathfinder MPM. - if binding_cli_name == "pathfinder-mpm": - return MPMBinding(options.mpm_port) - - # Otherwise just return the binding. - return binding_type() + # Pass in HTTP port for Pathfinder MPM and Parallax. + match binding_cli_name: + case "pathfinder-mpm": + return MPMBinding(options.mpm_port) + case "parallax": + return ParallaxBinding(options.parallax_port) + case _: + # Otherwise just return the binding. + return binding_type() # Raise an error if the platform type is not recognized. error_message = unrecognized_platform_type_error(selected_type)