Skip to content

Commit 2b88140

Browse files
authored
support reading any subset of wells on cytation5 (#718)
1 parent 9841813 commit 2b88140

File tree

2 files changed

+167
-59
lines changed

2 files changed

+167
-59
lines changed

pylabrobot/plate_reading/biotek_backend.py

Lines changed: 112 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import re
77
import time
88
from dataclasses import dataclass
9-
from typing import List, Literal, Optional, Tuple, Union
9+
from typing import Dict, Iterable, List, Literal, Optional, Tuple, Union
1010

1111
from pylabrobot.resources import Plate, Well
1212

@@ -72,6 +72,54 @@ def retry(func, *args, **kwargs):
7272
time.sleep(delay)
7373

7474

75+
def _non_overlapping_rectangles(
76+
points: Iterable[Tuple[int, int]],
77+
) -> List[Tuple[int, int, int, int]]:
78+
"""Find non-overlapping rectangles that cover all given points.
79+
80+
Example:
81+
>>> points = [
82+
>>> (1, 1),
83+
>>> (2, 2), (2, 3), (2, 4),
84+
>>> (3, 2), (3, 3), (3, 4),
85+
>>> (4, 2), (4, 3), (4, 4), (4, 5),
86+
>>> (5, 2), (5, 3), (5, 4), (5, 5),
87+
>>> (6, 2), (6, 3), (6, 4), (6, 5),
88+
>>> (7, 2), (7, 3), (7, 4),
89+
>>> ]
90+
>>> non_overlapping_rectangles(points)
91+
[
92+
(1, 1, 1, 1),
93+
(2, 2, 7, 4),
94+
(4, 5, 6, 5),
95+
]
96+
"""
97+
98+
pts = set(points)
99+
rects = []
100+
101+
while pts:
102+
# start a rectangle from one arbitrary point
103+
r0, c0 = min(pts)
104+
# expand right
105+
c1 = c0
106+
while (r0, c1 + 1) in pts:
107+
c1 += 1
108+
# expand downward as long as entire row segment is filled
109+
r1 = r0
110+
while all((r1 + 1, c) in pts for c in range(c0, c1 + 1)):
111+
r1 += 1
112+
113+
rects.append((r0, c0, r1, c1))
114+
# remove covered points
115+
for r in range(r0, r1 + 1):
116+
for c in range(c0, c1 + 1):
117+
pts.discard((r, c))
118+
119+
rects.sort()
120+
return rects
121+
122+
75123
class Cytation5Backend(ImageReaderBackend):
76124
"""Backend for biotek cytation 5 image reader.
77125
@@ -564,16 +612,14 @@ async def set_temperature(self, temperature: float):
564612
async def stop_heating_or_cooling(self):
565613
return await self.send_command("g", "00000")
566614

567-
def _parse_body(self, body: bytes) -> List[List[Optional[float]]]:
568-
start_index = body.index(b"01,01")
615+
def _parse_body(self, body: bytes) -> Dict[Tuple[int, int], float]:
616+
start_index = 22
569617
end_index = body.rindex(b"\r\n")
570618
num_rows = 8
571619
rows = body[start_index:end_index].split(b"\r\n,")[:num_rows]
572620

573621
assert self._plate is not None, "Plate must be set before reading data"
574-
parsed_data: List[List[Optional[float]]] = [
575-
[None for _ in range(self._plate.num_items_x)] for _ in range(self._plate.num_items_y)
576-
]
622+
parsed_data: Dict[Tuple[int, int], float] = {}
577623
for row in rows:
578624
values = row.split(b",")
579625
grouped_values = [values[i : i + 3] for i in range(0, len(values), 3)]
@@ -583,10 +629,20 @@ def _parse_body(self, body: bytes) -> List[List[Optional[float]]]:
583629
row_index = int(group[0].decode()) - 1 # 1-based index in the response
584630
column_index = int(group[1].decode()) - 1 # 1-based index in the response
585631
value = float(group[2].decode())
586-
parsed_data[row_index][column_index] = value
632+
parsed_data[(row_index, column_index)] = value
587633

588634
return parsed_data
589635

636+
def _data_dict_to_list(
637+
self, data: Dict[Tuple[int, int], float], plate: Plate
638+
) -> List[List[Optional[float]]]:
639+
result: List[List[Optional[float]]] = [
640+
[None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y)
641+
]
642+
for (row, col), value in data.items():
643+
result[row][col] = value
644+
return result
645+
590646
async def set_plate(self, plate: Plate):
591647
# 08120112207434014351135308559127881422
592648
# ^^^^ plate size z
@@ -640,24 +696,14 @@ async def set_plate(self, plate: Plate):
640696
self._plate = plate
641697
return resp
642698

643-
def _get_min_max_row_col(self, wells: List[Well], plate: Plate) -> Tuple[int, int, int, int]:
699+
def _get_min_max_row_col_tuples(
700+
self, wells: List[Well], plate: Plate
701+
) -> List[Tuple[int, int, int, int]]: # min_row, min_col, max_row, max_col
644702
# check if all wells are in the same plate
645703
plates = set(well.parent for well in wells)
646704
if len(plates) != 1 or plates.pop() != plate:
647705
raise ValueError("All wells must be in the specified plate")
648-
649-
# check if wells are in a grid
650-
rows = sorted(set(well.get_row() for well in wells))
651-
columns = sorted(set(well.get_column() for well in wells))
652-
min_row, max_row, min_col, max_col = rows[0], rows[-1], columns[0], columns[-1]
653-
if (
654-
(len(rows) * len(columns) != len(wells))
655-
or rows != list(range(min_row, max_row + 1))
656-
or columns != list(range(min_col, max_col + 1))
657-
):
658-
raise ValueError("Wells must be in a grid")
659-
660-
return min_row, max_row, min_col, max_col
706+
return _non_overlapping_rectangles((well.get_row(), well.get_column()) for well in wells)
661707

662708
async def read_absorbance(
663709
self, plate: Plate, wells: List[Well], wavelength: int
@@ -668,19 +714,22 @@ async def read_absorbance(
668714
await self.set_plate(plate)
669715

670716
wavelength_str = str(wavelength).zfill(4)
671-
min_row, max_row, min_col, max_col = self._get_min_max_row_col(wells, plate)
672-
cmd = f"004701{min_row+1:02}{min_col+1:02}{max_row+1:02}{max_col+1:02}000120010000110010000010600008{wavelength_str}1"
673-
checksum = str(sum(cmd.encode()) % 100).zfill(2)
674-
cmd = cmd + checksum + "\x03"
675-
await self.send_command("D", cmd)
717+
data: Dict[Tuple[int, int], float] = {}
676718

677-
resp = await self.send_command("O")
678-
assert resp == b"\x060000\x03"
719+
for min_row, min_col, max_row, max_col in self._get_min_max_row_col_tuples(wells, plate):
720+
cmd = f"004701{min_row+1:02}{min_col+1:02}{max_row+1:02}{max_col+1:02}000120010000110010000010600008{wavelength_str}1"
721+
checksum = str(sum(cmd.encode()) % 100).zfill(2)
722+
cmd = cmd + checksum + "\x03"
723+
await self.send_command("D", cmd)
679724

680-
# read data
681-
body = await self._read_until(b"\x03", timeout=60 * 3)
682-
assert resp is not None
683-
return self._parse_body(body)
725+
resp = await self.send_command("O")
726+
assert resp == b"\x060000\x03"
727+
728+
# read data
729+
body = await self._read_until(b"\x03", timeout=60 * 3)
730+
assert resp is not None
731+
data.update(self._parse_body(body))
732+
return self._data_dict_to_list(data, plate)
684733

685734
async def read_luminescence(
686735
self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 1
@@ -704,22 +753,23 @@ async def read_luminescence(
704753
integration_time_seconds_s = str(integration_time_seconds * 5).zfill(2)
705754
integration_time_milliseconds_s = str(int(float(integration_time_milliseconds * 50))).zfill(2)
706755

707-
min_row, max_row, min_col, max_col = self._get_min_max_row_col(wells, plate)
708-
709-
cmd = f"008401{min_row+1:02}{min_col+1:02}{max_row+1:02}{max_col+1:02}000120010000110010000012300{integration_time_seconds_s}{integration_time_milliseconds_s}200200-001000-003000000000000000000013510" # 0812
710-
checksum = str((sum(cmd.encode()) + 8) % 100).zfill(2) # don't know why +8
711-
cmd = cmd + checksum
712-
await self.send_command("D", cmd)
756+
data: Dict[Tuple[int, int], float] = {}
757+
for min_row, min_col, max_row, max_col in self._get_min_max_row_col_tuples(wells, plate):
758+
cmd = f"008401{min_row+1:02}{min_col+1:02}{max_row+1:02}{max_col+1:02}000120010000110010000012300{integration_time_seconds_s}{integration_time_milliseconds_s}200200-001000-003000000000000000000013510" # 0812
759+
checksum = str((sum(cmd.encode()) + 8) % 100).zfill(2) # don't know why +8
760+
cmd = cmd + checksum
761+
await self.send_command("D", cmd)
713762

714-
resp = await self.send_command("O")
715-
assert resp == b"\x060000\x03"
763+
resp = await self.send_command("O")
764+
assert resp == b"\x060000\x03"
716765

717-
# 2m10s of reading per 1 second of integration time
718-
# allow 60 seconds flat
719-
timeout = 60 + integration_time_seconds * (2 * 60 + 10)
720-
body = await self._read_until(b"\x03", timeout=timeout)
721-
assert body is not None
722-
return self._parse_body(body)
766+
# 2m10s of reading per 1 second of integration time
767+
# allow 60 seconds flat
768+
timeout = 60 + integration_time_seconds * (2 * 60 + 10)
769+
body = await self._read_until(b"\x03", timeout=timeout)
770+
assert body is not None
771+
data.update(self._parse_body(body))
772+
return self._data_dict_to_list(data, plate)
723773

724774
async def read_fluorescence(
725775
self,
@@ -743,21 +793,24 @@ async def read_fluorescence(
743793

744794
excitation_wavelength_str = str(excitation_wavelength).zfill(4)
745795
emission_wavelength_str = str(emission_wavelength).zfill(4)
746-
min_row, max_row, min_col, max_col = self._get_min_max_row_col(wells, plate)
747-
cmd = (
748-
f"008401{min_row+1:02}{min_col+1:02}{max_row+1:02}{max_col+1:02}0001200100001100100000135000100200200{excitation_wavelength_str}000"
749-
f"{emission_wavelength_str}000000000000000000210011"
750-
)
751-
checksum = str((sum(cmd.encode()) + 7) % 100).zfill(2) # don't know why +7
752-
cmd = cmd + checksum + "\x03"
753-
resp = await self.send_command("D", cmd)
754796

755-
resp = await self.send_command("O")
756-
assert resp == b"\x060000\x03"
797+
data: Dict[Tuple[int, int], float] = {}
798+
for min_row, min_col, max_row, max_col in self._get_min_max_row_col_tuples(wells, plate):
799+
cmd = (
800+
f"008401{min_row+1:02}{min_col+1:02}{max_row+1:02}{max_col+1:02}0001200100001100100000135000100200200{excitation_wavelength_str}000"
801+
f"{emission_wavelength_str}000000000000000000210011"
802+
)
803+
checksum = str((sum(cmd.encode()) + 7) % 100).zfill(2) # don't know why +7
804+
cmd = cmd + checksum + "\x03"
805+
resp = await self.send_command("D", cmd)
806+
807+
resp = await self.send_command("O")
808+
assert resp == b"\x060000\x03"
757809

758-
body = await self._read_until(b"\x03", timeout=60 * 2)
759-
assert body is not None
760-
return self._parse_body(body)
810+
body = await self._read_until(b"\x03", timeout=60 * 2)
811+
assert body is not None
812+
data.update(self._parse_body(body))
813+
return self._data_dict_to_list(data, plate)
761814

762815
async def _abort(self) -> None:
763816
await self.send_command("x", wait_for_response=False)

pylabrobot/plate_reading/biotek_tests.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,61 @@ async def test_read_absorbance(self):
183183
[0.1427, 0.1174, 0.0684, 0.0657, 0.0732, 0.067, 0.0602, 0.079, 0.0667, 0.1103, 0.129, 0.1316],
184184
]
185185

186+
async def test_read_luminescence_partial(self):
187+
self.backend.io.read.side_effect = _byte_iter(
188+
# plate
189+
"\x06"
190+
+ "\x03"
191+
# focal height
192+
+ "\x06"
193+
+ "\x03"
194+
# read block 1
195+
+ "\x06"
196+
+ "0350000000000000010000000000490300000\x03"
197+
+ "\x060000\x03"
198+
+ "01,1,\r000:00:00.0,237,01,01,0000003\r\n,02,01,0000003\r\n,03,01,0000005\r\n,04,01,0000004\r\n,05,01,0000003\r\n,06,01,0000002\r\n,07,01,0000000\r\n237\x1a132\x1a0000\x03"
199+
# read block 2
200+
+ "\x06"
201+
+ "0350000000000000010000000000030200000\x03"
202+
+ "\x060000\x03"
203+
+ "01,1,\r000:00:00.0,237,02,02,0000043,02,03,0000014\r\n,03,03,0000014,03,02,0000012\r\n,04,02,0000011,04,03,0000014\r\n,05,03,0000010,05,02,0000010\r\n,06,02,0000011,06,03,0000027\r\n,07,03,0000009,07,02,0000010\r\n237\x1a160\x1a0000\x03"
204+
# read block 3
205+
+ "\x06"
206+
+ "0350000000000000010000000000000170000\x03"
207+
+ "\x060000\x03"
208+
+ "01,1,\r000:00:00.0,237,04,04,0000018\r\n,05,04,0000017\r\n,06,04,0000014\r\n237\x1a210\x1a0000\x03"
209+
)
210+
211+
plate = CellVis_96_wellplate_350uL_Fb(name="plate")
212+
wells = plate["A1"] + plate["B1:G3"] + plate["D4:F4"]
213+
resp = await self.backend.read_luminescence(
214+
focal_height=4.5, integration_time=0.4, plate=plate, wells=wells
215+
)
216+
217+
print(self.backend.io.write.mock_calls)
218+
self.backend.io.write.assert_any_call(b"D")
219+
self.backend.io.write.assert_any_call(
220+
b"008401010107010001200100001100100000123000020200200-001000-00300000000000000000001351086"
221+
)
222+
self.backend.io.write.assert_any_call(
223+
b"008401020207030001200100001100100000123000020200200-001000-00300000000000000000001351090"
224+
)
225+
self.backend.io.write.assert_any_call(
226+
b"008401040406040001200100001100100000123000020200200-001000-00300000000000000000001351094"
227+
)
228+
self.backend.io.write.assert_any_call(b"O")
229+
230+
assert resp == [
231+
[3.0, None, None, None, None, None, None, None, None, None, None, None],
232+
[3.0, 43.0, 14.0, None, None, None, None, None, None, None, None, None],
233+
[5.0, 12.0, 14.0, None, None, None, None, None, None, None, None, None],
234+
[4.0, 11.0, 14.0, 18.0, None, None, None, None, None, None, None, None],
235+
[3.0, 10.0, 10.0, 17.0, None, None, None, None, None, None, None, None],
236+
[2.0, 11.0, 27.0, 14.0, None, None, None, None, None, None, None, None],
237+
[0.0, 10.0, 9.0, None, None, None, None, None, None, None, None, None],
238+
[None, None, None, None, None, None, None, None, None, None, None, None],
239+
]
240+
186241
async def test_read_fluorescence(self):
187242
self.backend.io.read.side_effect = _byte_iter(
188243
"\x06"

0 commit comments

Comments
 (0)