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
3 changes: 3 additions & 0 deletions pylabrobot/bulk_dispensers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from pylabrobot.bulk_dispensers.backend import BulkDispenserBackend
from pylabrobot.bulk_dispensers.bulk_dispenser import BulkDispenser
from pylabrobot.bulk_dispensers.chatterbox import BulkDispenserChatterboxBackend
84 changes: 84 additions & 0 deletions pylabrobot/bulk_dispensers/backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from __future__ import annotations

from abc import ABCMeta, abstractmethod

from pylabrobot.machines.backend import MachineBackend


class BulkDispenserBackend(MachineBackend, metaclass=ABCMeta):
"""Abstract class for bulk dispenser backends.

Volumes are specified in microliters (float). Concrete backends are responsible
for converting to instrument-specific units.
"""

@abstractmethod
async def dispense(self) -> None:
pass

@abstractmethod
async def prime(self, volume: float) -> None:
pass

@abstractmethod
async def empty(self, volume: float) -> None:
pass

@abstractmethod
async def shake(self, time: float, distance: int, speed: int) -> None:
"""Shake the plate.

Args:
time: Shake duration in seconds.
distance: Shake distance in mm (1-5).
speed: Shake frequency in Hz (1-20).
"""

@abstractmethod
async def move_plate_out(self) -> None:
pass

@abstractmethod
async def set_plate_type(self, plate_type: int) -> None:
pass

@abstractmethod
async def set_cassette_type(self, cassette_type: int) -> None:
pass

@abstractmethod
async def set_column_volume(self, column: int, volume: float) -> None:
"""Set dispense volume for a column.

Args:
column: Column number (0 = all columns).
volume: Volume in microliters.
"""

@abstractmethod
async def set_dispensing_height(self, height: int) -> None:
"""Set dispensing height.

Args:
height: Height in 1/100 mm (500-5500).
"""

@abstractmethod
async def set_pump_speed(self, speed: int) -> None:
"""Set pump speed as percentage of cassette range.

Args:
speed: Speed percentage (1-100).
"""

@abstractmethod
async def set_dispensing_order(self, order: int) -> None:
"""Set dispensing order.

Args:
order: 0 = row-wise, 1 = column-wise.
"""

@abstractmethod
async def abort(self) -> None:
pass
60 changes: 60 additions & 0 deletions pylabrobot/bulk_dispensers/bulk_dispenser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from __future__ import annotations

from pylabrobot.bulk_dispensers.backend import BulkDispenserBackend
from pylabrobot.machines.machine import Machine, need_setup_finished


class BulkDispenser(Machine):
"""Frontend for bulk reagent dispensers."""

def __init__(self, backend: BulkDispenserBackend) -> None:
super().__init__(backend=backend)
self.backend: BulkDispenserBackend = backend

@need_setup_finished
async def dispense(self, **backend_kwargs) -> None:
await self.backend.dispense(**backend_kwargs)

@need_setup_finished
async def prime(self, volume: float, **backend_kwargs) -> None:
await self.backend.prime(volume=volume, **backend_kwargs)

@need_setup_finished
async def empty(self, volume: float, **backend_kwargs) -> None:
await self.backend.empty(volume=volume, **backend_kwargs)

@need_setup_finished
async def shake(self, time: float, distance: int, speed: int, **backend_kwargs) -> None:
await self.backend.shake(time=time, distance=distance, speed=speed, **backend_kwargs)

@need_setup_finished
async def move_plate_out(self, **backend_kwargs) -> None:
await self.backend.move_plate_out(**backend_kwargs)

@need_setup_finished
async def set_plate_type(self, plate_type: int, **backend_kwargs) -> None:
await self.backend.set_plate_type(plate_type=plate_type, **backend_kwargs)

@need_setup_finished
async def set_cassette_type(self, cassette_type: int, **backend_kwargs) -> None:
await self.backend.set_cassette_type(cassette_type=cassette_type, **backend_kwargs)

@need_setup_finished
async def set_column_volume(self, column: int, volume: float, **backend_kwargs) -> None:
await self.backend.set_column_volume(column=column, volume=volume, **backend_kwargs)

@need_setup_finished
async def set_dispensing_height(self, height: int, **backend_kwargs) -> None:
await self.backend.set_dispensing_height(height=height, **backend_kwargs)

@need_setup_finished
async def set_pump_speed(self, speed: int, **backend_kwargs) -> None:
await self.backend.set_pump_speed(speed=speed, **backend_kwargs)

@need_setup_finished
async def set_dispensing_order(self, order: int, **backend_kwargs) -> None:
await self.backend.set_dispensing_order(order=order, **backend_kwargs)

@need_setup_finished
async def abort(self, **backend_kwargs) -> None:
await self.backend.abort(**backend_kwargs)
47 changes: 47 additions & 0 deletions pylabrobot/bulk_dispensers/chatterbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from pylabrobot.bulk_dispensers.backend import BulkDispenserBackend


class BulkDispenserChatterboxBackend(BulkDispenserBackend):
"""A backend that prints operations for testing without hardware."""

async def setup(self) -> None:
print("Setting up bulk dispenser.")

async def stop(self) -> None:
print("Stopping bulk dispenser.")

async def dispense(self) -> None:
print("Dispensing.")

async def prime(self, volume: float) -> None:
print(f"Priming with {volume} uL.")

async def empty(self, volume: float) -> None:
print(f"Emptying with {volume} uL.")

async def shake(self, time: float, distance: int, speed: int) -> None:
print(f"Shaking for {time}s, distance={distance}mm, speed={speed}Hz.")

async def move_plate_out(self) -> None:
print("Moving plate out.")

async def set_plate_type(self, plate_type: int) -> None:
print(f"Setting plate type to {plate_type}.")

