Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/user_guide/machines.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ tr > td:nth-child(5) { width: 15%; }
.badge-absorbance { background: #ffe6cc; }
.badge-fluorescence { background: #e0ffcc; }
.badge-luminescence { background: #e6e6ff; }
.badge-time-resolved-fluo { background: #ddffdd; }
.badge-fluo-polarization { background: #ddf2ffff; }
</style>
```

Expand Down Expand Up @@ -149,7 +151,8 @@ tr > td:nth-child(5) { width: 15%; }
| 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) |
| Byonoy | Absorbance 96 Automate | <span class="badge badge-absorbance">absorbance</span> | WIP | [OEM](https://byonoy.com/absorbance-96-automate/) |
| Byonoy | Luminescence 96 Automate | <span class="badge badge-luminescence">luminescence</span> | WIP | [OEM](https://byonoy.com/luminescence-96-automate/) |

| 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) |
| 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) |
### Flow Cytometers

| Manufacturer | Machine | PLR-Support | Links |
Expand Down
43 changes: 31 additions & 12 deletions pylabrobot/plate_reading/backend.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from abc import ABCMeta, abstractmethod
from typing import List, Optional
from typing import Dict, List, Optional

from pylabrobot.machines.backend import MachineBackend
from pylabrobot.plate_reading.standard import (
Expand Down Expand Up @@ -39,16 +39,27 @@ async def close(self, plate: Optional[Plate]) -> None:
@abstractmethod
async def read_luminescence(
self, plate: Plate, wells: List[Well], focal_height: float
) -> List[List[Optional[float]]]:
"""Read the luminescence from the plate reader. This should return a list of lists, where the
outer list is the columns of the plate and the inner list is the rows of the plate."""
) -> List[Dict]:
"""Read the luminescence from the plate reader.

Returns:
A list of dictionaries, one for each measurement. Each dictionary contains:
"time": float,
"temperature": float,
"data": List[List[float]]
"""

@abstractmethod
async def read_absorbance(
self, plate: Plate, wells: List[Well], wavelength: int
) -> List[List[Optional[float]]]:
"""Read the absorbance from the plate reader. This should return a list of lists, where the
outer list is the columns of the plate and the inner list is the rows of the plate."""
async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]:
"""Read the absorbance from the plate reader.

Returns:
A list of dictionaries, one for each measurement. Each dictionary contains:
"wavelength": int,
"time": float,
"temperature": float,
"data": List[List[float]]
"""

@abstractmethod
async def read_fluorescence(
Expand All @@ -58,9 +69,17 @@ async def read_fluorescence(
excitation_wavelength: int,
emission_wavelength: int,
focal_height: float,
) -> List[List[Optional[float]]]:
"""Read the fluorescence from the plate reader. This should return a list of lists, where the
outer list is the columns of the plate and the inner list is the rows of the plate."""
) -> List[Dict]:
"""Read the fluorescence from the plate reader.

Returns:
A list of dictionaries, one for each measurement. Each dictionary contains:
"ex_wavelength": int,
"em_wavelength": int,
"time": float,
"temperature": float,
"data": List[List[float]]
"""


class ImagerBackend(MachineBackend, metaclass=ABCMeta):
Expand Down
107 changes: 82 additions & 25 deletions pylabrobot/plate_reading/biotek_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -610,13 +610,14 @@ async def set_temperature(self, temperature: float):
async def stop_heating_or_cooling(self):
return await self.send_command("g", "00000")

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

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

return parsed_data

def _data_dict_to_list(
self, data: Dict[Tuple[int, int], float], plate: Plate
) -> List[List[Optional[float]]]:
result: List[List[Optional[float]]] = [
[None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y)
]
for (row, col), value in data.items():
result[row][col] = value
for (row_idx, col_idx), value in parsed_data.items():
result[row_idx][col_idx] = value
return result

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

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

await self.set_plate(plate)

wavelength_str = str(wavelength).zfill(4)
data: Dict[Tuple[int, int], float] = {}
all_data: List[List[Optional[float]]] = [
[None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y)
]

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

# read data
body = await self._read_until(b"\x03", timeout=60 * 3)
assert resp is not None
data.update(self._parse_body(body))
return self._data_dict_to_list(data, plate)
assert body is not None
parsed_data = self._parse_body(body)
# Merge data
for r in range(plate.num_items_y):
for c in range(plate.num_items_x):
if parsed_data[r][c] is not None:
all_data[r][c] = parsed_data[r][c]

# Get current temperature
try:
temp = await self.get_current_temperature()
except TimeoutError:
temp = float("nan")

return [
{
"wavelength": wavelength,
"data": all_data,
"temperature": temp,
"time": time.time(),
}
]

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

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

data: Dict[Tuple[int, int], float] = {}
all_data: List[List[Optional[float]]] = [
[None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y)
]
for min_row, min_col, max_row, max_col in self._get_min_max_row_col_tuples(wells, plate):
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
checksum = str((sum(cmd.encode()) + 8) % 100).zfill(2) # don't know why +8
Expand All @@ -766,8 +783,26 @@ async def read_luminescence(
timeout = 60 + integration_time_seconds * (2 * 60 + 10)
body = await self._read_until(b"\x03", timeout=timeout)
assert body is not None
data.update(self._parse_body(body))
return self._data_dict_to_list(data, plate)
parsed_data = self._parse_body(body)
# Merge data
for r in range(plate.num_items_y):
for c in range(plate.num_items_x):
if parsed_data[r][c] is not None:
all_data[r][c] = parsed_data[r][c]

# Get current temperature
try:
temp = await self.get_current_temperature()
except TimeoutError:
temp = float("nan")

return [
{
"data": all_data,
"temperature": temp,
"time": time.time(),
}
]

async def read_fluorescence(
self,
Expand All @@ -776,7 +811,7 @@ async def read_fluorescence(
excitation_wavelength: int,
emission_wavelength: int,
focal_height: float,
) -> List[List[Optional[float]]]:
) -> List[Dict]:
if not 4.5 <= focal_height <= 13.88:
raise ValueError("Focal height must be between 4.5 and 13.88")
if not 250 <= excitation_wavelength <= 700:
Expand All @@ -792,7 +827,9 @@ async def read_fluorescence(
excitation_wavelength_str = str(excitation_wavelength).zfill(4)
emission_wavelength_str = str(emission_wavelength).zfill(4)

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

body = await self._read_until(b"\x03", timeout=60 * 2)
assert body is not None
data.update(self._parse_body(body))
return self._data_dict_to_list(data, plate)
parsed_data = self._parse_body(body)
# Merge data
for r in range(plate.num_items_y):
for c in range(plate.num_items_x):
if parsed_data[r][c] is not None:
all_data[r][c] = parsed_data[r][c]

# Get current temperature
try:
temp = await self.get_current_temperature()
except TimeoutError:
temp = float("nan")

return [
{
"ex_wavelength": excitation_wavelength,
"em_wavelength": emission_wavelength,
"data": all_data,
"temperature": temp,
"time": time.time(),
}
]

async def _abort(self) -> None:
await self.send_command("x", wait_for_response=False)
Expand Down
47 changes: 43 additions & 4 deletions pylabrobot/plate_reading/biotek_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ async def asyncSetUp(self):
self.backend.io.set_rts = unittest.mock.AsyncMock()
self.plate = CellVis_24_wellplate_3600uL_Fb(name="plate")

# Mock time.time() to control the timestamp in the results
self.mock_time = unittest.mock.patch("time.time", return_value=12345.6789).start()
self.addCleanup(self.mock_time.stop)

async def test_setup(self):
self.backend.io.read.side_effect = _byte_iter("\x061650200 Version 1.04 0000\x03")
await self.backend.setup()
Expand Down Expand Up @@ -94,6 +98,7 @@ async def test_read_absorbance(self):
"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"
"41\x1a0000\x03"
)
+ "\x062360000\x03" # Temperature call
)

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

assert resp == [
expected_data = [
[
0.1917,
0.1225,
Expand Down Expand Up @@ -182,6 +187,17 @@ async def test_read_absorbance(self):
[0.1255, 0.0742, 0.0747, 0.0694, 0.1004, 0.09, 0.0659, 0.0858, 0.0876, 0.0815, 0.098, 0.1329],
[0.1427, 0.1174, 0.0684, 0.0657, 0.0732, 0.067, 0.0602, 0.079, 0.0667, 0.1103, 0.129, 0.1316],
]
self.assertEqual(
resp,
[
{
"wavelength": 580,
"data": expected_data,
"temperature": 23.6,
"time": 12345.6789,
}
],
)

async def test_read_luminescence_partial(self):
self.backend.io.read.side_effect = _byte_iter(
Expand All @@ -206,6 +222,7 @@ async def test_read_luminescence_partial(self):
+ "0350000000000000010000000000000170000\x03"
+ "\x060000\x03"
+ "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"
+ "\x062360000\x03" # Temperature call
)

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

print(self.backend.io.write.mock_calls)
self.backend.io.write.assert_any_call(b"D")
self.backend.io.write.assert_any_call(
b"008401010107010001200100001100100000123000020200200-001000-00300000000000000000001351086"
Expand All @@ -227,7 +243,7 @@ async def test_read_luminescence_partial(self):
)
self.backend.io.write.assert_any_call(b"O")

assert resp == [
expected_data = [
[3.0, None, None, None, None, None, None, None, None, None, None, None],
[3.0, 43.0, 14.0, None, None, None, None, None, None, None, None, None],
[5.0, 12.0, 14.0, None, None, None, None, None, None, None, None, None],
Expand All @@ -237,6 +253,16 @@ async def test_read_luminescence_partial(self):
[0.0, 10.0, 9.0, None, None, None, None, None, None, None, None, None],
[None, None, None, None, None, None, None, None, None, None, None, None],
]
self.assertEqual(
resp,
[
{
"data": expected_data,
"temperature": 23.6,
"time": 12345.6789,
}
],
)

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

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

assert resp == [
expected_data = [
[427.0, 746.0, 598.0, 742.0, 1516.0, 704.0, 676.0, 734.0, 1126.0, 790.0, 531.0, 531.0],
[462.0, 2187.0, 501.0, 465.0, 576.0, 484.0, 731.0, 891.0, 629.0, 618.0, 541.0, 2066.0],
[728.0, 583.0, 472.0, 492.0, 501.0, 491.0, 580.0, 541.0, 556.0, 474.0, 532.0, 522.0],
Expand All @@ -296,3 +323,15 @@ async def test_read_fluorescence(self):
[653.0, 783.0, 522.0, 536.0, 673.0, 858.0, 526.0, 627.0, 574.0, 1993.0, 712.0, 970.0],
[1118.0, 742.0, 542.0, 555.0, 622.0, 688.0, 542.0, 697.0, 900.0, 3002.0, 607.0, 523.0],
]
self.assertEqual(
resp,
[
{
"ex_wavelength": 485,
"em_wavelength": 528,
"data": expected_data,
"temperature": 23.6,
"time": 12345.6789,
}
],
)
Loading