Skip to content
Open
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
1,216 changes: 1,216 additions & 0 deletions docs/user_guide/02_analytical/plate-reading/byonoy.ipynb

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"\n",
"bmg-clariostar\n",
"cytation5\n",
"byonoy\n",
"```\n",
"\n",
"This example uses the `PlateReaderChatterboxBackend`. When using a real machine, use the corresponding backend."
Expand Down
59 changes: 56 additions & 3 deletions pylabrobot/io/hid.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,64 @@ def __init__(self, vid=0x03EB, pid=0x2023, serial_number: Optional[str] = None):
raise RuntimeError("Cannot create a new HID object while capture or validation is active")

async def setup(self):
"""
Sets up the HID device by enumerating connected devices, matching the specified
VID, PID, and optional serial number, and opening a connection to the device.
"""
if not USE_HID:
raise RuntimeError(
f"This backend requires the `hid` package to be installed. Import error: {_HID_IMPORT_ERROR}"
)
self.device = hid.Device(vid=self.vid, pid=self.pid, serial=self.serial_number)

# --- 1. Enumerate all HID devices ---
all_devices = hid.enumerate()
matching = [
d for d in all_devices if d.get("vendor_id") == self.vid and d.get("product_id") == self.pid
]

# --- 2. No devices found ---
if not matching:
raise RuntimeError(f"No HID devices found for VID=0x{self.vid:04X}, PID=0x{self.pid:04X}.")

# --- 3. Serial number specified: must match exactly 1 ---
if self.serial_number is not None:
matching_sn = [d for d in matching if d.get("serial_number") == self.serial_number]

if not matching_sn:
raise RuntimeError(
f"No HID devices found with VID=0x{self.vid:04X}, PID=0x{self.pid:04X}, "
f"serial={self.serial_number}."
)

if len(matching_sn) > 1:
# Extremely unlikely, but must follow serial semantics
raise RuntimeError(
f"Multiple HID devices found with identical serial number "
f"{self.serial_number} for VID/PID {self.vid}:{self.pid}. "
"Ambiguous; cannot continue."
)

chosen = matching_sn[0]

# --- 4. Serial number not specified: require exactly one device ---
else:
if len(matching) > 1:
raise RuntimeError(
f"Multiple HID devices detected for VID=0x{self.vid:04X}, "
f"PID=0x{self.pid:04X}.\n"
f"Serial numbers: {[d.get('serial_number') for d in matching]}\n"
"Please specify `serial_number=` explicitly."
)
chosen = matching[0]

# --- 5. Open the device ---
self.device = hid.Device(
path=chosen["path"] # safer than vid/pid/serial triple
)
self._executor = ThreadPoolExecutor(max_workers=1)

self.device_info = chosen

logger.log(LOG_LEVEL_IO, "Opened HID device %s", self._unique_id)
capturer.record(HIDCommand(device_id=self._unique_id, action="open", data=""))

Expand Down Expand Up @@ -107,8 +159,9 @@ def _read():
if self._executor is None:
raise RuntimeError("Call setup() first.")
r = await loop.run_in_executor(self._executor, _read)
logger.log(LOG_LEVEL_IO, "[%s] read %s", self._unique_id, r)
capturer.record(HIDCommand(device_id=self._unique_id, action="read", data=r.hex()))
if len(r.hex()) != 0:
logger.log(LOG_LEVEL_IO, "[%s] read %s", self._unique_id, r)
capturer.record(HIDCommand(device_id=self._unique_id, action="read", data=r.hex()))
return cast(bytes, r)

