ephys-link 2.0.0__py3-none-any.whl → 2.0.0b2__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.
@@ -1,315 +0,0 @@
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])
@@ -1,157 +0,0 @@
1
- """Bindings for Sensapex uMp-4 platform.
2
-
3
- Usage: Instantiate Ump4Bindings to interact with the Sensapex uMp-4 platform.
4
- """
5
-
6
- from asyncio import get_running_loop
7
- from typing import NoReturn, final, override
8
-
9
- from sensapex import UMP, SensapexDevice # pyright: ignore [reportMissingTypeStubs]
10
- from vbl_aquarium.models.unity import Vector4
11
-
12
- from ephys_link.utils.base_binding import BaseBinding
13
- from ephys_link.utils.constants import RESOURCES_DIRECTORY
14
- from ephys_link.utils.converters import (
15
- list_to_vector4,
16
- scalar_mm_to_um,
17
- um_to_mm,
18
- vector4_to_array,
19
- vector_mm_to_um,
20
- )
21
-
22
-
23
- @final
24
- class Ump4Binding(BaseBinding):
25
- """Bindings for UMP-4 platform"""
26
-
27
- def __init__(self) -> None:
28
- """Initialize UMP-4 bindings."""
29
-
30
- # Establish connection to Sensapex API (exit if connection fails).
31
- UMP.set_library_path(RESOURCES_DIRECTORY)
32
- self._ump = UMP.get_ump() # pyright: ignore [reportUnknownMemberType]
33
-
34
- @staticmethod
35
- @override
36
- def get_display_name() -> str:
37
- return "Sensapex uMp-4"
38
-
39
- @staticmethod
40
- @override
41
- def get_cli_name() -> str:
42
- return "ump-4"
43
-
44
- @override
45
- async def get_manipulators(self) -> list[str]:
46
- return list(map(str, self._ump.list_devices()))
47
-
48
- @override
49
- async def get_axes_count(self) -> int:
50
- return 4
51
-
52
- @override
53
- def get_dimensions(self) -> Vector4:
54
- return Vector4(x=20, y=20, z=20, w=20)
55
-
56
- @override
57
- async def get_position(self, manipulator_id: str) -> Vector4:
58
- return um_to_mm(list_to_vector4(self._get_device(manipulator_id).get_pos(1))) # pyright: ignore [reportUnknownMemberType]
59
-
60
- @override
61
- async def get_angles(self, manipulator_id: str) -> NoReturn:
62
- """uMp-4 does not support getting angles so raise an error.
63
-
64
- Raises:
65
- AttributeError: uMp-4 does not support getting angles.
66
- """
67
- error_message = "UMP-4 does not support getting angles"
68
- raise AttributeError(error_message)
69
-
70
- @override
71
- async def get_shank_count(self, manipulator_id: str) -> NoReturn:
72
- """uMp-4 does not support getting shank count so raise an error.
73
-
74
- Raises:
75
- AttributeError: uMp-4 does not support getting shank count.
76
- """
77
- error_message = "UMP-4 does not support getting shank count"
78
- raise AttributeError(error_message)
79
-
80
- @override
81
- def get_movement_tolerance(self) -> float:
82
- return 0.001
83
-
84
- @override
85
- async def set_position(self, manipulator_id: str, position: Vector4, speed: float) -> Vector4:
86
- # Convert position to micrometers.
87
- target_position_um = vector_mm_to_um(position)
88
-
89
- # Request movement.
90
- movement = self._get_device(manipulator_id).goto_pos( # pyright: ignore [reportUnknownMemberType]
91
- vector4_to_array(target_position_um), scalar_mm_to_um(speed)
92
- )
93
-
94
- # Wait for movement to finish.
95
- _ = await get_running_loop().run_in_executor(None, movement.finished_event.wait, None)
96
-
97
- # Handle interrupted movement.
98
- if movement.interrupted:
99
- error_message = f"Manipulator {manipulator_id} interrupted: {movement.interrupt_reason}" # pyright: ignore [reportUnknownMemberType]
100
- raise RuntimeError(error_message)
101
-
102
- # Handle empty end position.
103
- if movement.last_pos is None or len(movement.last_pos) == 0: # pyright: ignore [reportUnknownMemberType, reportUnknownArgumentType]
104
- error_message = f"Manipulator {manipulator_id} did not reach target position"
105
- raise RuntimeError(error_message)
106
-
107
- return um_to_mm(list_to_vector4(movement.last_pos)) # pyright: ignore [reportArgumentType, reportUnknownMemberType]
108
-
109
- @override
110
- async def set_depth(self, manipulator_id: str, depth: float, speed: float) -> float:
111
- # Augment current position with depth.
112
- current_position = await self.get_position(manipulator_id)
113
- new_platform_position = current_position.model_copy(update={"w": depth})
114
-
115
- # Make the movement.
116
- final_platform_position = await self.set_position(manipulator_id, new_platform_position, speed)
117
-
118
- # Return the final depth.
119
- return float(final_platform_position.w)
120
-
121
- @override
122
- async def stop(self, manipulator_id: str) -> None:
123
- self._get_device(manipulator_id).stop()
124
-
125
- @override
126
- def platform_space_to_unified_space(self, platform_space: Vector4) -> Vector4:
127
- # unified <- platform
128
- # +x <- +y
129
- # +y <- -z
130
- # +z <- +x
131
- # +d <- +d
132
-
133
- return Vector4(
134
- x=platform_space.y,
135
- y=self.get_dimensions().z - platform_space.z,
136
- z=platform_space.x,
137
- w=platform_space.w,
138
- )
139
-
140
- @override
141
- def unified_space_to_platform_space(self, unified_space: Vector4) -> Vector4:
142
- # platform <- unified
143
- # +x <- +z
144
- # +y <- +x
145
- # +z <- -y
146
- # +d <- +d
147
-
148
- return Vector4(
149
- x=unified_space.z,
150
- y=unified_space.x,
151
- z=self.get_dimensions().z - unified_space.y,
152
- w=unified_space.w,
153
- )
154
-
155
- # Helper methods.
156
- def _get_device(self, manipulator_id: str) -> SensapexDevice:
157
- return self._ump.get_device(int(manipulator_id)) # pyright: ignore [reportUnknownMemberType]
@@ -1,23 +0,0 @@
1
- """Globally accessible constants"""
2
-
3
- from os.path import abspath, dirname, join
4
-
5
- # Ephys Link ASCII.
6
- ASCII = r"""
7
- ______ _ _ _ _
8
- | ____| | | | | (_) | |
9
- | |__ _ __ | |__ _ _ ___ | | _ _ __ | | __
10
- | __| | '_ \| '_ \| | | / __| | | | | '_ \| |/ /
11
- | |____| |_) | | | | |_| \__ \ | |____| | | | | <
12
- |______| .__/|_| |_|\__, |___/ |______|_|_| |_|_|\_\
13
- | | __/ |
14
- |_| |___/
15
- """
16
-
17
- # Absolute path to the resource folder.
18
- PACKAGE_DIRECTORY = dirname(dirname(abspath(__file__)))
19
- RESOURCES_DIRECTORY = join(PACKAGE_DIRECTORY, "resources")
20
- BINDINGS_DIRECTORY = join(PACKAGE_DIRECTORY, "bindings")
21
-
22
- # Ephys Link Port
23
- PORT = 3000
@@ -1,86 +0,0 @@
1
- """Commonly used conversion functions."""
2
-
3
- from vbl_aquarium.models.unity import Vector4
4
-
5
-
6
- def scalar_mm_to_um(mm: float) -> float:
7
- """Convert scalar values of millimeters to micrometers.
8
-
9
- Args:
10
- mm: Scalar value in millimeters.
11
-
12
- Returns:
13
- Scalar value in micrometers.
14
- """
15
- return mm * 1_000
16
-
17
-
18
- def vector_mm_to_um(mm: Vector4) -> Vector4:
19
- """Convert vector values of millimeters to micrometers.
20
-
21
- Args:
22
- mm: Vector in millimeters.
23
-
24
- Returns:
25
- Vector in micrometers.
26
- """
27
- return mm * 1_000
28
-
29
-
30
- def um_to_mm(um: Vector4) -> Vector4:
31
- """Convert micrometers to millimeters.
32
-
33
- Args:
34
- um: Length in micrometers.
35
-
36
- Returns:
37
- Length in millimeters.
38
- """
39
- return um / 1_000
40
-
41
-
42
- def vector4_to_array(vector4: Vector4) -> list[float]:
43
- """Convert a [Vector4][vbl_aquarium.models.unity.Vector4] to a list of floats.
44
-
45
- Args:
46
- vector4: [Vector4][vbl_aquarium.models.unity.Vector4] to convert.
47
-
48
- Returns:
49
- List of floats.
50
- """
51
- return [vector4.x, vector4.y, vector4.z, vector4.w]
52
-
53
-
54
- def list_to_vector4(float_list: list[float | int]) -> Vector4:
55
- """Convert a list of floats to a [Vector4][vbl_aquarium.models.unity.Vector4].
56
-
57
- Args:
58
- float_list: List of floats.
59
-
60
- Returns:
61
- First four elements of the list as a Vector4 padded with zeros if necessary.
62
- """
63
-
64
- def get_element(this_array: list[float | int], index: int) -> float:
65
- """Safely get an element from an array.
66
-
67
- Return 0 if the index is out of bounds.
68
-
69
- Args:
70
- this_array: Array to get the element from.
71
- index: Index to get.
72
-
73
- Returns:
74
- Element at the index or 0 if the index is out of bounds.
75
- """
76
- try:
77
- return this_array[index]
78
- except IndexError:
79
- return 0.0
80
-
81
- return Vector4(
82
- x=get_element(float_list, 0),
83
- y=get_element(float_list, 1),
84
- z=get_element(float_list, 2),
85
- w=get_element(float_list, 3),
86
- )
@@ -1,65 +0,0 @@
1
- """Program startup helper functions."""
2
-
3
- from importlib import import_module
4
- from inspect import getmembers, isclass
5
- from pkgutil import iter_modules
6
-
7
- from packaging.version import parse
8
- from requests import ConnectionError, ConnectTimeout, get
9
-
10
- from ephys_link.__about__ import __version__
11
- from ephys_link.utils.base_binding import BaseBinding
12
- from ephys_link.utils.console import Console
13
- from ephys_link.utils.constants import ASCII, BINDINGS_DIRECTORY
14
-
15
-
16
- def preamble() -> None:
17
- """Print the server startup preamble."""
18
- print(ASCII) # noqa: T201
19
- print(__version__) # noqa: T201
20
- print() # noqa: T201
21
- print("This is the Ephys Link server window.") # noqa: T201
22
- print("You may safely leave it running in the background.") # noqa: T201
23
- print("To stop it, close this window or press CTRL + Pause/Break.") # noqa: T201
24
- print() # noqa: T201
25
-
26
-
27
- def check_for_updates(console: Console) -> None:
28
- """Check for updates to the Ephys Link.
29
-
30
- Args:
31
- console: Console instance for printing messages.
32
- """
33
- try:
34
- response = get("https://api.github.com/repos/VirtualBrainLab/ephys-link/tags", timeout=10)
35
- latest_version = str(response.json()[0]["name"]) # pyright: ignore [reportAny]
36
- if parse(latest_version) > parse(__version__):
37
- console.critical_print(f"Update available: {latest_version} (current: {__version__})")
38
- console.critical_print("Download at: https://github.com/VirtualBrainLab/ephys-link/releases/latest")
39
- except (ConnectionError, ConnectTimeout):
40
- console.error_print(
41
- "UPDATE", "Unable to check for updates. Ignore updates or use the the -i flag to disable checks.\n"
42
- )
43
-
44
-
45
- def get_bindings() -> list[type[BaseBinding]]:
46
- """Get all binding classes from the bindings directory.
47
-
48
- Returns:
49
- List of binding classes.
50
- """
51
- return [
52
- binding_type
53
- for module in iter_modules([BINDINGS_DIRECTORY])
54
- for _, binding_type in getmembers(import_module(f"ephys_link.bindings.{module.name}"), isclass)
55
- if issubclass(binding_type, BaseBinding) and binding_type != BaseBinding
56
- ]
57
-
58
-
59
- def get_binding_display_to_cli_name() -> dict[str, str]:
60
- """Get mapping of display to CLI option names of the available platform bindings.
61
-
62
- Returns:
63
- Dictionary of platform binding display name to CLI option name.
64
- """
65
- return {binding_type.get_display_name(): binding_type.get_cli_name() for binding_type in get_bindings()}