Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2a8c0a7
initial byonoy draft
rickwierenga Jul 23, 2025
6df2963
improve response parsing
rickwierenga Jul 26, 2025
f3a7c98
absorbance
rickwierenga Jul 27, 2025
d9be377
support device wavelengths
rickwierenga Jul 27, 2025
8f25dbf
use send_command in luminescence
rickwierenga Jul 27, 2025
d47624f
move into seperate classes
rickwierenga Jul 28, 2025
581d659
rename to backend
rickwierenga Jul 28, 2025
bce4852
resource modeling draft
rickwierenga Aug 9, 2025
7ba4fcc
some tests
rickwierenga Aug 10, 2025
fda5bd2
rewrite byonoy with delf suggestions
rickwierenga Oct 1, 2025
26492cb
Merge branch 'main' into byonoy-luminescence
rickwierenga Oct 1, 2025
204d7b7
lint, typo, type (not tests yet)
rickwierenga Oct 1, 2025
911d427
Merge branch 'main' into byonoy-luminescence
rickwierenga Oct 17, 2025
7c3fa78
Small Docs Update for Byonoy Absorbance 96 Automate (#690)
BioCam Oct 17, 2025
15a6a27
update type
rickwierenga Oct 17, 2025
a3f1c5c
relax the checks
rickwierenga Oct 17, 2025
125b7ff
lint
rickwierenga Oct 17, 2025
90f4262
try
rickwierenga Oct 21, 2025
701be0c
set get_available_absorbance_wavelengths routing info to 8040
rickwierenga Oct 21, 2025
e8b1b21
fix typo
rickwierenga Oct 21, 2025
36b2bd4
update
rickwierenga Oct 21, 2025
6ed6d33
typo
rickwierenga Oct 22, 2025
f9bca03
fix missing measurement initialization
BioCam Nov 30, 2025
e0dfbf2
upgrade pylabrobot.hid
BioCam Nov 30, 2025
36a033a
Merge branch 'main' into byonoy-luminescence
BioCam Dec 16, 2025
6d36ae9
update docs
BioCam Dec 17, 2025
23f573a
Merge branch 'main' into byonoy-luminescence
rickwierenga Dec 18, 2025
287ea02
fixes
rickwierenga Dec 18, 2025
c6c9367
type, use plr plate reading standard
rickwierenga Dec 18, 2025
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,198 changes: 1,198 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 @@ -13,6 +13,8 @@
":maxdepth: 1\n",
"\n",
"bmg-clariostar\n",
"cytation5\n",
"byonoy\n",
"cytation\n",
"synergyh1\n",
"```\n",
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 @@ -2040,6 +2040,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 @@ -2389,7 +2392,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
Expand Up @@ -6,6 +6,12 @@
CytationImagingConfig,
)
from .agilent_biotek_synergyh1_backend import SynergyH1Backend
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