def serialize(self):
Expand Down
5 changes: 4 additions & 1 deletion pylabrobot/liquid_handling/liquid_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2109,6 +2109,9 @@ async def drop_resource(
raise RuntimeError("No resource picked up")
resource = self._resource_pickup.resource

if isinstance(destination, Resource):
destination.check_can_drop_resource_here(resource)

# compute rotation based on the pickup_direction and drop_direction
if self._resource_pickup.direction == direction:
rotation_applied_by_move = 0
Expand Down Expand Up @@ -2458,7 +2461,7 @@ async def move_plate(
**backend_kwargs,
)

def serialize(self):
def serialize(self) -> dict:
return {
**Resource.serialize(self),
**Machine.serialize(self),
Expand Down
6 changes: 6 additions & 0 deletions pylabrobot/plate_reading/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
from .biotek_backend import Cytation5Backend, Cytation5ImagingConfig
from .byonoy import (
ByonoyAbsorbance96AutomateBackend,
ByonoyLuminescence96AutomateBackend,
byonoy_absorbance96_base_and_reader,
byonoy_absorbance_adapter,
)
from .chatterbox import PlateReaderChatterboxBackend
from .clario_star_backend import CLARIOstarBackend
from .image_reader import ImageReader
Expand Down
2 changes: 2 additions & 0 deletions pylabrobot/plate_reading/byonoy/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .byonoy import byonoy_absorbance96_base_and_reader, byonoy_absorbance_adapter
from .byonoy_backend import ByonoyAbsorbance96AutomateBackend, ByonoyLuminescence96AutomateBackend
152 changes: 152 additions & 0 deletions pylabrobot/plate_reading/byonoy/byonoy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
from typing import Optional, Tuple

from pylabrobot.plate_reading.byonoy.byonoy_backend import ByonoyAbsorbance96AutomateBackend
from pylabrobot.plate_reading.plate_reader import PlateReader
from pylabrobot.resources import Coordinate, PlateHolder, Resource, ResourceHolder


def byonoy_absorbance_adapter(name: str) -> ResourceHolder:
return ResourceHolder(
name=name,
size_x=127.76, # measured
size_y=85.59, # measured
size_z=14.07, # measured
child_location=Coordinate(
x=-(138 - 127.76) / 2, # measured
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where does the 138 mm come from?

y=-(95.7 - 85.59) / 2, # measured
z=14.07 - 2.45, # measured
),
)


class _ByonoyAbsorbanceReaderPlateHolder(PlateHolder):
"""Custom plate holder that checks if the reader sits on the parent base.
This check is used to prevent crashes (moving plate onto holder while reader is on the base)."""

def __init__(
self,
name: str,
size_x: float,
size_y: float,
size_z: float,
pedestal_size_z: float = None, # type: ignore
child_location=Coordinate.zero(),
category="plate_holder",
model: Optional[str] = None,
):
super().__init__(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
pedestal_size_z=pedestal_size_z,
child_location=child_location,
category=category,
model=model,
)
self._byonoy_base: Optional["ByonoyBase"] = None

def check_can_drop_resource_here(self, resource: Resource) -> None:
if self._byonoy_base is None:
raise RuntimeError(
"ByonoyBase not assigned its plate holder. "
"Please assign a ByonoyBase instance to the plate holder."
)

if self._byonoy_base.reader_holder.resource is not None:
raise RuntimeError(
f"Cannot drop resource {resource.name} onto plate holder while reader is on the base. "
"Please remove the reader from the base before dropping a resource."
)

super().check_can_drop_resource_here(resource)


class ByonoyBase(Resource):
def __init__(self, name, rotation=None, category=None, model=None, barcode=None):
super().__init__(
name=name,
size_x=138,
size_y=95.7,
size_z=27.7,
)

self.plate_holder = _ByonoyAbsorbanceReaderPlateHolder(
name=self.name + "_plate_holder",
size_x=127.76,
size_y=85.59,
size_z=0,
child_location=Coordinate(x=(138 - 127.76) / 2, y=(95.7 - 85.59) / 2, z=27.7),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is 27.7 mm the distance from the bottom of the "SBS Adapter" to the surface that the plate sits on?

I am getting:

  • SBS_adapter.size_z = 17 mm
  • detection_unit.size_z = 20 mm
  • detection_unit.child_location.z = 16 mm

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i.e. I am getting the exact same dimensions for the dimensions which are specified in the manual:

Screenshot 2025-11-28 at 17 11 07

pedestal_size_z=0,
)
self.assign_child_resource(self.plate_holder, location=Coordinate.zero())

self.reader_holder = ResourceHolder(
name=self.name + "_reader_holder",
size_x=138,
size_y=95.7,
size_z=0,
child_location=Coordinate(x=0, y=0, z=10.66),
)
self.assign_child_resource(self.reader_holder, location=Coordinate.zero())

def assign_child_resource(
self, resource: Resource, location: Optional[Coordinate], reassign=True
):
if isinstance(resource, _ByonoyAbsorbanceReaderPlateHolder):
if self.plate_holder._byonoy_base is not None:
raise ValueError("ByonoyBase can only have one plate holder assigned.")
self.plate_holder._byonoy_base = self
return super().assign_child_resource(resource, location, reassign)

def check_can_drop_resource_here(self, resource: Resource) -> None:
raise RuntimeError(
"ByonoyBase does not support assigning child resources directly. "
"Use the plate_holder or reader_holder to assign plates and the reader, respectively."
)


def byonoy_absorbance96_base_and_reader(name: str, assign=True) -> Tuple[ByonoyBase, PlateReader]:
"""Creates a ByonoyBase and a PlateReader instance."""
byonoy_base = ByonoyBase(name=name + "_base")
reader = PlateReader(
name=name + "_reader",
size_x=138,
size_y=95.7,
size_z=0,
backend=ByonoyAbsorbance96AutomateBackend(),
)
if assign:
byonoy_base.reader_holder.assign_child_resource(reader)
return byonoy_base, reader


# === absorbance ===

# total

# x: 138
# y: 95.7
# z: 53.35

# base
# z = 27.7
# z without skirt 25.25

# top
# z = 41.62

# adapter
# z = 14.07

# location of top wrt base
# z = 10.66

# pickup distance from top
# z = 7.45

# === lum ===

# x: 155.5
# y: 95.7
# z: 56.9
Loading