Skip to content

Commit f3a7c98

Browse files
committed
absorbance
1 parent 6df2963 commit f3a7c98

File tree

1 file changed

+121
-30
lines changed

1 file changed

+121
-30
lines changed

pylabrobot/plate_reading/byonoy.py

Lines changed: 121 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class Byonoy(PlateReaderBackend):
1414
absorbance, or fluorescence from a plate."""
1515

1616
def __init__(self) -> None:
17-
self.io = HID(vid=0x16D0, pid=0x119B)
17+
self.io = HID(vid=0x16D0, pid=0x1199) # 16d0:119B for fluorescence
1818
self._background_thread: Optional[threading.Thread] = None
1919
self._stop_background = threading.Event()
2020
self._ping_interval = 1.0 # Send ping every second
@@ -53,13 +53,13 @@ def _background_ping_worker(self) -> None:
5353
async def _ping_loop(self) -> None:
5454
"""Main ping loop that runs in the background thread."""
5555
while not self._stop_background.is_set():
56-
# Only send ping if pings are enabled
5756
if self._sending_pings:
58-
# Send ping command
59-
cmd = "40000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008040"
57+
# TODO: are they the same?
58+
# cmd = "40000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008040" # fluor?
59+
cmd = "40000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040" # abs?
6060
await self.io.write(bytes.fromhex(cmd))
61+
# don't read in background thread, data might get lost here
6162

62-
# Wait for the ping interval or until stop is requested
6363
self._stop_background.wait(self._ping_interval)
6464

6565
def _start_background_pings(self) -> None:
@@ -89,8 +89,8 @@ async def _wait_for_response(self, timeout=30):
8989
data = b""
9090
t0 = time.time()
9191
while True:
92-
data += await self._read_until_empty(timeout=timeout - (time.time() - t0))
93-
if len(data) > 64:
92+
data += await self.io.read(64, timeout=timeout - (time.time() - t0))
93+
if len(data) >= 64:
9494
break
9595
if time.time() - t0 > timeout:
9696
raise TimeoutError("Timeout waiting for response")
@@ -107,6 +107,19 @@ async def close(self, plate: Optional[Plate]) -> None:
107107
"byonoy cannot close by itself. you need to move the top module using a robot arm."
108108
)
109109

110+
def _get_floats(self, data):
111+
"""Extract floats from a 8 * 64 byte chunk.
112+
Then for each 64 byte chunk, the first 12 and last 4 bytes are ignored,
113+
"""
114+
chunks64 = [data[i : i + 64] for i in range(0, len(data), 64)]
115+
floats = []
116+
for chunk in chunks64:
117+
float_bytes = chunk[12:-4] # fluor is 8?
118+
floats.extend(
119+
[struct.unpack("f", float_bytes[i : i + 4])[0] for i in range(0, len(float_bytes), 4)]
120+
)
121+
return floats
122+
110123
async def read_luminescence(self, plate: Plate, focal_height: float) -> List[List[float]]:
111124
"""Read the luminescence from the plate reader. This should return a list of lists, where the
112125
outer list is the columns of the plate and the inner list is the rows of the plate."""
@@ -191,36 +204,114 @@ async def read_luminescence(self, plate: Plate, focal_height: float) -> List[Lis
191204
below_breakdown_measurement_b,
192205
) = blobs
193206

194-
def get_floats(data):
195-
"""Extract floats from a 9 * 64 byte chunk.
196-
First 64 bytes are ignored.
197-
Then for each 64 byte chunk, the first 12 and lat 4 bytes are ignored,
198-
"""
199-
chunks64 = [data[i : i + 64] for i in range(0, len(data), 64)]
200-
floats = []
201-
for chunk in chunks64[1:]:
202-
float_bytes = chunk[12:-8]
203-
floats.extend(
204-
[struct.unpack("f", float_bytes[i : i + 4])[0] for i in range(0, len(float_bytes), 4)]
205-
)
206-
return floats
207-
208-
hybrid_result = get_floats(hybrid_result_b)
209-
_ = get_floats(counting_result_b)
210-
_ = get_floats(sampling_result_b)
211-
_ = get_floats(micro_counting_result_b) # don't know if they are floats
212-
_ = get_floats(micro_integration_result_b) # don't know if they are floats
213-
_ = get_floats(repetition_count_b)
214-
_ = get_floats(integration_time_b)
215-
_ = get_floats(below_breakdown_measurement_b)
207+
hybrid_result = self._get_floats(hybrid_result_b[64:])
208+
_ = self._get_floats(counting_result_b[64:])
209+
_ = self._get_floats(sampling_result_b[64:])
210+
_ = self._get_floats(micro_counting_result_b[64:]) # don't know if they are floats
211+
_ = self._get_floats(micro_integration_result_b[64:]) # don't know if they are floats
212+
_ = self._get_floats(repetition_count_b[64:])
213+
_ = self._get_floats(integration_time_b[64:])
214+
_ = self._get_floats(below_breakdown_measurement_b[64:])
216215

217216
return hybrid_result
218217

218+
async def send_command(self, command: bytes, wait_for_response: bool = True) -> Optional[bytes]:
219+
await self.io.write(command)
220+
if wait_for_response:
221+
response = b""
222+
223+
if command.startswith(bytes.fromhex("004000")):
224+
should_start = bytes.fromhex("0005")
225+
elif command.startswith(bytes.fromhex("002003")):
226+
should_start = bytes.fromhex("3000")
227+
else:
228+
should_start = command[1:3] # ignore the Report ID byte. FIXME
229+
230+
# responses that start with 0x20 are just status, we ignore those
231+
while len(response) == 0 or response.startswith(b"\x20"):
232+
response = await self.io.read(64, timeout=30)
233+
if len(response) == 0:
234+
continue
235+
236+
# if the first 2 bytes do not match, we continue reading
237+
if not response.startswith(should_start):
238+
response = b""
239+
continue
240+
return response
241+
242+
async def get_available_absorbance_wavelengths(self) -> List[float]:
243+
"""Get the available absorbance wavelengths from the plate reader. Assumes this plate reader can read absorbance."""
244+
245+
available_wavelengths_r = await self.send_command(
246+
bytes.fromhex(
247+
"0030030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008040"
248+
),
249+
wait_for_response=True,
250+
)
251+
assert available_wavelengths_r is not None, "Failed to get available wavelengths."
252+
# cut out the first 2 bytes, then read the next 2 bytes as an integer
253+
# 64 - 4 = 60. 60/2 = 30 16 bit integers
254+
assert available_wavelengths_r.startswith(bytes.fromhex("3003"))
255+
available_wavelengths = [
256+
struct.unpack("H", available_wavelengths_r[i : i + 2])[0]
257+
for i in range(2, 62, 2)
258+
]
259+
available_wavelengths = [w for w in available_wavelengths if w != 0]
260+
return available_wavelengths
261+
219262
async def read_absorbance(self, plate: Plate, wavelength: int) -> List[List[float]]:
220263
"""Read the absorbance from the plate reader. This should return a list of lists, where the
221264
outer list is the columns of the plate and the inner list is the rows of the plate."""
222265

223-
# TODO: confirm that this particular device can read absorbance
266+
await self.send_command(bytes.fromhex("0010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040"))
267+
await self.send_command(bytes.fromhex("0050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040"))
268+
await self.send_command(bytes.fromhex("0000020700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008040"))
269+
# above this it checks if absorbance is supported. which command?
270+
271+
available_wavelengths = await self.get_available_absorbance_wavelengths()
272+
if wavelength not in available_wavelengths:
273+
raise ValueError(
274+
f"Wavelength {wavelength} nm is not supported by this plate reader. "
275+
f"Available wavelengths: {available_wavelengths}"
276+
)
277+
278+
await self.send_command(bytes.fromhex("0000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040"))
279+
await self.send_command(bytes.fromhex("0020035802000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040"))
280+
# first b"Received REP_ABS_TRIGGER_MEASUREMENT_OUT" response
281+
await self.send_command(bytes.fromhex("0040000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008040"))
282+
await self.send_command(bytes.fromhex("0000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040"))
283+
await self.send_command(bytes.fromhex("0000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040"))
284+
await self.send_command(bytes.fromhex("0000020700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008040"))
285+
await self.send_command(bytes.fromhex("0020035802000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040"))
286+
await self.send_command(bytes.fromhex("0040000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008040"), wait_for_response=False)
287+
self._stop_background_pings()
288+
289+
t0 = time.time()
290+
data = b""
291+
292+
while True:
293+
# read for 2 minutes max
294+
if time.time() - t0 > 120:
295+
break
296+
297+
chunk = await self.io.read(64, timeout=30)
298+
data += chunk
299+
300+
if b"Slots" in chunk:
301+
break
302+
303+
await self.send_command(bytes.fromhex("40000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040"), wait_for_response=False)
304+
305+
self._start_background_pings()
306+
307+
# split into 64 byte chunks
308+
# get the 8 blobs that start with 0x0005
309+
# splitting and then joining is not a great pattern.
310+
blobs = [data[i : i + 64] for i in range(0, len(data), 64) if data[i:i+2] == b"\x00\x05"]
311+
if len(blobs) != 8:
312+
raise ValueError("Not enough blobs received. Expected 8, got {}".format(len(blobs)))
313+
floats = self._get_floats(b"".join(blobs))
314+
return floats
224315

225316
async def read_fluorescence(
226317
self,

0 commit comments

Comments
 (0)