async def set_cassette_type(self, cassette_type: int) -> None:
print(f"Setting cassette type to {cassette_type}.")

async def set_column_volume(self, column: int, volume: float) -> None:
print(f"Setting column {column} volume to {volume} uL.")

async def set_dispensing_height(self, height: int) -> None:
print(f"Setting dispensing height to {height}.")

async def set_pump_speed(self, speed: int) -> None:
print(f"Setting pump speed to {speed}%.")

async def set_dispensing_order(self, order: int) -> None:
print(f"Setting dispensing order to {order}.")

async def abort(self) -> None:
print("Aborting.")
Empty file.
91 changes: 91 additions & 0 deletions pylabrobot/bulk_dispensers/tests/bulk_dispenser_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import unittest

from pylabrobot.bulk_dispensers import (
BulkDispenser,
BulkDispenserBackend,
BulkDispenserChatterboxBackend,
)


class BulkDispenserSetupTests(unittest.IsolatedAsyncioTestCase):
"""Test setup/stop lifecycle and need_setup_finished guard."""

def setUp(self):
self.backend = BulkDispenserChatterboxBackend()
self.dispenser = BulkDispenser(backend=self.backend)

async def test_methods_fail_before_setup(self):
with self.assertRaises(RuntimeError):
await self.dispenser.dispense()
with self.assertRaises(RuntimeError):
await self.dispenser.prime(volume=100.0)
with self.assertRaises(RuntimeError):
await self.dispenser.abort()

async def test_setup_and_stop(self):
await self.dispenser.setup()
self.assertTrue(self.dispenser.setup_finished)
await self.dispenser.stop()
self.assertFalse(self.dispenser.setup_finished)

async def test_context_manager(self):
async with BulkDispenser(backend=BulkDispenserChatterboxBackend()) as d:
self.assertTrue(d.setup_finished)
self.assertFalse(d.setup_finished)


class BulkDispenserDelegationTests(unittest.IsolatedAsyncioTestCase):
"""Test that frontend methods delegate to the backend."""

async def asyncSetUp(self):
self.backend = unittest.mock.MagicMock(spec=BulkDispenserBackend)
self.dispenser = BulkDispenser(backend=self.backend)
self.dispenser._setup_finished = True

async def test_dispense(self):
await self.dispenser.dispense()
self.backend.dispense.assert_awaited_once()

async def test_prime(self):
await self.dispenser.prime(volume=50.0)
self.backend.prime.assert_awaited_once_with(volume=50.0)

async def test_empty(self):
await self.dispenser.empty(volume=100.0)
self.backend.empty.assert_awaited_once_with(volume=100.0)

async def test_shake(self):
await self.dispenser.shake(time=5.0, distance=3, speed=10)
self.backend.shake.assert_awaited_once_with(time=5.0, distance=3, speed=10)

async def test_move_plate_out(self):
await self.dispenser.move_plate_out()
self.backend.move_plate_out.assert_awaited_once()

async def test_set_plate_type(self):
await self.dispenser.set_plate_type(plate_type=3)
self.backend.set_plate_type.assert_awaited_once_with(plate_type=3)

async def test_set_cassette_type(self):
await self.dispenser.set_cassette_type(cassette_type=1)
self.backend.set_cassette_type.assert_awaited_once_with(cassette_type=1)

async def test_set_column_volume(self):
await self.dispenser.set_column_volume(column=0, volume=25.0)
self.backend.set_column_volume.assert_awaited_once_with(column=0, volume=25.0)

async def test_set_dispensing_height(self):
await self.dispenser.set_dispensing_height(height=2500)
self.backend.set_dispensing_height.assert_awaited_once_with(height=2500)

async def test_set_pump_speed(self):
await self.dispenser.set_pump_speed(speed=50)
self.backend.set_pump_speed.assert_awaited_once_with(speed=50)

async def test_set_dispensing_order(self):
await self.dispenser.set_dispensing_order(order=1)
self.backend.set_dispensing_order.assert_awaited_once_with(order=1)

async def test_abort(self):
await self.dispenser.abort()
self.backend.abort.assert_awaited_once()
13 changes: 13 additions & 0 deletions pylabrobot/bulk_dispensers/thermo_scientific/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi import (
CassetteType,
DispensingOrder,
EmptyMode,
MultidropCombiBackend,
MultidropCombiCommunicationError,
MultidropCombiError,
MultidropCombiInstrumentError,
PrimeMode,
plate_to_pla_params,
plate_to_type_index,
plate_well_count,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.backend import (
MultidropCombiBackend,
)
from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.enums import (
CassetteType,
DispensingOrder,
EmptyMode,
PrimeMode,
)
from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.errors import (
MultidropCombiCommunicationError,
MultidropCombiError,
MultidropCombiInstrumentError,
)
from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.helpers import (
plate_to_pla_params,
plate_to_type_index,
plate_well_count,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Control operations mixin for the Multidrop Combi."""

from __future__ import annotations

from typing import Optional

from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.errors import (
MultidropCombiCommunicationError,
)
from pylabrobot.io.serial import Serial


class MultidropCombiActionsMixin:
"""Mixin providing control operations for the Multidrop Combi."""

io: Optional[Serial]

async def abort(self) -> None:
"""Send ESC character to abort the current operation."""
if self.io is None:
raise MultidropCombiCommunicationError("Not connected to instrument", operation="abort")
await self.io.write(b"\x1b")

async def restart(self) -> None:
"""Restart the instrument (equivalent to power cycle)."""
await self._send_command("RST", timeout=10.0) # type: ignore[attr-defined]

async def acknowledge_error(self) -> None:
"""Clear instrument error state."""
await self._send_command("EAK", timeout=5.0) # type: ignore[attr-defined]
Loading
Loading