Skip to content

Commit 90aa4cd

Browse files
committed
microscope.controllers.prior: add support for Prior ProScanIII controller.
ProScanIII is the concrete class implementing the controller interface for the Prior ProScanII device. For now it can only control filter wheels. There is work in progress to add support for stages but it is dependent on defining a stage interface (issue #99). We don't add support for light sources because we don't have access to them. And don't add support for shutters because there's not even work in progress to add an ABC for them. _ProScanIIIFilterWheel is the concrete class implementing the filter wheel interface. It was tested with a HF108A and a HF110A filter wheel. _ProScanIIIConnection wraps the serial connection and its commands so they can be shared between future classes that add support for other device types. Also handles synchronization of communication from different threads.
1 parent 0ca37af commit 90aa4cd

File tree

6 files changed

+248
-3
lines changed

6 files changed

+248
-3
lines changed

NEWS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ Version 0.4.0 (upcoming)
77
* New devices supported:
88

99
* Coherent Obis laser.
10+
* Prior ProScan III controller
11+
* Prior filter wheels
1012

1113
* Changes to device ABCs:
1214

doc/supported-devices.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ Deformable Mirrors
2929
Filter Wheels
3030
-------------
3131

32-
- Thorlabs (:class:`microscope.filterwheels.thorlabs`)
3332
- Aurox Clarity (:class:`microscope.filterwheels.aurox`)
33+
- Prior (:class:`microscope.controllers.prior`)
34+
- Thorlabs (:class:`microscope.filterwheels.thorlabs`)
3435

3536
Lasers
3637
------
@@ -39,3 +40,8 @@ Lasers
3940
- Coherent Sapphire (:class:`microscope.lasers.sapphire`)
4041
- Coherent Obis (:class:`microscope.lasers.obis`)
4142
- Omicron Deepstar (:class:`microscope.lasers.deepstar`)
43+
44+
Controllers
45+
-----------
46+
47+
- Prior ProScan III (:class:`microscope.controllers.prior`)

microscope/controllers/__init__.py

Whitespace-only changes.

microscope/controllers/prior.py

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
#!/usr/bin/env python
2+
# -*- coding: utf-8 -*-
3+
4+
## Copyright (C) 2019 David Miguel Susano Pinto <david.pinto@bioch.ox.ac.uk>
5+
##
6+
## Microscope is free software: you can redistribute it and/or modify
7+
## it under the terms of the GNU General Public License as published by
8+
## the Free Software Foundation, either version 3 of the License, or
9+
## (at your option) any later version.
10+
##
11+
## Microscope is distributed in the hope that it will be useful,
12+
## but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
## GNU General Public License for more details.
15+
##
16+
## You should have received a copy of the GNU General Public License
17+
## along with Microscope. If not, see <http://www.gnu.org/licenses/>.
18+
19+
"""Prior controller.
20+
"""
21+
22+
import contextlib
23+
import threading
24+
import typing
25+
26+
import serial
27+
28+
import microscope.devices
29+
30+
31+
class _ProScanIIIConnection:
32+
"""Connection to a Prior ProScanIII and wrapper to its commands.
33+
34+
Devices that are controlled by the same controller should share
35+
the same connection instance to ensure correct synchronization of
36+
communications from different threads. This ensures that commands
37+
for different devices, or replies from different devices, don't
38+
get entangled.
39+
40+
This class also implements the logic to parse and validate
41+
commands so it can be shared between multiple devices.
42+
43+
"""
44+
def __init__(self, port: str, baudrate: int, timeout: float) -> None:
45+
# From the technical datasheet: 8 bit word 1 stop bit, no
46+
# parity no handshake, baudrate options of 9600, 19200, 38400,
47+
# 57600 and 115200.
48+
self._serial = serial.Serial(port=port, baudrate=baudrate,
49+
timeout=timeout, bytesize=serial.EIGHTBITS,
50+
stopbits=serial.STOPBITS_ONE,
51+
parity=serial.PARITY_NONE, xonxoff=False,
52+
rtscts=False, dsrdtr=False)
53+
self._lock = threading.RLock()
54+
55+
with self._lock:
56+
# We do not use the general get_description() because if
57+
# this is not a ProScan device it would never reach the
58+
# '\rEND\r' that signals the end of the description.
59+
self.command(b'?')
60+
answer = self.readline()
61+
if answer != b'PROSCAN INFORMATION\r':
62+
self.read_until_timeout()
63+
raise RuntimeError("Not a ProScanIII device: '?' returned '%s'"
64+
% answer.decode())
65+
# A description ends with END on its own line.
66+
line = self._serial.read_until(b'\rEND\r')
67+
if not line.endswith(b'\rEND\r'):
68+
raise RuntimeError("Failed to clear description")
69+
70+
71+
def command(self, command: bytes) -> None:
72+
"""Send command to device."""
73+
with self._lock:
74+
self._serial.write(command + b'\r')
75+
76+
def readline(self) -> bytes:
77+
"""Read a line from the device connection."""
78+
with self._lock:
79+
return self._serial.read_until(b'\r')
80+
81+
def read_until_timeout(self) -> None:
82+
"""Read until timeout; used to clean buffer if in an unknown state."""
83+
with self._lock:
84+
self._serial.flushInput()
85+
while len(self._serial.readline()) != 0:
86+
continue
87+
88+
def _command_and_validate(self, command: bytes, expected: bytes) -> None:
89+
"""Send command and raise exception if answer is unexpected"""
90+
with self._lock:
91+
answer = self.get_command(command)
92+
if answer != expected:
93+
self.read_until_timeout()
94+
raise RuntimeError("command '%s' failed (got '%s')"
95+
% (command.decode(), answer.decode()))
96+
97+
def get_command(self, command: bytes) -> bytes:
98+
"""Send get command and return the answer."""
99+
with self._lock:
100+
self.command(command)
101+
return self.readline()
102+
103+
def move_command(self, command: bytes) -> None:
104+
"""Send a move command and check return value."""
105+
# Movement commands respond with an R at the end of move.
106+
# Once a movement command is issued the application should
107+
# wait until the end of move R response is received before
108+
# sending any further commands.
109+
# TODO: this times 10 is a bit arbitrary.
110+
with self.changed_timeout(10 * self._serial.timeout):
111+
self._command_and_validate(command, b'R\r')
112+
113+
def set_command(self, command: bytes) -> None:
114+
"""Send a set command and check return value."""
115+
# Property type commands that set certain status respond with
116+
# zero. They respond with a zero even if there are invalid
117+
# arguments in the command.
118+
self._command_and_validate(command, b'0\r')
119+
120+
def get_description(self, command: bytes) -> bytes:
121+
"""Send a get description command and return it."""
122+
with self._lock:
123+
self.command(command)
124+
return self._serial.read_until(b'\rEND\r')
125+
126+
127+
@contextlib.contextmanager
128+
def changed_timeout(self, new_timeout: float):
129+
previous = self._serial.timeout
130+
try:
131+
self._serial.timeout = new_timeout
132+
yield
133+
finally:
134+
self._serial.timeout = previous
135+
136+
137+
def assert_filterwheel_number(self, number: int) -> None:
138+
assert number > 0 and number < 4
139+
140+
141+
def _has_thing(self, command: bytes, expected_start: bytes) -> bool:
142+
# Use the commands that returns a description string to find
143+
# whether a specific device is connected.
144+
with self._lock:
145+
description = self.get_description(command)
146+
if not description.startswith(expected_start):
147+
self.read_until_timeout()
148+
raise RuntimeError("Failed to get description '%s' (got '%s')"
149+
% (command.decode(), description.decode()))
150+
return not description.startswith(expected_start + b'NONE\r')
151+
152+
153+
def has_filterwheel(self, number: int) -> bool:
154+
self.assert_filterwheel_number(number)
155+
# We use the 'FILTER w' command to check if there's a filter
156+
# wheel instead of the '?' command. The reason is that the
157+
# third filter wheel, named "A AXIS" on the controller box and
158+
# "FOURTH" on the output of the '?' command, can be used for
159+
# non filter wheels. We hope that 'FILTER 3' will fail
160+
# properly if what is connected to "A AXIS" is not a filter
161+
# wheel.
162+
return self._has_thing(b'FILTER %d' % number, b'FILTER_%d = ' % number)
163+
164+
165+
def get_n_filter_positions(self, number: int) -> int:
166+
self.assert_filterwheel_number(number)
167+
answer = self.get_command(b'FPW %d' % number)
168+
return int(answer)
169+
170+
def get_filter_position(self, number: int) -> int:
171+
self.assert_filterwheel_number(number)
172+
answer = self.get_command(b'7 %d F' % number)
173+
return int(answer)
174+
175+
def set_filter_position(self, number: int, pos: int) -> None:
176+
self.assert_filterwheel_number(number)
177+
self.move_command(b'7 %d %d' % (number, pos))
178+
179+
180+
class ProScanIII(microscope.devices.ControllerDevice):
181+
"""Prior ProScanIII controller.
182+
183+
The controlled devices have the following labels:
184+
185+
`filter 1`
186+
Filter wheel connected to connector labelled "FILTER 1".
187+
`filter 2`
188+
Filter wheel connected to connector labelled "FILTER 1".
189+
`filter 3`
190+
Filter wheel connected to connector labelled "A AXIS".
191+
192+
.. note::
193+
194+
The Prior ProScanIII can control up to three filter wheels.
195+
However, a filter position may have a different number
196+
dependening on which connector it is. For example, using an 8
197+
position filter wheel, what is position 1 on the filter 1 and 2
198+
connectors, is position 4 when on the A axis (filter 3)
199+
connector.
200+
201+
"""
202+
def __init__(self, port: str, baudrate: int = 9600, timeout: float = 0.5,
203+
**kwargs) -> None:
204+
super().__init__(**kwargs)
205+
self._conn = _ProScanIIIConnection(port, baudrate, timeout)
206+
self._devices = {} # type: typing.Mapping[str, microscope.devices.Device]
207+
208+
# Can have up to three filter wheels, numbered 1 to 3.
209+
for number in range(1, 4):
210+
if self._conn.has_filterwheel(number):
211+
key = 'filter %d' % number
212+
self._devices[key] = _ProScanIIIFilterWheel(self._conn, number)
213+
214+
@property
215+
def devices(self) -> typing.Mapping[str, microscope.devices.Device]:
216+
return self._devices
217+
218+
219+
class _ProScanIIIFilterWheel(microscope.devices.FilterWheelBase):
220+
def __init__(self, connection: _ProScanIIIConnection, number: int) -> None:
221+
super().__init__()
222+
self._conn = connection
223+
self._number = number
224+
self._positions = self._conn.get_n_filter_positions(self._number)
225+
226+
def get_position(self) -> int:
227+
return self._conn.get_filter_position(self._number)
228+
229+
def set_position(self, position: int) -> None:
230+
self._conn.set_filter_position(self._number, position)
231+
232+
def _on_shutdown(self) -> None:
233+
super()._on_shutdown()
234+
235+
def initialize(self) -> None:
236+
super().initialize()

microscope/devices.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -952,8 +952,8 @@ def __init__(self, **kwargs) -> None:
952952
"""
953953
super().__init__(**kwargs)
954954

955-
self._patterns = None # type: typing.Optional[numpy.ndarray]
956-
self._pattern_idx = -1 # type: int
955+
self._patterns = None # type: typing.Optional[numpy.ndarray]
956+
self._pattern_idx = -1 # type: int
957957

958958
@property
959959
def n_actuators(self) -> int:

microscope/deviceserver.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ def run(self):
229229
if isinstance(self._device, microscope.devices.FloatingDeviceMixin):
230230
_logger.info('Device UID on port %s is %s'
231231
% (port, self._device.get_id()))
232+
232233
# Wait for termination event. We should just be able to call
233234
# wait() on the exit_event, but this causes issues with locks
234235
# in multiprocessing - see http://bugs.python.org/issue30975 .

0 commit comments

Comments
 (0)