ephys-link 1.3.3__py3-none-any.whl → 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ephys_link/__about__.py +1 -1
- ephys_link/__main__.py +51 -105
- ephys_link/back_end/__init__.py +0 -0
- ephys_link/back_end/platform_handler.py +315 -0
- ephys_link/back_end/server.py +274 -0
- ephys_link/bindings/__init__.py +0 -0
- ephys_link/bindings/fake_binding.py +84 -0
- ephys_link/bindings/mpm_binding.py +315 -0
- ephys_link/bindings/ump_4_binding.py +157 -0
- ephys_link/front_end/__init__.py +0 -0
- ephys_link/front_end/cli.py +104 -0
- ephys_link/front_end/gui.py +204 -0
- ephys_link/utils/__init__.py +0 -0
- ephys_link/utils/base_binding.py +176 -0
- ephys_link/utils/console.py +127 -0
- ephys_link/utils/constants.py +23 -0
- ephys_link/utils/converters.py +86 -0
- ephys_link/utils/startup.py +65 -0
- ephys_link-2.0.0.dist-info/METADATA +91 -0
- ephys_link-2.0.0.dist-info/RECORD +25 -0
- {ephys_link-1.3.3.dist-info → ephys_link-2.0.0.dist-info}/WHEEL +1 -1
- {ephys_link-1.3.3.dist-info → ephys_link-2.0.0.dist-info}/licenses/LICENSE +674 -674
- ephys_link/common.py +0 -49
- ephys_link/emergency_stop.py +0 -67
- ephys_link/gui.py +0 -217
- ephys_link/platform_handler.py +0 -465
- ephys_link/platform_manipulator.py +0 -35
- ephys_link/platforms/__init__.py +0 -5
- ephys_link/platforms/new_scale_handler.py +0 -141
- ephys_link/platforms/new_scale_manipulator.py +0 -312
- ephys_link/platforms/new_scale_pathfinder_handler.py +0 -235
- ephys_link/platforms/sensapex_handler.py +0 -151
- ephys_link/platforms/sensapex_manipulator.py +0 -227
- ephys_link/platforms/ump3_handler.py +0 -57
- ephys_link/platforms/ump3_manipulator.py +0 -147
- ephys_link/resources/CP210xManufacturing.dll +0 -0
- ephys_link/resources/NstMotorCtrl.dll +0 -0
- ephys_link/resources/SiUSBXp.dll +0 -0
- ephys_link/server.py +0 -508
- ephys_link-1.3.3.dist-info/METADATA +0 -164
- ephys_link-1.3.3.dist-info/RECORD +0 -26
- {ephys_link-1.3.3.dist-info → ephys_link-2.0.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Socket.IO Server.
|
|
2
|
+
|
|
3
|
+
Responsible to managing the Socket.IO connection and events.
|
|
4
|
+
Directs events to the platform handler or handles them directly.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
Instantiate Server with the appropriate options, platform handler, and console.
|
|
8
|
+
Then call `launch()` to start the server.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
Server(options, platform_handler, console).launch()
|
|
12
|
+
```
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from asyncio import get_event_loop, run
|
|
16
|
+
from collections.abc import Callable, Coroutine
|
|
17
|
+
from json import JSONDecodeError, dumps, loads
|
|
18
|
+
from typing import Any, TypeVar, final
|
|
19
|
+
from uuid import uuid4
|
|
20
|
+
|
|
21
|
+
from aiohttp.web import Application, run_app
|
|
22
|
+
from pydantic import ValidationError
|
|
23
|
+
from socketio import AsyncClient, AsyncServer # pyright: ignore [reportMissingTypeStubs]
|
|
24
|
+
from vbl_aquarium.models.ephys_link import (
|
|
25
|
+
EphysLinkOptions,
|
|
26
|
+
SetDepthRequest,
|
|
27
|
+
SetInsideBrainRequest,
|
|
28
|
+
SetPositionRequest,
|
|
29
|
+
)
|
|
30
|
+
from vbl_aquarium.models.proxy import PinpointIdResponse
|
|
31
|
+
from vbl_aquarium.utils.vbl_base_model import VBLBaseModel
|
|
32
|
+
|
|
33
|
+
from ephys_link.__about__ import __version__
|
|
34
|
+
from ephys_link.back_end.platform_handler import PlatformHandler
|
|
35
|
+
from ephys_link.utils.console import Console
|
|
36
|
+
from ephys_link.utils.constants import PORT
|
|
37
|
+
|
|
38
|
+
# Server message generic types.
|
|
39
|
+
INPUT_TYPE = TypeVar("INPUT_TYPE", bound=VBLBaseModel)
|
|
40
|
+
OUTPUT_TYPE = TypeVar("OUTPUT_TYPE", bound=VBLBaseModel)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@final
|
|
44
|
+
class Server:
|
|
45
|
+
def __init__(self, options: EphysLinkOptions, platform_handler: PlatformHandler, console: Console) -> None:
|
|
46
|
+
"""Initialize server fields based on options and platform handler.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
options: Launch options object.
|
|
50
|
+
platform_handler: Platform handler instance.
|
|
51
|
+
console: Console instance.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
# Save fields.
|
|
55
|
+
self._options = options
|
|
56
|
+
self._platform_handler = platform_handler
|
|
57
|
+
self._console = console
|
|
58
|
+
|
|
59
|
+
# Initialize based on proxy usage.
|
|
60
|
+
self._sio: AsyncServer | AsyncClient = AsyncClient() if self._options.use_proxy else AsyncServer()
|
|
61
|
+
if not self._options.use_proxy:
|
|
62
|
+
# Exit if _sio is not a Server.
|
|
63
|
+
if not isinstance(self._sio, AsyncServer):
|
|
64
|
+
error = "Server not initialized."
|
|
65
|
+
self._console.critical_print(error)
|
|
66
|
+
raise TypeError(error)
|
|
67
|
+
|
|
68
|
+
self._app = Application()
|
|
69
|
+
self._sio.attach(self._app) # pyright: ignore [reportUnknownMemberType]
|
|
70
|
+
|
|
71
|
+
# Bind connection events.
|
|
72
|
+
_ = self._sio.on("connect", self.connect) # pyright: ignore [reportUnknownMemberType, reportUnknownVariableType]
|
|
73
|
+
_ = self._sio.on("disconnect", self.disconnect) # pyright: ignore [reportUnknownMemberType, reportUnknownVariableType]
|
|
74
|
+
|
|
75
|
+
# Store connected client.
|
|
76
|
+
self._client_sid: str = ""
|
|
77
|
+
|
|
78
|
+
# Generate Pinpoint ID for proxy usage.
|
|
79
|
+
self._pinpoint_id = str(uuid4())[:8]
|
|
80
|
+
|
|
81
|
+
# Bind events.
|
|
82
|
+
_ = self._sio.on("*", self.platform_event_handler) # pyright: ignore [reportUnknownMemberType, reportUnknownVariableType]
|
|
83
|
+
|
|
84
|
+
def launch(self) -> None:
|
|
85
|
+
"""Launch the server.
|
|
86
|
+
|
|
87
|
+
Based on the options, either connect to a proxy or launch the server locally.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
# List platform and available manipulators.
|
|
91
|
+
self._console.info_print("PLATFORM", self._platform_handler.get_display_name())
|
|
92
|
+
self._console.info_print(
|
|
93
|
+
"MANIPULATORS",
|
|
94
|
+
str(get_event_loop().run_until_complete(self._platform_handler.get_manipulators()).manipulators),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Launch server
|
|
98
|
+
if self._options.use_proxy:
|
|
99
|
+
self._console.info_print("PINPOINT ID", self._pinpoint_id)
|
|
100
|
+
|
|
101
|
+
async def connect_proxy() -> None:
|
|
102
|
+
# Exit if _sio is not a proxy client.
|
|
103
|
+
if not isinstance(self._sio, AsyncClient):
|
|
104
|
+
error = "Proxy client not initialized."
|
|
105
|
+
self._console.critical_print(error)
|
|
106
|
+
raise TypeError(error)
|
|
107
|
+
|
|
108
|
+
# noinspection HttpUrlsUsage
|
|
109
|
+
await self._sio.connect(f"http://{self._options.proxy_address}:{PORT}") # pyright: ignore [reportUnknownMemberType]
|
|
110
|
+
await self._sio.wait()
|
|
111
|
+
|
|
112
|
+
run(connect_proxy())
|
|
113
|
+
else:
|
|
114
|
+
run_app(self._app, port=PORT)
|
|
115
|
+
|
|
116
|
+
# Helper functions.
|
|
117
|
+
def _malformed_request_response(self, request: str, data: tuple[tuple[Any], ...]) -> str: # pyright: ignore [reportExplicitAny]
|
|
118
|
+
"""Return a response for a malformed request.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
request: Original request.
|
|
122
|
+
data: Request data.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Response for a malformed request.
|
|
126
|
+
"""
|
|
127
|
+
self._console.error_print("MALFORMED REQUEST", f"{request}: {data}")
|
|
128
|
+
return dumps({"error": "Malformed request."})
|
|
129
|
+
|
|
130
|
+
async def _run_if_data_available(
|
|
131
|
+
self,
|
|
132
|
+
function: Callable[[str], Coroutine[Any, Any, VBLBaseModel]], # pyright: ignore [reportExplicitAny]
|
|
133
|
+
event: str,
|
|
134
|
+
data: tuple[tuple[Any], ...], # pyright: ignore [reportExplicitAny]
|
|
135
|
+
) -> str:
|
|
136
|
+
"""Run a function if data is available.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
function: Function to run.
|
|
140
|
+
event: Event name.
|
|
141
|
+
data: Event data.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Response data from function.
|
|
145
|
+
"""
|
|
146
|
+
request_data = data[1]
|
|
147
|
+
if request_data:
|
|
148
|
+
return str((await function(str(request_data))).to_json_string())
|
|
149
|
+
return self._malformed_request_response(event, request_data)
|
|
150
|
+
|
|
151
|
+
async def _run_if_data_parses(
|
|
152
|
+
self,
|
|
153
|
+
function: Callable[[INPUT_TYPE], Coroutine[Any, Any, OUTPUT_TYPE]], # pyright: ignore [reportExplicitAny]
|
|
154
|
+
data_type: type[INPUT_TYPE],
|
|
155
|
+
event: str,
|
|
156
|
+
data: tuple[tuple[Any], ...], # pyright: ignore [reportExplicitAny]
|
|
157
|
+
) -> str:
|
|
158
|
+
"""Run a function if data parses.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
function: Function to run.
|
|
162
|
+
data_type: Data type to parse.
|
|
163
|
+
event: Event name.
|
|
164
|
+
data: Event data.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Response data from function.
|
|
168
|
+
"""
|
|
169
|
+
request_data = data[1]
|
|
170
|
+
if request_data:
|
|
171
|
+
try:
|
|
172
|
+
parsed_data = data_type(**loads(str(request_data)))
|
|
173
|
+
except JSONDecodeError:
|
|
174
|
+
return self._malformed_request_response(event, request_data)
|
|
175
|
+
except ValidationError as e:
|
|
176
|
+
self._console.exception_error_print(event, e)
|
|
177
|
+
return self._malformed_request_response(event, request_data)
|
|
178
|
+
else:
|
|
179
|
+
return str((await function(parsed_data)).to_json_string())
|
|
180
|
+
return self._malformed_request_response(event, request_data)
|
|
181
|
+
|
|
182
|
+
# Event Handlers.
|
|
183
|
+
|
|
184
|
+
async def connect(self, sid: str, _: str) -> bool:
|
|
185
|
+
"""Handle connections to the server.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
sid: Socket session ID.
|
|
189
|
+
_: Extra connection data (unused).
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
False on error to refuse connection, True otherwise.
|
|
193
|
+
"""
|
|
194
|
+
self._console.info_print("CONNECTION REQUEST", sid)
|
|
195
|
+
|
|
196
|
+
if self._client_sid == "":
|
|
197
|
+
self._client_sid = sid
|
|
198
|
+
self._console.info_print("CONNECTION GRANTED", sid)
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
self._console.error_print(
|
|
202
|
+
"CONNECTION REFUSED", f"Cannot connect {sid} as {self._client_sid} is already connected."
|
|
203
|
+
)
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
async def disconnect(self, sid: str) -> None:
|
|
207
|
+
"""Handle disconnections from the server.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
sid: Socket session ID.
|
|
211
|
+
"""
|
|
212
|
+
self._console.info_print("DISCONNECTED", sid)
|
|
213
|
+
|
|
214
|
+
# Reset client SID if it matches.
|
|
215
|
+
if self._client_sid == sid:
|
|
216
|
+
self._client_sid = ""
|
|
217
|
+
else:
|
|
218
|
+
self._console.error_print("DISCONNECTION", f"Client {sid} disconnected without being connected.")
|
|
219
|
+
|
|
220
|
+
async def platform_event_handler(self, event: str, *args: tuple[Any]) -> str: # pyright: ignore [reportExplicitAny]
|
|
221
|
+
"""Handle events from the server.
|
|
222
|
+
|
|
223
|
+
Matches incoming events based on the Socket.IO API.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
event: Event name.
|
|
227
|
+
args: Event arguments.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Response data.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
# Log event.
|
|
234
|
+
self._console.debug_print("EVENT", event)
|
|
235
|
+
|
|
236
|
+
# Handle event.
|
|
237
|
+
match event:
|
|
238
|
+
# Server metadata.
|
|
239
|
+
case "get_version":
|
|
240
|
+
return __version__
|
|
241
|
+
case "get_pinpoint_id":
|
|
242
|
+
return PinpointIdResponse(pinpoint_id=self._pinpoint_id, is_requester=False).to_json_string()
|
|
243
|
+
case "get_platform_info":
|
|
244
|
+
return (await self._platform_handler.get_platform_info()).to_json_string()
|
|
245
|
+
|
|
246
|
+
# Manipulator commands.
|
|
247
|
+
case "get_manipulators":
|
|
248
|
+
return str((await self._platform_handler.get_manipulators()).to_json_string())
|
|
249
|
+
case "get_position":
|
|
250
|
+
return await self._run_if_data_available(self._platform_handler.get_position, event, args)
|
|
251
|
+
case "get_angles":
|
|
252
|
+
return await self._run_if_data_available(self._platform_handler.get_angles, event, args)
|
|
253
|
+
case "get_shank_count":
|
|
254
|
+
return await self._run_if_data_available(self._platform_handler.get_shank_count, event, args)
|
|
255
|
+
case "set_position":
|
|
256
|
+
return await self._run_if_data_parses(
|
|
257
|
+
self._platform_handler.set_position, SetPositionRequest, event, args
|
|
258
|
+
)
|
|
259
|
+
case "set_depth":
|
|
260
|
+
return await self._run_if_data_parses(self._platform_handler.set_depth, SetDepthRequest, event, args)
|
|
261
|
+
case "set_inside_brain":
|
|
262
|
+
return await self._run_if_data_parses(
|
|
263
|
+
self._platform_handler.set_inside_brain, SetInsideBrainRequest, event, args
|
|
264
|
+
)
|
|
265
|
+
case "stop":
|
|
266
|
+
request_data = args[1]
|
|
267
|
+
if request_data:
|
|
268
|
+
return await self._platform_handler.stop(str(request_data))
|
|
269
|
+
return self._malformed_request_response(event, request_data)
|
|
270
|
+
case "stop_all":
|
|
271
|
+
return await self._platform_handler.stop_all()
|
|
272
|
+
case _:
|
|
273
|
+
self._console.error_print("EVENT", f"Unknown event: {event}.")
|
|
274
|
+
return dumps({"error": "Unknown event."})
|
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from typing import final, override
|
|
2
|
+
|
|
3
|
+
from vbl_aquarium.models.unity import Vector3, Vector4
|
|
4
|
+
|
|
5
|
+
from ephys_link.utils.base_binding import BaseBinding
|
|
6
|
+
from ephys_link.utils.converters import list_to_vector4
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@final
|
|
10
|
+
class FakeBinding(BaseBinding):
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
"""Initialize fake manipulator infos."""
|
|
13
|
+
|
|
14
|
+
self._positions = [Vector4() for _ in range(8)]
|
|
15
|
+
self._angles = [
|
|
16
|
+
Vector3(x=90, y=60, z=0),
|
|
17
|
+
Vector3(x=-90, y=60, z=0),
|
|
18
|
+
Vector3(x=180, y=60, z=0),
|
|
19
|
+
Vector3(x=0, y=60, z=0),
|
|
20
|
+
Vector3(x=45, y=30, z=0),
|
|
21
|
+
Vector3(x=-45, y=30, z=0),
|
|
22
|
+
Vector3(x=135, y=30, z=0),
|
|
23
|
+
Vector3(x=-135, y=30, z=0),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
@override
|
|
28
|
+
def get_display_name() -> str:
|
|
29
|
+
return "Fake Manipulator"
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
@override
|
|
33
|
+
def get_cli_name() -> str:
|
|
34
|
+
return "fake"
|
|
35
|
+
|
|
36
|
+
@override
|
|
37
|
+
async def get_manipulators(self) -> list[str]:
|
|
38
|
+
return list(map(str, range(8)))
|
|
39
|
+
|
|
40
|
+
@override
|
|
41
|
+
async def get_axes_count(self) -> int:
|
|
42
|
+
return 4
|
|
43
|
+
|
|
44
|
+
@override
|
|
45
|
+
def get_dimensions(self) -> Vector4:
|
|
46
|
+
return list_to_vector4([20] * 4)
|
|
47
|
+
|
|
48
|
+
@override
|
|
49
|
+
async def get_position(self, manipulator_id: str) -> Vector4:
|
|
50
|
+
return self._positions[int(manipulator_id)]
|
|
51
|
+
|
|
52
|
+
@override
|
|
53
|
+
async def get_angles(self, manipulator_id: str) -> Vector3:
|
|
54
|
+
return self._angles[int(manipulator_id)]
|
|
55
|
+
|
|
56
|
+
@override
|
|
57
|
+
async def get_shank_count(self, manipulator_id: str) -> int:
|
|
58
|
+
return 1
|
|
59
|
+
|
|
60
|
+
@override
|
|
61
|
+
def get_movement_tolerance(self) -> float:
|
|
62
|
+
return 0.001
|
|
63
|
+
|
|
64
|
+
@override
|
|
65
|
+
async def set_position(self, manipulator_id: str, position: Vector4, speed: float) -> Vector4:
|
|
66
|
+
self._positions[int(manipulator_id)] = position
|
|
67
|
+
return position
|
|
68
|
+
|
|
69
|
+
@override
|
|
70
|
+
async def set_depth(self, manipulator_id: str, depth: float, speed: float) -> float:
|
|
71
|
+
self._positions[int(manipulator_id)].w = depth
|
|
72
|
+
return depth
|
|
73
|
+
|
|
74
|
+
@override
|
|
75
|
+
async def stop(self, manipulator_id: str) -> None:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
@override
|
|
79
|
+
def platform_space_to_unified_space(self, platform_space: Vector4) -> Vector4:
|
|
80
|
+
return platform_space
|
|
81
|
+
|
|
82
|
+
@override
|
|
83
|
+
def unified_space_to_platform_space(self, unified_space: Vector4) -> Vector4:
|
|
84
|
+
return unified_space
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Bindings for New Scale Pathfinder MPM HTTP server platform.
|
|
2
|
+
|
|
3
|
+
Usage: Instantiate MPMBindings to interact with the New Scale Pathfinder MPM HTTP server 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 MPMBinding(BaseBinding):
|
|
19
|
+
"""Bindings for New Scale Pathfinder MPM HTTP server platform."""
|
|
20
|
+
|
|
21
|
+
# Valid New Scale manipulator IDs
|
|
22
|
+
VALID_MANIPULATOR_IDS = (
|
|
23
|
+
"A",
|
|
24
|
+
"B",
|
|
25
|
+
"C",
|
|
26
|
+
"D",
|
|
27
|
+
"E",
|
|
28
|
+
"F",
|
|
29
|
+
"G",
|
|
30
|
+
"H",
|
|
31
|
+
"I",
|
|
32
|
+
"J",
|
|
33
|
+
"K",
|
|
34
|
+
"L",
|
|
35
|
+
"M",
|
|
36
|
+
"N",
|
|
37
|
+
"O",
|
|
38
|
+
"P",
|
|
39
|
+
"Q",
|
|
40
|
+
"R",
|
|
41
|
+
"S",
|
|
42
|
+
"T",
|
|
43
|
+
"U",
|
|
44
|
+
"V",
|
|
45
|
+
"W",
|
|
46
|
+
"X",
|
|
47
|
+
"Y",
|
|
48
|
+
"Z",
|
|
49
|
+
"AA",
|
|
50
|
+
"AB",
|
|
51
|
+
"AC",
|
|
52
|
+
"AD",
|
|
53
|
+
"AE",
|
|
54
|
+
"AF",
|
|
55
|
+
"AG",
|
|
56
|
+
"AH",
|
|
57
|
+
"AI",
|
|
58
|
+
"AJ",
|
|
59
|
+
"AK",
|
|
60
|
+
"AL",
|
|
61
|
+
"AM",
|
|
62
|
+
"AN",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Server cache lifetime (60 FPS).
|
|
66
|
+
CACHE_LIFETIME = 1 / 60
|
|
67
|
+
|
|
68
|
+
# Movement polling preferences.
|
|
69
|
+
UNCHANGED_COUNTER_LIMIT = 10
|
|
70
|
+
POLL_INTERVAL = 0.1
|
|
71
|
+
|
|
72
|
+
# Speed preferences (mm/s to use coarse mode).
|
|
73
|
+
COARSE_SPEED_THRESHOLD = 0.1
|
|
74
|
+
INSERTION_SPEED_LIMIT = 9_000
|
|
75
|
+
|
|
76
|
+
def __init__(self, port: int = 8080) -> None:
|
|
77
|
+
"""Initialize connection to MPM HTTP server.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
port: Port number for MPM HTTP server.
|
|
81
|
+
"""
|
|
82
|
+
self._url = f"http://localhost:{port}"
|
|
83
|
+
self._movement_stopped = False
|
|
84
|
+
|
|
85
|
+
# Data cache.
|
|
86
|
+
self.cache: dict[str, Any] = {} # pyright: ignore [reportExplicitAny]
|
|
87
|
+
self.cache_time = 0
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
@override
|
|
91
|
+
def get_display_name() -> str:
|
|
92
|
+
return "Pathfinder MPM Control v2.8.8+"
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
@override
|
|
96
|
+
def get_cli_name() -> str:
|
|
97
|
+
return "pathfinder-mpm"
|
|
98
|
+
|
|
99
|
+
@override
|
|
100
|
+
async def get_manipulators(self) -> list[str]:
|
|
101
|
+
return [manipulator["Id"] for manipulator in (await self._query_data())["ProbeArray"]] # pyright: ignore [reportAny]
|
|
102
|
+
|
|
103
|
+
@override
|
|
104
|
+
async def get_axes_count(self) -> int:
|
|
105
|
+
return 3
|
|
106
|
+
|
|
107
|
+
@override
|
|
108
|
+
def get_dimensions(self) -> Vector4:
|
|
109
|
+
return Vector4(x=15, y=15, z=15, w=15)
|
|
110
|
+
|
|
111
|
+
@override
|
|
112
|
+
async def get_position(self, manipulator_id: str) -> Vector4:
|
|
113
|
+
manipulator_data: dict[str, float] = await self._manipulator_data(manipulator_id)
|
|
114
|
+
stage_z: float = manipulator_data["Stage_Z"]
|
|
115
|
+
|
|
116
|
+
await sleep(self.POLL_INTERVAL) # Wait for the stage to stabilize.
|
|
117
|
+
|
|
118
|
+
return Vector4(
|
|
119
|
+
x=manipulator_data["Stage_X"],
|
|
120
|
+
y=manipulator_data["Stage_Y"],
|
|
121
|
+
z=stage_z,
|
|
122
|
+
w=stage_z,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@override
|
|
126
|
+
async def get_angles(self, manipulator_id: str) -> Vector3:
|
|
127
|
+
manipulator_data: dict[str, float] = await self._manipulator_data(manipulator_id)
|
|
128
|
+
|
|
129
|
+
# Apply PosteriorAngle to Polar to get the correct angle.
|
|
130
|
+
adjusted_polar: int = manipulator_data["Polar"] - (await self._query_data())["PosteriorAngle"]
|
|
131
|
+
|
|
132
|
+
return Vector3(
|
|
133
|
+
x=adjusted_polar if adjusted_polar > 0 else 360 + adjusted_polar,
|
|
134
|
+
y=manipulator_data["Pitch"],
|
|
135
|
+
z=manipulator_data["ShankOrientation"],
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@override
|
|
139
|
+
async def get_shank_count(self, manipulator_id: str) -> int:
|
|
140
|
+
return int((await self._manipulator_data(manipulator_id))["ShankCount"]) # pyright: ignore [reportAny]
|
|
141
|
+
|
|
142
|
+
@override
|
|
143
|
+
def get_movement_tolerance(self) -> float:
|
|
144
|
+
return 0.01
|
|
145
|
+
|
|
146
|
+
@override
|
|
147
|
+
async def set_position(self, manipulator_id: str, position: Vector4, speed: float) -> Vector4:
|
|
148
|
+
# Keep track of the previous position to check if the manipulator stopped advancing.
|
|
149
|
+
current_position = await self.get_position(manipulator_id)
|
|
150
|
+
previous_position = current_position
|
|
151
|
+
unchanged_counter = 0
|
|
152
|
+
|
|
153
|
+
# Set step mode based on speed.
|
|
154
|
+
await self._put_request(
|
|
155
|
+
{
|
|
156
|
+
"PutId": "ProbeStepMode",
|
|
157
|
+
"Probe": self.VALID_MANIPULATOR_IDS.index(manipulator_id),
|
|
158
|
+
"StepMode": 0 if speed > self.COARSE_SPEED_THRESHOLD else 1,
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Send move request.
|
|
163
|
+
await self._put_request(
|
|
164
|
+
{
|
|
165
|
+
"PutId": "ProbeMotion",
|
|
166
|
+
"Probe": self.VALID_MANIPULATOR_IDS.index(manipulator_id),
|
|
167
|
+
"Absolute": 1,
|
|
168
|
+
"Stereotactic": 0,
|
|
169
|
+
"AxisMask": 7,
|
|
170
|
+
"X": position.x,
|
|
171
|
+
"Y": position.y,
|
|
172
|
+
"Z": position.z,
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Wait for the manipulator to reach the target position or be stopped or stuck.
|
|
177
|
+
while (
|
|
178
|
+
not self._movement_stopped
|
|
179
|
+
and not self._is_vector_close(current_position, position)
|
|
180
|
+
and unchanged_counter < self.UNCHANGED_COUNTER_LIMIT
|
|
181
|
+
):
|
|
182
|
+
# Wait for a short time before checking again.
|
|
183
|
+
await sleep(self.POLL_INTERVAL)
|
|
184
|
+
|
|
185
|
+
# Update current position.
|
|
186
|
+
current_position = await self.get_position(manipulator_id)
|
|
187
|
+
|
|
188
|
+
# Check if manipulator is not moving.
|
|
189
|
+
if self._is_vector_close(previous_position, current_position):
|
|
190
|
+
# Position did not change.
|
|
191
|
+
unchanged_counter += 1
|
|
192
|
+
else:
|
|
193
|
+
# Position changed.
|
|
194
|
+
unchanged_counter = 0
|
|
195
|
+
previous_position = current_position
|
|
196
|
+
|
|
197
|
+
# Reset movement stopped flag.
|
|
198
|
+
self._movement_stopped = False
|
|
199
|
+
|
|
200
|
+
# Return the final position.
|
|
201
|
+
return await self.get_position(manipulator_id)
|
|
202
|
+
|
|
203
|
+
@override
|
|
204
|
+
async def set_depth(self, manipulator_id: str, depth: float, speed: float) -> float:
|
|
205
|
+
# Keep track of the previous depth to check if the manipulator stopped advancing unexpectedly.
|
|
206
|
+
current_depth = (await self.get_position(manipulator_id)).w
|
|
207
|
+
previous_depth = current_depth
|
|
208
|
+
unchanged_counter = 0
|
|
209
|
+
|
|
210
|
+
# Send move request.
|
|
211
|
+
# Convert mm/s to um/min and cap speed at the limit.
|
|
212
|
+
await self._put_request(
|
|
213
|
+
{
|
|
214
|
+
"PutId": "ProbeInsertion",
|
|
215
|
+
"Probe": self.VALID_MANIPULATOR_IDS.index(manipulator_id),
|
|
216
|
+
"Distance": scalar_mm_to_um(current_depth - depth),
|
|
217
|
+
"Rate": min(scalar_mm_to_um(speed) * 60, self.INSERTION_SPEED_LIMIT),
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Wait for the manipulator to reach the target depth or be stopped or get stuck.
|
|
222
|
+
while not self._movement_stopped and not abs(current_depth - depth) <= self.get_movement_tolerance():
|
|
223
|
+
# Wait for a short time before checking again.
|
|
224
|
+
await sleep(self.POLL_INTERVAL)
|
|
225
|
+
|
|
226
|
+
# Get the current depth.
|
|
227
|
+
current_depth = (await self.get_position(manipulator_id)).w
|
|
228
|
+
|
|
229
|
+
# Check if manipulator is not moving.
|
|
230
|
+
if abs(previous_depth - current_depth) <= self.get_movement_tolerance():
|
|
231
|
+
# Depth did not change.
|
|
232
|
+
unchanged_counter += 1
|
|
233
|
+
else:
|
|
234
|
+
# Depth changed.
|
|
235
|
+
unchanged_counter = 0
|
|
236
|
+
previous_depth = current_depth
|
|
237
|
+
|
|
238
|
+
# Reset movement stopped flag.
|
|
239
|
+
self._movement_stopped = False
|
|
240
|
+
|
|
241
|
+
# Return the final depth.
|
|
242
|
+
return float((await self.get_position(manipulator_id)).w)
|
|
243
|
+
|
|
244
|
+
@override
|
|
245
|
+
async def stop(self, manipulator_id: str) -> None:
|
|
246
|
+
request: dict[str, str | int | float] = {
|
|
247
|
+
"PutId": "ProbeStop",
|
|
248
|
+
"Probe": self.VALID_MANIPULATOR_IDS.index(manipulator_id),
|
|
249
|
+
}
|
|
250
|
+
await self._put_request(request)
|
|
251
|
+
self._movement_stopped = True
|
|
252
|
+
|
|
253
|
+
@override
|
|
254
|
+
def platform_space_to_unified_space(self, platform_space: Vector4) -> Vector4:
|
|
255
|
+
# unified <- platform
|
|
256
|
+
# +x <- -x
|
|
257
|
+
# +y <- +z
|
|
258
|
+
# +z <- +y
|
|
259
|
+
# +w <- -w
|
|
260
|
+
|
|
261
|
+
return Vector4(
|
|
262
|
+
x=self.get_dimensions().x - platform_space.x,
|
|
263
|
+
y=platform_space.z,
|
|
264
|
+
z=platform_space.y,
|
|
265
|
+
w=self.get_dimensions().w - platform_space.w,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
@override
|
|
269
|
+
def unified_space_to_platform_space(self, unified_space: Vector4) -> Vector4:
|
|
270
|
+
# platform <- unified
|
|
271
|
+
# +x <- -x
|
|
272
|
+
# +y <- +z
|
|
273
|
+
# +z <- +y
|
|
274
|
+
# +w <- -w
|
|
275
|
+
|
|
276
|
+
return Vector4(
|
|
277
|
+
x=self.get_dimensions().x - unified_space.x,
|
|
278
|
+
y=unified_space.z,
|
|
279
|
+
z=unified_space.y,
|
|
280
|
+
w=self.get_dimensions().w - unified_space.w,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Helper functions.
|
|
284
|
+
async def _query_data(self) -> dict[str, Any]: # pyright: ignore [reportExplicitAny]
|
|
285
|
+
try:
|
|
286
|
+
# Update cache if it's expired.
|
|
287
|
+
if get_running_loop().time() - self.cache_time > self.CACHE_LIFETIME:
|
|
288
|
+
# noinspection PyTypeChecker
|
|
289
|
+
self.cache = (await get_running_loop().run_in_executor(None, get, self._url)).json()
|
|
290
|
+
self.cache_time = get_running_loop().time()
|
|
291
|
+
except ConnectionError as connectionError:
|
|
292
|
+
error_message = f"Unable to connect to MPM HTTP server: {connectionError}"
|
|
293
|
+
raise RuntimeError(error_message) from connectionError
|
|
294
|
+
except JSONDecodeError as jsonDecodeError:
|
|
295
|
+
error_message = f"Unable to decode JSON response from MPM HTTP server: {jsonDecodeError}"
|
|
296
|
+
raise ValueError(error_message) from jsonDecodeError
|
|
297
|
+
else:
|
|
298
|
+
# Return cached data.
|
|
299
|
+
return self.cache
|
|
300
|
+
|
|
301
|
+
async def _manipulator_data(self, manipulator_id: str) -> dict[str, Any]: # pyright: ignore [reportExplicitAny]
|
|
302
|
+
probe_data: list[dict[str, Any]] = (await self._query_data())["ProbeArray"] # pyright: ignore [reportExplicitAny]
|
|
303
|
+
for probe in probe_data:
|
|
304
|
+
if probe["Id"] == manipulator_id:
|
|
305
|
+
return probe
|
|
306
|
+
|
|
307
|
+
# If we get here, that means the manipulator doesn't exist.
|
|
308
|
+
error_message = f"Manipulator {manipulator_id} not found."
|
|
309
|
+
raise ValueError(error_message)
|
|
310
|
+
|
|
311
|
+
async def _put_request(self, request: dict[str, Any]) -> None: # pyright: ignore [reportExplicitAny]
|
|
312
|
+
_ = await get_running_loop().run_in_executor(None, put, self._url, dumps(request))
|
|
313
|
+
|
|
314
|
+
def _is_vector_close(self, target: Vector4, current: Vector4) -> bool:
|
|
315
|
+
return all(abs(axis) <= self.get_movement_tolerance() for axis in vector4_to_array(target - current)[:3])
|