Skip to content

Commit 07a0821

Browse files
authored
molecular devices plate readers (#715)
1 parent 5ba7efc commit 07a0821

11 files changed

+2405
-77
lines changed

docs/user_guide/machines.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ tr > td:nth-child(5) { width: 15%; }
3333
.badge-absorbance { background: #ffe6cc; }
3434
.badge-fluorescence { background: #e0ffcc; }
3535
.badge-luminescence { background: #e6e6ff; }
36+
.badge-time-resolved-fluo { background: #ddffdd; }
37+
.badge-fluo-polarization { background: #ddf2ffff; }
3638
</style>
3739
```
3840

@@ -149,7 +151,8 @@ tr > td:nth-child(5) { width: 15%; }
149151
| Agilent (BioTek) | Cytation 5 | <span class="badge badge-absorbance">absorbance</span><span class="badge badge-fluorescence">fluorescence</span><span class="badge badge-luminescence">luminescence</span><span class="badge badge-microscopy">microscopy</span> | Full | [PLR](https://docs.pylabrobot.org/user_guide/02_analytical/plate-reading/cytation5.html) / [OEM](https://www.agilent.com/en/product/cell-analysis/cell-imaging-microscopy/cell-imaging-multimode-readers/biotek-cytation-5-cell-imaging-multimode-reader-1623202) |
150152
| Byonoy | Absorbance 96 Automate | <span class="badge badge-absorbance">absorbance</span> | WIP | [OEM](https://byonoy.com/absorbance-96-automate/) |
151153
| Byonoy | Luminescence 96 Automate | <span class="badge badge-luminescence">luminescence</span> | WIP | [OEM](https://byonoy.com/luminescence-96-automate/) |
152-
154+
| Molecular Devices | SpectraMax M5e | <span class="badge badge-absorbance">absorbance</span><span class="badge badge-fluorescence">fluorescence</span> <span class="badge badge-time-resolved-fluo">time-resolved fluorescence</span><span class="badge badge-fluo-polarization">fluorescence polarization</span> | Full | [OEM] (https://www.moleculardevices.com/products/microplate-readers/multi-mode-readers/spectramax-m-series-readers) |
155+
| Molecular Devices | SpectraMax 384plus | <span class="badge badge-absorbance">absorbance</span> | Full | [OEM] (https://www.moleculardevices.com/products/microplate-readers/absorbance-readers/spectramax-abs-plate-readers) |
153156
### Flow Cytometers
154157

155158
| Manufacturer | Machine | PLR-Support | Links |

pylabrobot/plate_reading/backend.py

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from abc import ABCMeta, abstractmethod
4-
from typing import List, Optional
4+
from typing import Dict, List, Optional
55

66
from pylabrobot.machines.backend import MachineBackend
77
from pylabrobot.plate_reading.standard import (
@@ -39,16 +39,27 @@ async def close(self, plate: Optional[Plate]) -> None:
3939
@abstractmethod
4040
async def read_luminescence(
4141
self, plate: Plate, wells: List[Well], focal_height: float
42-
) -> List[List[Optional[float]]]:
43-
"""Read the luminescence from the plate reader. This should return a list of lists, where the
44-
outer list is the columns of the plate and the inner list is the rows of the plate."""
42+
) -> List[Dict]:
43+
"""Read the luminescence from the plate reader.
44+
45+
Returns:
46+
A list of dictionaries, one for each measurement. Each dictionary contains:
47+
"time": float,
48+
"temperature": float,
49+
"data": List[List[float]]
50+
"""
4551

4652
@abstractmethod
47-
async def read_absorbance(
48-
self, plate: Plate, wells: List[Well], wavelength: int
49-
) -> List[List[Optional[float]]]:
50-
"""Read the absorbance from the plate reader. This should return a list of lists, where the
51-
outer list is the columns of the plate and the inner list is the rows of the plate."""
53+
async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]:
54+
"""Read the absorbance from the plate reader.
55+
56+
Returns:
57+
A list of dictionaries, one for each measurement. Each dictionary contains:
58+
"wavelength": int,
59+
"time": float,
60+
"temperature": float,
61+
"data": List[List[float]]
62+
"""
5263

5364
@abstractmethod
5465
async def read_fluorescence(
@@ -58,9 +69,17 @@ async def read_fluorescence(
5869
excitation_wavelength: int,
5970
emission_wavelength: int,
6071
focal_height: float,
61-
) -> List[List[Optional[float]]]:
62-
"""Read the fluorescence from the plate reader. This should return a list of lists, where the
63-
outer list is the columns of the plate and the inner list is the rows of the plate."""
72+
) -> List[Dict]:
73+
"""Read the fluorescence from the plate reader.
74+
75+
Returns:
76+
A list of dictionaries, one for each measurement. Each dictionary contains:
77+
"ex_wavelength": int,
78+
"em_wavelength": int,
79+
"time": float,
80+
"temperature": float,
81+
"data": List[List[float]]
82+
"""
6483

6584

6685
class ImagerBackend(MachineBackend, metaclass=ABCMeta):

pylabrobot/plate_reading/biotek_backend.py

Lines changed: 82 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -610,13 +610,14 @@ async def set_temperature(self, temperature: float):
610610
async def stop_heating_or_cooling(self):
611611
return await self.send_command("g", "00000")
612612

613-
def _parse_body(self, body: bytes) -> Dict[Tuple[int, int], float]:
613+
def _parse_body(self, body: bytes) -> List[List[Optional[float]]]:
614+
assert self._plate is not None, "Plate must be set before reading data"
615+
plate = self._plate
614616
start_index = 22
615617
end_index = body.rindex(b"\r\n")
616-
num_rows = 8
618+
num_rows = plate.num_items_y
617619
rows = body[start_index:end_index].split(b"\r\n,")[:num_rows]
618620

619-
assert self._plate is not None, "Plate must be set before reading data"
620621
parsed_data: Dict[Tuple[int, int], float] = {}
621622
for row in rows:
622623
values = row.split(b",")
@@ -629,16 +630,11 @@ def _parse_body(self, body: bytes) -> Dict[Tuple[int, int], float]:
629630
value = float(group[2].decode())
630631
parsed_data[(row_index, column_index)] = value
631632

632-
return parsed_data
633-
634-
def _data_dict_to_list(
635-
self, data: Dict[Tuple[int, int], float], plate: Plate
636-
) -> List[List[Optional[float]]]:
637633
result: List[List[Optional[float]]] = [
638634
[None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y)
639635
]
640-
for (row, col), value in data.items():
641-
result[row][col] = value
636+
for (row_idx, col_idx), value in parsed_data.items():
637+
result[row_idx][col_idx] = value
642638
return result
643639

644640
async def set_plate(self, plate: Plate):
@@ -703,16 +699,16 @@ def _get_min_max_row_col_tuples(
703699
raise ValueError("All wells must be in the specified plate")
704700
return _non_overlapping_rectangles((well.get_row(), well.get_column()) for well in wells)
705701

706-
async def read_absorbance(
707-
self, plate: Plate, wells: List[Well], wavelength: int
708-
) -> List[List[Optional[float]]]:
702+
async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]:
709703
if not 230 <= wavelength <= 999:
710704
raise ValueError("Wavelength must be between 230 and 999")
711705

712706
await self.set_plate(plate)
713707

714708
wavelength_str = str(wavelength).zfill(4)
715-
data: Dict[Tuple[int, int], float] = {}
709+
all_data: List[List[Optional[float]]] = [
710+
[None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y)
711+
]
716712

717713
for min_row, min_col, max_row, max_col in self._get_min_max_row_col_tuples(wells, plate):
718714
cmd = f"004701{min_row+1:02}{min_col+1:02}{max_row+1:02}{max_col+1:02}000120010000110010000010600008{wavelength_str}1"
@@ -725,13 +721,32 @@ async def read_absorbance(
725721

726722
# read data
727723
body = await self._read_until(b"\x03", timeout=60 * 3)
728-
assert resp is not None
729-
data.update(self._parse_body(body))
730-
return self._data_dict_to_list(data, plate)
724+
assert body is not None
725+
parsed_data = self._parse_body(body)
726+
# Merge data
727+
for r in range(plate.num_items_y):
728+
for c in range(plate.num_items_x):
729+
if parsed_data[r][c] is not None:
730+
all_data[r][c] = parsed_data[r][c]
731+
732+
# Get current temperature
733+
try:
734+
temp = await self.get_current_temperature()
735+
except TimeoutError:
736+
temp = float("nan")
737+
738+
return [
739+
{
740+
"wavelength": wavelength,
741+
"data": all_data,
742+
"temperature": temp,
743+
"time": time.time(),
744+
}
745+
]
731746

732747
async def read_luminescence(
733748
self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 1
734-
) -> List[List[Optional[float]]]:
749+
) -> List[Dict]:
735750
if not 4.5 <= focal_height <= 13.88:
736751
raise ValueError("Focal height must be between 4.5 and 13.88")
737752

@@ -751,7 +766,9 @@ async def read_luminescence(
751766
integration_time_seconds_s = str(integration_time_seconds * 5).zfill(2)
752767
integration_time_milliseconds_s = str(int(float(integration_time_milliseconds * 50))).zfill(2)
753768

754-
data: Dict[Tuple[int, int], float] = {}
769+
all_data: List[List[Optional[float]]] = [
770+
[None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y)
771+
]
755772
for min_row, min_col, max_row, max_col in self._get_min_max_row_col_tuples(wells, plate):
756773
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
757774
checksum = str((sum(cmd.encode()) + 8) % 100).zfill(2) # don't know why +8
@@ -766,8 +783,26 @@ async def read_luminescence(
766783
timeout = 60 + integration_time_seconds * (2 * 60 + 10)
767784
body = await self._read_until(b"\x03", timeout=timeout)
768785
assert body is not None
769-
data.update(self._parse_body(body))
770-
return self._data_dict_to_list(data, plate)
786+
parsed_data = self._parse_body(body)
787+
# Merge data
788+
for r in range(plate.num_items_y):
789+
for c in range(plate.num_items_x):
790+
if parsed_data[r][c] is not None:
791+
all_data[r][c] = parsed_data[r][c]
792+
793+
# Get current temperature
794+
try:
795+
temp = await self.get_current_temperature()
796+
except TimeoutError:
797+
temp = float("nan")
798+
799+
return [
800+
{
801+
"data": all_data,
802+
"temperature": temp,
803+
"time": time.time(),
804+
}
805+
]
771806

772807
async def read_fluorescence(
773808
self,
@@ -776,7 +811,7 @@ async def read_fluorescence(
776811
excitation_wavelength: int,
777812
emission_wavelength: int,
778813
focal_height: float,
779-
) -> List[List[Optional[float]]]:
814+
) -> List[Dict]:
780815
if not 4.5 <= focal_height <= 13.88:
781816
raise ValueError("Focal height must be between 4.5 and 13.88")
782817
if not 250 <= excitation_wavelength <= 700:
@@ -792,7 +827,9 @@ async def read_fluorescence(
792827
excitation_wavelength_str = str(excitation_wavelength).zfill(4)
793828
emission_wavelength_str = str(emission_wavelength).zfill(4)
794829

795-
data: Dict[Tuple[int, int], float] = {}
830+
all_data: List[List[Optional[float]]] = [
831+
[None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y)
832+
]
796833
for min_row, min_col, max_row, max_col in self._get_min_max_row_col_tuples(wells, plate):
797834
cmd = (
798835
f"008401{min_row+1:02}{min_col+1:02}{max_row+1:02}{max_col+1:02}0001200100001100100000135000100200200{excitation_wavelength_str}000"
@@ -807,8 +844,28 @@ async def read_fluorescence(
807844

808845
body = await self._read_until(b"\x03", timeout=60 * 2)
809846
assert body is not None
810-
data.update(self._parse_body(body))
811-
return self._data_dict_to_list(data, plate)
847+
parsed_data = self._parse_body(body)
848+
# Merge data
849+
for r in range(plate.num_items_y):
850+
for c in range(plate.num_items_x):
851+
if parsed_data[r][c] is not None:
852+
all_data[r][c] = parsed_data[r][c]
853+
854+
# Get current temperature
855+
try:
856+
temp = await self.get_current_temperature()
857+
except TimeoutError:
858+
temp = float("nan")
859+
860+
return [
861+
{
862+
"ex_wavelength": excitation_wavelength,
863+
"em_wavelength": emission_wavelength,
864+
"data": all_data,
865+
"temperature": temp,
866+
"time": time.time(),
867+
}
868+
]
812869

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

pylabrobot/plate_reading/biotek_tests.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ async def asyncSetUp(self):
3434
self.backend.io.set_rts = unittest.mock.AsyncMock()
3535
self.plate = CellVis_24_wellplate_3600uL_Fb(name="plate")
3636

37+
# Mock time.time() to control the timestamp in the results
38+
self.mock_time = unittest.mock.patch("time.time", return_value=12345.6789).start()
39+
self.addCleanup(self.mock_time.stop)
40+
3741
async def test_setup(self):
3842
self.backend.io.read.side_effect = _byte_iter("\x061650200 Version 1.04 0000\x03")
3943
await self.backend.setup()
@@ -94,6 +98,7 @@ async def test_read_absorbance(self):
9498
"0.0670,08,05,+0.0732,08,04,+0.0657,08,03,+0.0684,08,02,+0.1174,08,01,+0.1427\r\n228\x1a0"
9599
"41\x1a0000\x03"
96100
)
101+
+ "\x062360000\x03" # Temperature call
97102
)
98103

99104
plate = CellVis_96_wellplate_350uL_Fb(name="plate")
@@ -107,7 +112,7 @@ async def test_read_absorbance(self):
107112
)
108113
self.backend.io.write.assert_any_call(b"O")
109114

110-
assert resp == [
115+
expected_data = [
111116
[
112117
0.1917,
113118
0.1225,
@@ -182,6 +187,17 @@ async def test_read_absorbance(self):
182187
[0.1255, 0.0742, 0.0747, 0.0694, 0.1004, 0.09, 0.0659, 0.0858, 0.0876, 0.0815, 0.098, 0.1329],
183188
[0.1427, 0.1174, 0.0684, 0.0657, 0.0732, 0.067, 0.0602, 0.079, 0.0667, 0.1103, 0.129, 0.1316],
184189
]
190+
self.assertEqual(
191+
resp,
192+
[
193+
{
194+
"wavelength": 580,
195+
"data": expected_data,
196+
"temperature": 23.6,
197+
"time": 12345.6789,
198+
}
199+
],
200+
)
185201

186202
async def test_read_luminescence_partial(self):
187203
self.backend.io.read.side_effect = _byte_iter(
@@ -206,6 +222,7 @@ async def test_read_luminescence_partial(self):
206222
+ "0350000000000000010000000000000170000\x03"
207223
+ "\x060000\x03"
208224
+ "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"
225+
+ "\x062360000\x03" # Temperature call
209226
)
210227

211228
plate = CellVis_96_wellplate_350uL_Fb(name="plate")
@@ -214,7 +231,6 @@ async def test_read_luminescence_partial(self):
214231
focal_height=4.5, integration_time=0.4, plate=plate, wells=wells
215232
)
216233

217-
print(self.backend.io.write.mock_calls)
218234
self.backend.io.write.assert_any_call(b"D")
219235
self.backend.io.write.assert_any_call(
220236
b"008401010107010001200100001100100000123000020200200-001000-00300000000000000000001351086"
@@ -227,7 +243,7 @@ async def test_read_luminescence_partial(self):
227243
)
228244
self.backend.io.write.assert_any_call(b"O")
229245

230-
assert resp == [
246+
expected_data = [
231247
[3.0, None, None, None, None, None, None, None, None, None, None, None],
232248
[3.0, 43.0, 14.0, None, None, None, None, None, None, None, None, None],
233249
[5.0, 12.0, 14.0, None, None, None, None, None, None, None, None, None],
@@ -237,6 +253,16 @@ async def test_read_luminescence_partial(self):
237253
[0.0, 10.0, 9.0, None, None, None, None, None, None, None, None, None],
238254
[None, None, None, None, None, None, None, None, None, None, None, None],
239255
]
256+
self.assertEqual(
257+
resp,
258+
[
259+
{
260+
"data": expected_data,
261+
"temperature": 23.6,
262+
"time": 12345.6789,
263+
}
264+
],
265+
)
240266

241267
async def test_read_fluorescence(self):
242268
self.backend.io.read.side_effect = _byte_iter(
@@ -266,6 +292,7 @@ async def test_read_fluorescence(self):
266292
",0000607,08,10,0003002,08,09,0000900,08,08,0000697,08,07,0000542,08,06,0000688,08,05,0000"
267293
"622,08,04,0000555,08,03,0000542,08,02,0000742,08,01,0001118\r\n228\x1a091\x1a0000\x03"
268294
)
295+
+ "\x062360000\x03" # Temperature call
269296
)
270297

271298
plate = CellVis_96_wellplate_350uL_Fb(name="plate")
@@ -286,7 +313,7 @@ async def test_read_fluorescence(self):
286313
)
287314
self.backend.io.write.assert_any_call(b"O")
288315

289-
assert resp == [
316+
expected_data = [
290317
[427.0, 746.0, 598.0, 742.0, 1516.0, 704.0, 676.0, 734.0, 1126.0, 790.0, 531.0, 531.0],
291318
[462.0, 2187.0, 501.0, 465.0, 576.0, 484.0, 731.0, 891.0, 629.0, 618.0, 541.0, 2066.0],
292319
[728.0, 583.0, 472.0, 492.0, 501.0, 491.0, 580.0, 541.0, 556.0, 474.0, 532.0, 522.0],
@@ -296,3 +323,15 @@ async def test_read_fluorescence(self):
296323
[653.0, 783.0, 522.0, 536.0, 673.0, 858.0, 526.0, 627.0, 574.0, 1993.0, 712.0, 970.0],
297324
[1118.0, 742.0, 542.0, 555.0, 622.0, 688.0, 542.0, 697.0, 900.0, 3002.0, 607.0, 523.0],
298325
]
326+
self.assertEqual(
327+
resp,
328+
[
329+
{
330+
"ex_wavelength": 485,
331+
"em_wavelength": 528,
332+
"data": expected_data,
333+
"temperature": 23.6,
334+
"time": 12345.6789,
335+
}
336+
],
337+
)

0 commit comments

Comments
 (0)