Skip to content

Commit 42ceb2c

Browse files
committed
added luld stage module based on prior- needs editing
1 parent afaf078 commit 42ceb2c

File tree

1 file changed

+273
-0
lines changed

1 file changed

+273
-0
lines changed

microscope/stages/ludl.py

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
#!/usr/bin/env python3
2+
3+
## Copyright (C) 2020 David Miguel Susano Pinto <carandraug@gmail.com>
4+
## Copyright (C) 2022 Ian Dobbie <ian.dobbie@jhu.edu>
5+
##
6+
##
7+
## This file is part of Microscope.
8+
##
9+
## Microscope is free software: you can redistribute it and/or modify
10+
## it under the terms of the GNU General Public License as published by
11+
## the Free Software Foundation, either version 3 of the License, or
12+
## (at your option) any later version.
13+
##
14+
## Microscope is distributed in the hope that it will be useful,
15+
## but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17+
## GNU General Public License for more details.
18+
##
19+
## You should have received a copy of the GNU General Public License
20+
## along with Microscope. If not, see <http://www.gnu.org/licenses/>.
21+
22+
"""Ludl controller.
23+
"""
24+
25+
import contextlib
26+
import threading
27+
import typing
28+
29+
import serial
30+
31+
import microscope.abc
32+
33+
34+
35+
# commands
36+
# Where X - A: 12000
37+
# Where X Y - :A -2000 1000
38+
# Where X Y - :A -2000 N-2 # Y axis no installed N-2 is error -2
39+
40+
41+
# errors
42+
# -1 Unknown command
43+
# -2 Illegal point type or axis, or module not installed
44+
# -3 Not enough parameters (e.g. move r=)
45+
# -4 Parameter out of range
46+
# -21 Process aborted by HALT command
47+
48+
# Slide Loader:
49+
# -4 (parameter out of range) used for cassette or slot range errors
50+
# -10 No slides selected
51+
# -11 End of list reached
52+
# -12 Slide error
53+
# -16 Motor move error (move not completed successfully due to stall,
54+
# end limit, etc….)
55+
# -17 Initialization erro
56+
57+
# MOVE X=2000 - A: positive reply? need to check movement is finished.
58+
# VMOVE X=1 y=2 -
59+
# MOVREL
60+
61+
62+
class _LudlController:
63+
"""Connection to a Ludl Controller and wrapper to its commands.
64+
65+
Tested with MC2000 controller and xy stage.
66+
67+
68+
This class also implements the logic to parse and validate
69+
commands so it can be shared between multiple devices.
70+
71+
This class has only been tested on a MAC2000 controller from the
72+
1990's however newer controllers should be compatible.
73+
74+
"""
75+
76+
def __init__(self, port: str, baudrate: int, timeout: float) -> None:
77+
# From the technical datasheet: 8 bit word 1 stop bit, no
78+
# parity no handshake, baudrate options of 9600, 19200, 38400,
79+
# 57600 and 115200.
80+
self._serial = serial.Serial(
81+
port=port,
82+
baudrate=baudrate,
83+
timeout=timeout,
84+
bytesize=serial.EIGHTBITS,
85+
stopbits=serial.STOPBITS_ONE,
86+
parity=serial.PARITY_NONE,
87+
xonxoff=False,
88+
rtscts=False,
89+
dsrdtr=False,
90+
)
91+
self._lock = threading.RLock()
92+
93+
with self._lock:
94+
# We do not use the general get_description() here because
95+
# if this is not a ProScan device it would never reach the
96+
# '\rEND\r' that signals the end of the description.
97+
self.command(b"?")
98+
answer = self.readline()
99+
if answer != b"PROSCAN INFORMATION\r":
100+
self.read_until_timeout()
101+
raise RuntimeError(
102+
"Not a ProScanIII device: '?' returned '%s'"
103+
% answer.decode()
104+
)
105+
# A description ends with END on its own line.
106+
line = self._serial.read_until(b"\rEND\r")
107+
if not line.endswith(b"\rEND\r"):
108+
raise RuntimeError("Failed to clear description")
109+
110+
def command(self, command: bytes) -> None:
111+
"""Send command to device."""
112+
with self._lock:
113+
self._serial.write(command + b"\r")
114+
115+
def readline(self) -> bytes:
116+
"""Read a line from the device connection."""
117+
with self._lock:
118+
return self._serial.read_until(b"\r")
119+
120+
def read_until_timeout(self) -> None:
121+
"""Read until timeout; used to clean buffer if in an unknown state."""
122+
with self._lock:
123+
self._serial.flushInput()
124+
while self._serial.readline():
125+
continue
126+
127+
def _command_and_validate(self, command: bytes, expected: bytes) -> None:
128+
"""Send command and raise exception if answer is unexpected"""
129+
with self._lock:
130+
answer = self.get_command(command)
131+
if answer != expected:
132+
self.read_until_timeout()
133+
raise RuntimeError(
134+
"command '%s' failed (got '%s')"
135+
% (command.decode(), answer.decode())
136+
)
137+
138+
def get_command(self, command: bytes) -> bytes:
139+
"""Send get command and return the answer."""
140+
with self._lock:
141+
self.command(command)
142+
return self.readline()
143+
144+
def move_command(self, command: bytes) -> None:
145+
"""Send a move command and check return value."""
146+
# Movement commands respond with an R at the end of move.
147+
# Once a movement command is issued the application should
148+
# wait until the end of move R response is received before
149+
# sending any further commands.
150+
# TODO: this times 10 for timeout is a bit arbitrary.
151+
with self.changed_timeout(10 * self._serial.timeout):
152+
self._command_and_validate(command, b"R\r")
153+
154+
def set_command(self, command: bytes) -> None:
155+
"""Send a set command and check return value."""
156+
# Property type commands that set certain status respond with
157+
# zero. They respond with a zero even if there are invalid
158+
# arguments in the command.
159+
self._command_and_validate(command, b"0\r")
160+
161+
def get_description(self, command: bytes) -> bytes:
162+
"""Send a get description command and return it."""
163+
with self._lock:
164+
self.command(command)
165+
return self._serial.read_until(b"\rEND\r")
166+
167+
@contextlib.contextmanager
168+
def changed_timeout(self, new_timeout: float):
169+
previous = self._serial.timeout
170+
try:
171+
self._serial.timeout = new_timeout
172+
yield
173+
finally:
174+
self._serial.timeout = previous
175+
176+
def assert_filterwheel_number(self, number: int) -> None:
177+
assert number > 0 and number < 4
178+
179+
def _has_thing(self, command: bytes, expected_start: bytes) -> bool:
180+
# Use the commands that returns a description string to find
181+
# whether a specific device is connected.
182+
with self._lock:
183+
description = self.get_description(command)
184+
if not description.startswith(expected_start):
185+
self.read_until_timeout()
186+
raise RuntimeError(
187+
"Failed to get description '%s' (got '%s')"
188+
% (command.decode(), description.decode())
189+
)
190+
return not description.startswith(expected_start + b"NONE\r")
191+
192+
def has_filterwheel(self, number: int) -> bool:
193+
self.assert_filterwheel_number(number)
194+
# We use the 'FILTER w' command to check if there's a filter
195+
# wheel instead of the '?' command. The reason is that the
196+
# third filter wheel, named "A AXIS" on the controller box and
197+
# "FOURTH" on the output of the '?' command, can be used for
198+
# non filter wheels. We hope that 'FILTER 3' will fail
199+
# properly if what is connected to "A AXIS" is not a filter
200+
# wheel.
201+
return self._has_thing(b"FILTER %d" % number, b"FILTER_%d = " % number)
202+
203+
def get_n_filter_positions(self, number: int) -> int:
204+
self.assert_filterwheel_number(number)
205+
answer = self.get_command(b"FPW %d" % number)
206+
return int(answer)
207+
208+
def get_filter_position(self, number: int) -> int:
209+
self.assert_filterwheel_number(number)
210+
answer = self.get_command(b"7 %d F" % number)
211+
return int(answer)
212+
213+
def set_filter_position(self, number: int, pos: int) -> None:
214+
self.assert_filterwheel_number(number)
215+
self.move_command(b"7 %d %d" % (number, pos))
216+
217+
218+
class ludlMC2000(microscope.abc.Controller):
219+
"""Ludl MC 2000 controller.
220+
221+
The controlled devices have the following labels:
222+
223+
`filter 1`
224+
Filter wheel connected to connector labelled "FILTER 1".
225+
`filter 2`
226+
Filter wheel connected to connector labelled "FILTER 1".
227+
`filter 3`
228+
Filter wheel connected to connector labelled "A AXIS".
229+
230+
.. note::
231+
232+
The Prior ProScanIII can control up to three filter wheels.
233+
However, a filter position may have a different number
234+
dependening on which connector it is. For example, using an 8
235+
position filter wheel, what is position 1 on the "filter 1" and
236+
"filter 2" connectors, is position 4 when on the "A axis" (or
237+
"filter 3") connector.
238+
239+
"""
240+
241+
def __init__(
242+
self, port: str, baudrate: int = 9600, timeout: float = 0.5, **kwargs
243+
) -> None:
244+
super().__init__(**kwargs)
245+
self._conn = _ludlConnection(port, baudrate, timeout)
246+
self._devices: typing.Mapping[str, microscope.abc.Device] = {}
247+
248+
# Can have up to three filter wheels, numbered 1 to 3.
249+
for number in range(1, 4):
250+
if self._conn.has_filterwheel(number):
251+
key = "filter %d" % number
252+
self._devices[key] = _ludlFilterWheel(self._conn, number)
253+
254+
@property
255+
def devices(self) -> typing.Mapping[str, microscope.abc.Device]:
256+
return self._devices
257+
258+
# ludl controller can do filter wheels so leave this code for future adoption
259+
#
260+
# class _ludlFilterWheel(microscope.abc.FilterWheel):
261+
# def __init__(self, connection: _ludlConnection, number: int) -> None:
262+
# super().__init__(positions=connection.get_n_filter_positions(number))
263+
# self._conn = connection
264+
# self._number = number
265+
266+
# def _do_get_position(self) -> int:
267+
# return self._conn.get_filter_position(self._number)
268+
269+
# def _do_set_position(self, position: int) -> None:
270+
# self._conn.set_filter_position(self._number, position)
271+
272+
# def _do_shutdown(self) -> None:
273+
# pass

0 commit comments

Comments
 (0)