Skip to content

Commit 2a8c0a7

Browse files
committed
initial byonoy draft
1 parent 28e0313 commit 2a8c0a7

File tree

2 files changed

+230
-0
lines changed

2 files changed

+230
-0
lines changed

pylabrobot/plate_reading/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .biotek_backend import Cytation5Backend, Cytation5ImagingConfig
2+
from .byonoy import Byonoy
23
from .clario_star_backend import CLARIOStarBackend
34
from .image_reader import ImageReader
45
from .imager import Imager

pylabrobot/plate_reading/byonoy.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import asyncio
2+
import struct
3+
import threading
4+
import time
5+
from typing import List, Optional
6+
7+
from pylabrobot.io.hid import HID
8+
from pylabrobot.plate_reading.backend import PlateReaderBackend
9+
from pylabrobot.resources.plate import Plate
10+
11+
12+
class Byonoy(PlateReaderBackend):
13+
"""An abstract class for a plate reader. Plate readers are devices that can read luminescence,
14+
absorbance, or fluorescence from a plate."""
15+
16+
def __init__(self) -> None:
17+
self.io = HID(vid=0x16D0, pid=0x119B)
18+
self._background_thread: Optional[threading.Thread] = None
19+
self._stop_background = threading.Event()
20+
self._ping_interval = 1.0 # Send ping every second
21+
self._sending_pings = True # Whether to actively send pings
22+
23+
async def setup(self) -> None:
24+
"""Set up the plate reader. This should be called before any other methods."""
25+
26+
await self.io.setup()
27+
28+
# Start background keep alive messages
29+
self._stop_background.clear()
30+
self._background_thread = threading.Thread(target=self._background_ping_worker, daemon=True)
31+
self._background_thread.start()
32+
33+
async def stop(self) -> None:
34+
"""Close all connections to the plate reader and make sure setup() can be called again."""
35+
36+
# Stop background keep alive messages
37+
self._stop_background.set()
38+
if self._background_thread and self._background_thread.is_alive():
39+
self._background_thread.join(timeout=2.0)
40+
41+
await self.io.stop()
42+
43+
def _background_ping_worker(self) -> None:
44+
"""Background worker that sends periodic ping commands."""
45+
loop = asyncio.new_event_loop()
46+
asyncio.set_event_loop(loop)
47+
48+
try:
49+
loop.run_until_complete(self._ping_loop())
50+
finally:
51+
loop.close()
52+
53+
async def _ping_loop(self) -> None:
54+
"""Main ping loop that runs in the background thread."""
55+
while not self._stop_background.is_set():
56+
try:
57+
# Only send ping if pings are enabled
58+
if self._sending_pings:
59+
# Send ping command
60+
cmd = "40000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008040"
61+
print("> " + cmd + " (background ping)")
62+
await self.io.write(bytes.fromhex(cmd))
63+
except Exception as e:
64+
print(f"Error in background ping: {e}")
65+
66+
# Wait for the ping interval or until stop is requested
67+
self._stop_background.wait(self._ping_interval)
68+
69+
def _start_background_pings(self) -> None:
70+
self._sending_pings = True
71+
72+
def _stop_background_pings(self) -> None:
73+
self._sending_pings = False
74+
75+
async def _read_until_empty(self, timeout=30):
76+
data = b""
77+
while True:
78+
chunk = await self.io.read(64, timeout=timeout)
79+
if not chunk:
80+
break
81+
else:
82+
print("< ", chunk.hex())
83+
data += chunk
84+
85+
if chunk.startswith(b"\x70"):
86+
await self.io.write(
87+
bytes.fromhex(
88+
"20007000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
89+
)
90+
)
91+
return data
92+
93+
async def _wait_for_response(self, timeout=30):
94+
time.sleep(1)
95+
data = b""
96+
t0 = time.time()
97+
while True:
98+
data += await self._read_until_empty(timeout=timeout - (time.time() - t0))
99+
if len(data) > 64:
100+
break
101+
if time.time() - t0 > timeout:
102+
print("Timeout waiting for response")
103+
return data
104+
time.sleep(0.1)
105+
return data
106+
107+
async def open(self) -> None:
108+
raise NotImplementedError(
109+
"byonoy cannot open by itself. you need to move the top module using a robot arm."
110+
)
111+
112+
async def close(self, plate: Optional[Plate]) -> None:
113+
raise NotImplementedError(
114+
"byonoy cannot close by itself. you need to move the top module using a robot arm."
115+
)
116+
117+
async def read_luminescence(self, plate: Plate, focal_height: float) -> List[List[float]]:
118+
"""Read the luminescence from the plate reader. This should return a list of lists, where the
119+
outer list is the columns of the plate and the inner list is the rows of the plate."""
120+
121+
# TODO: confirm that this particular device can read luminescence
122+
123+
await self.io.write(
124+
bytes.fromhex(
125+
"10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040"
126+
)
127+
)
128+
await self.io.write(
129+
bytes.fromhex(
130+
"50000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040"
131+
)
132+
)
133+
await self.io.write(
134+
bytes.fromhex(
135+
"00020700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008040"
136+
)
137+
)
138+
await self.io.write(
139+
bytes.fromhex(
140+
"40000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040"
141+
)
142+
)
143+
await self.io.write(
144+
bytes.fromhex(
145+
"400380841e00ffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040"
146+
)
147+
)
148+
await self.io.write(
149+
bytes.fromhex(
150+
"40000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008040"
151+
)
152+
)
153+
154+
t0 = time.time()
155+
r = b""
156+
reading_data = False
157+
data = b""
158+
159+
while True:
160+
# read for 2 minutes max
161+
if time.time() - t0 > 120:
162+
break
163+
164+
chunk = await self._read_until_empty(timeout=30)
165+
166+
if (
167+
bytes.fromhex(
168+
"30000000000034000000526573756c74732020546f7020526561646f75740a0a0000000000000000000000000000000000000000000000000000000000000000"
169+
)
170+
in chunk
171+
):
172+
print("Received result")
173+
reading_data = True
174+
self._stop_background_pings()
175+
176+
if reading_data:
177+
data += chunk
178+
179+
if b"Finished in " in chunk:
180+
break
181+
182+
cmd = "40000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040"
183+
print("> " + cmd)
184+
await self.io.write(bytes.fromhex(cmd))
185+
186+
self._start_background_pings()
187+
188+
# split data in 64 byte chunks
189+
data_chunks = [data[i : i + 64] for i in range(0, len(data), 64)]
190+
191+
measurements = []
192+
reading_measurements = False
193+
read_n_data_packets = 0
194+
for line in data_chunks:
195+
if reading_measurements:
196+
segment = line
197+
measurements.append(segment)
198+
read_n_data_packets += 1
199+
200+
if b"hybrid result" in line:
201+
reading_measurements = True
202+
203+
if read_n_data_packets == 8:
204+
break
205+
206+
floats = [
207+
f
208+
for line in [l[12:-4] for l in measurements]
209+
for f in struct.unpack("f" * (len(line) // 4), line)
210+
]
211+
return floats
212+
213+
async def read_absorbance(self, plate: Plate, wavelength: int) -> List[List[float]]:
214+
"""Read the absorbance from the plate reader. This should return a list of lists, where the
215+
outer list is the columns of the plate and the inner list is the rows of the plate."""
216+
217+
# TODO: confirm that this particular device can read absorbance
218+
219+
async def read_fluorescence(
220+
self,
221+
plate: Plate,
222+
excitation_wavelength: int,
223+
emission_wavelength: int,
224+
focal_height: float,
225+
) -> List[List[float]]:
226+
"""Read the fluorescence from the plate reader. This should return a list of lists, where the
227+
outer list is the columns of the plate and the inner list is the rows of the plate."""
228+
229+
raise NotImplementedError("byonoy does not support fluorescence reading.")

0 commit comments

Comments
 (0)