Skip to content

Commit 0f6146a

Browse files
add Cytation5 (#238)
Co-authored-by: jkh <[email protected]>
1 parent 2145f38 commit 0f6146a

10 files changed

+669
-29
lines changed

Diff for: CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6060
- `NestedTipRack` (https://github.com/PyLabRobot/pylabrobot/pull/228)
6161
- `HTF_L_ULTRAWIDE`, `ultrawide_high_volume_tip_with_filter` (https://github.com/PyLabRobot/pylabrobot/pull/229/)
6262
- `get_absolute_size_x`, `get_absolute_size_y`, `get_absolute_size_z` for `Resource` (https://github.com/PyLabRobot/pylabrobot/pull/235)
63+
- `Cytation5Backend` for plate reading on BioTek Cytation 5 (https://github.com/PyLabRobot/pylabrobot/pull/238)
6364

6465
### Deprecated
6566

@@ -91,3 +92,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
9192
- `hamilton_parse` module and the VENUS labware database parser.
9293
- `PLT_CAR_L4_SHAKER` was removed in favor of `MFX_CAR_L5_base` (https://github.com/PyLabRobot/pylabrobot/pull/188/).
9394
- `items`, `num_items_x` and `num_items_y` attributes of `ItemizedResource` (https://github.com/PyLabRobot/pylabrobot/pull/231)
95+
- `report` is no longer a parameter of `PlateReader.read_absorbance` (default is now OD) (https://github.com/PyLabRobot/pylabrobot/pull/238)

Diff for: docs/cytation5.ipynb

+214
Large diffs are not rendered by default.

Diff for: docs/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ PyLabRobot provides a layer of general-purpose abstractions over robot functions
5959
:caption: Plate reading
6060

6161
plate_reading
62+
cytation5
6263

6364

6465
.. toctree::

Diff for: pylabrobot/plate_reading/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
from .plate_reader import PlateReader
2+
from .biotek_backend import Cytation5Backend
23
from .clario_star import CLARIOStar

Diff for: pylabrobot/plate_reading/backend.py

+10-10
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
11
from __future__ import annotations
22

33
from abc import ABCMeta, abstractmethod
4-
import sys
54
from typing import List
65

76
from pylabrobot.machines.backends import MachineBackend
87

9-
if sys.version_info >= (3, 8):
10-
from typing import Literal
11-
else:
12-
from typing_extensions import Literal
13-
148

159
class PlateReaderBackend(MachineBackend, metaclass=ABCMeta):
1610
""" An abstract class for a plate reader. Plate readers are devices that can read luminescence,
@@ -38,10 +32,16 @@ async def read_luminescence(self, focal_height: float) -> List[List[float]]:
3832
outer list is the columns of the plate and the inner list is the rows of the plate. """
3933

4034
@abstractmethod
41-
async def read_absorbance(
35+
async def read_absorbance(self, wavelength: int) -> List[List[float]]:
36+
""" Read the absorbance from the plate reader. This should return a list of lists, where the
37+
outer list is the columns of the plate and the inner list is the rows of the plate. """
38+
39+
@abstractmethod
40+
async def read_fluorescence(
4241
self,
43-
wavelength: int,
44-
report: Literal["OD", "transmittance"]
42+
excitation_wavelength: int,
43+
emission_wavelength: int,
44+
focal_height: float
4545
) -> List[List[float]]:
46-
""" Read the absorbance from the plate reader. This should return a list of lists, where the
46+
""" Read the fluorescence from the plate reader. This should return a list of lists, where the
4747
outer list is the columns of the plate and the inner list is the rows of the plate. """

Diff for: pylabrobot/plate_reading/biotek_backend.py

+252
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import asyncio
2+
import enum
3+
import logging
4+
import time
5+
from typing import List, Optional, Union
6+
from pylibftdi import Device
7+
from pylabrobot.plate_reading.backend import PlateReaderBackend
8+
9+
10+
logger = logging.getLogger("pylabrobot.plate_reading.biotek")
11+
12+
13+
class Cytation5Backend(PlateReaderBackend):
14+
""" Backend for biotek cytation 5 image reader """
15+
def __init__(self, timeout: float = 60) -> None:
16+
super().__init__()
17+
self.timeout = timeout
18+
self.dev = Device(lazy_open=True)
19+
20+
async def setup(self) -> None:
21+
logger.info("[cytation5] setting up")
22+
self.dev.open()
23+
self.dev.baudrate = 9600
24+
self.dev.ftdi_fn.ftdi_set_line_property(8, 2, 0) # 8 bits, 2 stop bits, no parity
25+
SIO_RTS_CTS_HS = 0x1 << 8
26+
self.dev.ftdi_fn.ftdi_setflowctrl(SIO_RTS_CTS_HS)
27+
self.dev.ftdi_fn.ftdi_setrts(1)
28+
29+
self._shaking = False
30+
self._shaking_task: Optional[asyncio.Task] = None
31+
32+
async def stop(self) -> None:
33+
logger.info("[cytation5] stopping")
34+
await self.stop_shaking()
35+
self.dev.close()
36+
37+
async def _purge_buffers(self) -> None:
38+
""" Purge the RX and TX buffers, as implemented in Gen5.exe """
39+
for _ in range(6):
40+
self.dev.ftdi_fn.ftdi_usb_purge_rx_buffer()
41+
self.dev.ftdi_fn.ftdi_usb_purge_tx_buffer()
42+
43+
async def _read_until(self, char: bytes, timeout: Optional[float] = None) -> bytes:
44+
""" If timeout is None, use self.timeout """
45+
if timeout is None:
46+
timeout = self.timeout
47+
x = None
48+
res = b""
49+
t0 = time.time()
50+
while x != char:
51+
x = self.dev.read(1)
52+
res += x
53+
54+
if time.time() - t0 > timeout:
55+
raise TimeoutError("Timeout while waiting for response")
56+
57+
if x == b"":
58+
await asyncio.sleep(0.01)
59+
60+
logger.debug("[cytation5] received %s", res)
61+
return res
62+
63+
async def send_command(
64+
self,
65+
command: Union[bytes, str],
66+
purge: bool = True,
67+
wait_for_char: Optional[bytes] = b"\x03") -> Optional[bytes]:
68+
if purge:
69+
# real software does this, but I don't think it's necessary
70+
await self._purge_buffers()
71+
72+
if not isinstance(command, bytes):
73+
command = command.encode()
74+
self.dev.write(command)
75+
logger.debug("[cytation5] sent %s", command)
76+
77+
if wait_for_char is None:
78+
return None
79+
80+
return await self._read_until(wait_for_char)
81+
82+
async def get_serial_number(self) -> str:
83+
resp = await self.send_command("C")
84+
assert resp is not None
85+
return resp[1:].split(b" ")[0].decode()
86+
87+
async def get_firmware_version(self) -> str:
88+
resp = await self.send_command("e")
89+
assert resp is not None
90+
return " ".join(resp[1:-1].decode().split(" ")[0:4])
91+
92+
async def open(self):
93+
return await self.send_command("J")
94+
95+
async def close(self):
96+
return await self.send_command("A")
97+
98+
async def get_current_temperature(self) -> float:
99+
""" Get current temperature in degrees Celsius. """
100+
resp = await self.send_command("h")
101+
assert resp is not None
102+
return int(resp[1:-1]) / 100000
103+
104+
def _parse_body(self, body: bytes) -> List[List[float]]:
105+
start_index = body.index(b"01,01")
106+
end_index = body.rindex(b"\r\n")
107+
num_rows = 8
108+
rows = body[start_index:end_index].split(b"\r\n,")[:num_rows]
109+
110+
parsed_data: List[List[float]] = []
111+
for row_idx, row in enumerate(rows):
112+
parsed_data.append([])
113+
values = row.split(b",")
114+
grouped_values = [values[i:i+3] for i in range(0, len(values), 3)]
115+
116+
for group in grouped_values:
117+
assert len(group) == 3
118+
value = float(group[2].decode())
119+
parsed_data[row_idx].append(value)
120+
return parsed_data
121+
122+
async def read_absorbance(self, wavelength: int) -> List[List[float]]:
123+
if not 230 <= wavelength <= 999:
124+
raise ValueError("Wavelength must be between 230 and 999")
125+
126+
resp = await self.send_command("y", wait_for_char=b"\x06")
127+
assert resp == b"\x06"
128+
await self.send_command(b"08120112207434014351135308559127881772\x03", purge=False)
129+
130+
resp = await self.send_command("D", wait_for_char=b"\x06")
131+
assert resp == b"\x06"
132+
wavelength_str = str(wavelength).zfill(4)
133+
cmd = f"00470101010812000120010000110010000010600008{wavelength_str}1".encode()
134+
checksum = str(sum(cmd) % 100).encode()
135+
cmd = cmd + checksum + b"\x03"
136+
await self.send_command(cmd, purge=False)
137+
138+
resp1 = await self.send_command("O", wait_for_char=b"\x06")
139+
assert resp1 == b"\x06"
140+
resp2 = await self._read_until(b"\x03")
141+
assert resp2 == b"0000\x03"
142+
143+
# read data
144+
body = await self._read_until(b"\x03")
145+
assert resp is not None
146+
return self._parse_body(body)
147+
148+
async def read_luminescence(self, focal_height: float) -> List[List[float]]:
149+
raise NotImplementedError("Not implemented yet")
150+
151+
async def read_fluorescence(
152+
self,
153+
excitation_wavelength: int,
154+
emission_wavelength: int,
155+
focal_height: float,
156+
) -> List[List[float]]:
157+
if not 4.5 <= focal_height <= 13.88:
158+
raise ValueError("Focal height must be between 4.5 and 13.88")
159+
if not 250 <= excitation_wavelength <= 700:
160+
raise ValueError("Excitation wavelength must be between 250 and 700")
161+
if not 250 <= emission_wavelength <= 700:
162+
raise ValueError("Emission wavelength must be between 250 and 700")
163+
164+
resp = await self.send_command("t", wait_for_char=b"\x06")
165+
assert resp == b"\x06"
166+
167+
cmd = f"{614220 + int(1000*focal_height)}\x03".encode()
168+
await self.send_command(cmd, purge=False)
169+
170+
resp = await self.send_command("y", wait_for_char=b"\x06")
171+
assert resp == b"\x06"
172+
await self.send_command(b"08120112207434014351135308559127881772\x03", purge=False)
173+
174+
resp = await self.send_command("D", wait_for_char=b"\x06")
175+
assert resp == b"\x06"
176+
excitation_wavelength_str = str(excitation_wavelength).zfill(4)
177+
emission_wavelength_str = str(emission_wavelength).zfill(4)
178+
cmd = (f"008401010108120001200100001100100000135000100200200{excitation_wavelength_str}000"
179+
f"{emission_wavelength_str}000000000000000000210011").encode()
180+
checksum = str((sum(cmd)+7) % 100).encode() # don't know why +7
181+
cmd = cmd + checksum + b"\x03"
182+
await self.send_command(cmd, purge=False)
183+
184+
resp1 = await self.send_command("O", wait_for_char=b"\x06")
185+
assert resp1 == b"\x06"
186+
resp2 = await self._read_until(b"\x03")
187+
assert resp2 == b"0000\x03"
188+
189+
body = await self._read_until(b"\x03", timeout=60*2)
190+
assert body is not None
191+
return self._parse_body(body)
192+
193+
async def _abort(self) -> None:
194+
await self.send_command("x", wait_for_char=None)
195+
196+
class ShakeType(enum.IntEnum):
197+
LINEAR = 0
198+
ORBITAL = 1
199+
200+
async def shake(self, shake_type: ShakeType) -> None:
201+
""" Warning: the duration for shaking has to be specified on the machine, and the maximum is
202+
16 minutes. As a hack, we start shaking for the maximum duration every time as long as stop
203+
is not called. """
204+
max_duration = 16*60 # 16 minutes
205+
206+
async def shake_maximal_duration():
207+
""" This method will start the shaking, but returns immediately after
208+
shaking has started. """
209+
resp = await self.send_command("y", wait_for_char=b"\x06")
210+
assert resp == b"\x06"
211+
await self.send_command(b"08120112207434014351135308559127881422\x03", purge=False)
212+
213+
resp = await self.send_command("D", wait_for_char=b"\x06")
214+
assert resp == b"\x06"
215+
shake_type_bit = str(shake_type.value)
216+
217+
duration = str(max_duration).zfill(3)
218+
cmd = f"0033010101010100002000000013{duration}{shake_type_bit}301".encode()
219+
checksum = str((sum(cmd)+73) % 100).encode() # don't know why +73
220+
cmd = cmd + checksum + b"\x03"
221+
await self.send_command(cmd, purge=False)
222+
223+
resp = await self.send_command("O", wait_for_char=b"\x06")
224+
assert resp == b"\x06"
225+
resp = await self._read_until(b"\x03")
226+
assert resp == b"0000\x03"
227+
228+
async def shake_continuous():
229+
while self._shaking:
230+
await shake_maximal_duration()
231+
232+
# short sleep allows = frequent checks for fast stopping
233+
seconds_since_start: float = 0
234+
loop_wait_time = 0.25
235+
while seconds_since_start < max_duration and self._shaking:
236+
seconds_since_start += loop_wait_time
237+
await asyncio.sleep(loop_wait_time)
238+
239+
self._shaking = True
240+
self._shaking_task = asyncio.create_task(shake_continuous())
241+
242+
async def stop_shaking(self) -> None:
243+
await self._abort()
244+
if self._shaking:
245+
self._shaking = False
246+
if self._shaking_task is not None:
247+
self._shaking_task.cancel()
248+
try:
249+
await self._shaking_task
250+
except asyncio.CancelledError:
251+
pass
252+
self._shaking_task = None

0 commit comments

Comments
 (0)