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
11 changes: 7 additions & 4 deletions pylabrobot/hamilton/liquid_handlers/star/chatterbox.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
"""STARChatterboxDriver: logs commands instead of sending them over USB."""

import logging
from typing import Optional

from .autoload import STARAutoload
from .cover import STARCover
from pylabrobot.resources.hamilton import HamiltonDeck

from .autoload import STARAutoload
from .cover import STARCover
from .driver import (
DriveConfiguration,
ExtendedConfiguration,
MachineConfiguration,
STARConfiguration,
STARDriver,
)
from .head96_backend import STARHead96Backend
Expand Down Expand Up @@ -49,8 +51,9 @@ def __init__(
num_channels: int = 8,
machine_configuration: MachineConfiguration = _DEFAULT_MACHINE_CONF,
extended_configuration: ExtendedConfiguration = _DEFAULT_EXTENDED_CONF,
configuration: Optional[STARConfiguration] = None,
):
super().__init__(deck=deck)
super().__init__(deck=deck, configuration=configuration)
self._num_channels = num_channels
self._machine_configuration = machine_configuration
self._extended_configuration = extended_configuration
Expand All @@ -77,7 +80,7 @@ async def setup(self, backend_params=None):
self.head96 = None

if self.extended_conf.left_x_drive.iswap_installed:
self.iswap = iSWAPBackend(driver=self)
self.iswap = iSWAPBackend(driver=self, configuration=self._configuration.iswap)
self.iswap._version = "chatterbox"
self.iswap._parked = True
else:
Expand Down
29 changes: 24 additions & 5 deletions pylabrobot/hamilton/liquid_handlers/star/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
)
from .fw_parsing import parse_star_firmware_version_date, parse_star_fw_string
from .head96_backend import STARHead96Backend
from .iswap import iSWAPBackend
from .iswap import iSWAPBackend, iSWAPConfiguration
from .pip_backend import STARPIPBackend
from .wash_station import STARWashStation
from .x_arm import STARXArm
Expand Down Expand Up @@ -109,6 +109,23 @@ class ExtendedConfiguration:
right_arm_min_y_position: float = 6.0


@dataclass
class STARConfiguration:
"""Per-capability configuration for a STAR, injected at construction.

One optional field per capability, named after the capability attribute
(``star.iswap`` -> ``iswap``); each value is that capability's own configuration
dataclass (e.g. ``iSWAPConfiguration``: encoder resolutions, drive ranges,
geometric constants, per-machine calibration). Passed to ``STAR``/``STARLet`` or
the driver and distributed to each backend as its ``.configuration``. An unset
field means that capability uses its own defaults - read from the device at setup
on real hardware, or factory defaults under the chatterbox (no device to read).
Grows one field per capability as each gains a configuration.
"""

iswap: Optional[iSWAPConfiguration] = None


# ---------------------------------------------------------------------------
# STARDriver
# ---------------------------------------------------------------------------
Expand All @@ -133,6 +150,7 @@ def __init__(
read_timeout: int = 30,
write_timeout: int = 30,
left_side_panel_installed: bool = False,
configuration: Optional[STARConfiguration] = None,
):
super().__init__(
id_product=0x8000,
Expand All @@ -144,6 +162,9 @@ def __init__(
)
self.deck = deck
self.left_side_panel_installed = left_side_panel_installed
# Injection-only bundle, consumed at setup to seed each capability's own
# `.configuration`. The runtime home is `star.iswap.configuration`, not here.
self._configuration = configuration if configuration is not None else STARConfiguration()

# Populated during setup().
self.machine_conf: Optional[MachineConfiguration] = None
Expand Down Expand Up @@ -305,7 +326,7 @@ async def setup(self, backend_params: Optional[BackendParams] = None):
self.head96 = None

if self.extended_conf.left_x_drive.iswap_installed:
self.iswap = iSWAPBackend(driver=self)
self.iswap = iSWAPBackend(driver=self, configuration=self._configuration.iswap)
else:
self.iswap = None

Expand Down Expand Up @@ -542,9 +563,7 @@ async def core_read_barcode(
try:
reading_direction_int = {"vertical": 0, "horizontal": 1, "free": 2}[reading_direction]
except KeyError as e:
raise ValueError(
"reading_direction must be 'vertical', 'horizontal', or 'free'"
) from e
raise ValueError("reading_direction must be 'vertical', 'horizontal', or 'free'") from e

resp = await self.send_command(
module="C0",
Expand Down
70 changes: 68 additions & 2 deletions pylabrobot/hamilton/liquid_handlers/star/iswap.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
"""iSWAPBackend: the STAR's iSWAP, the internal swivel-arm plate handler (firmware C0 and R0).

Drives the arm - gripper open/close, relative/absolute X/Y/Z and rotation/wrist moves,
plate pick/place, parking, and teaching - and holds its device facts and per-machine
calibration in `iSWAPConfiguration`, exposed as `iSWAPBackend.configuration`.
"""

from __future__ import annotations

import enum
import logging
from contextlib import asynccontextmanager, contextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal, Optional, cast
from typing import TYPE_CHECKING, Dict, Literal, Optional, Tuple, cast

from pylabrobot.capabilities.arms.backend import OrientableGripperArmBackend
from pylabrobot.capabilities.arms.standard import CartesianPose, GripperDirection
Expand All @@ -31,6 +38,59 @@ def _direction_degrees_to_grip_direction(degrees: float) -> int:
return mapping[normalized]


@dataclass
class iSWAPConfiguration:
"""Device facts and per-machine calibration for the installed iSWAP (v1 equivalent of v0 ``iSWAPInformation``).

Two tiers. Firmware/hardware-version device facts (4th-generation iSWAP, the
only supported generation) are defaulted and identical across units. Per-machine
calibration carries Hamilton factory defaults so the configuration is valid
before setup; ``_on_setup`` refines these from the device EEPROM once the
corresponding read commands are wired.
"""

# -- per-machine calibration (factory defaults; refined from EEPROM at setup) --
fw_version: Optional[str] = None
rotation_drive_x_offset: float = 34.0 # mm; master EEPROM `kg` (C0 RA ra=kg)
rotation_drive_y_max: float = 0.0 # mm; refined at setup (Y parking bound)
link_1_length: float = 138.0 # mm; EEPROM pw[9] (R0 RA ra=pw)
link_2_length: float = 138.0 # mm; EEPROM pt[9] (R0 RA ra=pt)
rotation_drive_predefined_increments: Optional[
Dict["iSWAPBackend.RotationDriveOrientation", int]
] = None # EEPROM pw[0..4]
wrist_drive_predefined_increments: Optional[Dict["iSWAPBackend.WristDriveOrientation", int]] = (
None # EEPROM pt[1..4]
)

# -- Y --
y_increment_range: Tuple[int, int] = (0, 14_000)
y_mm_per_increment: float = 0.046302083
y_speed_increment_range: Tuple[int, int] = (50, 8_000) # increments/sec

# -- Z --
z_increment_range: Tuple[int, int] = (-187, 26_661)
z_mm_per_increment: float = 0.01072765
z_speed_increment_range: Tuple[int, int] = (50, 15_000) # increments/sec
z_acceleration_increment_range: Tuple[int, int] = (5, 999) # 1000 increments/sec^2

# -- rotation drive (joint 1, W) --
rotation_increment_range: Tuple[int, int] = (-30_032, 30_032)
rotation_deg_per_increment: float = 0.00309619077

# -- wrist drive (joint 2, T) --
wrist_increment_range: Tuple[int, int] = (-30_000, 30_000)
wrist_deg_per_increment: float = 0.00507968798

# -- gripper (G) --
gripper_increment_range: Tuple[int, int] = (12_780, 24_120) # jaw width
gripper_mm_per_increment: float = 0.00554337

# -- geometric constants --
rotation_drive_diameter: float = 30.5 # mm
rotation_drive_safety_radius: float = 90.0 # mm
rotation_drive_z_offset_above_finger: float = 13.0 # mm; R0 RZ finger plane -> drive bottom


class iSWAPBackend(OrientableGripperArmBackend):
class RotationDriveOrientation(enum.Enum):
LEFT = 1
Expand All @@ -44,9 +104,15 @@ class WristDriveOrientation(enum.Enum):
LEFT = 3
REVERSE = 4

def __init__(self, driver: STARDriver, traversal_height: float = 280.0):
def __init__(
self,
driver: STARDriver,
traversal_height: float = 280.0,
configuration: Optional[iSWAPConfiguration] = None,
):
self.driver = driver
self.traversal_height = traversal_height
self.configuration = configuration if configuration is not None else iSWAPConfiguration()
self._version: Optional[str] = None
self._parked: Optional[bool] = None

Expand Down
23 changes: 16 additions & 7 deletions pylabrobot/hamilton/liquid_handlers/star/star.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from .chatterbox import STARChatterboxDriver
from .core import CoreGripper
from .driver import STARDriver
from .driver import STARConfiguration, STARDriver


class _HamiltonSTAR(Device):
Expand All @@ -26,8 +26,17 @@ class _HamiltonSTAR(Device):
after hardware discovery during setup().
"""

def __init__(self, deck: HamiltonDeck, chatterbox: bool = False):
driver = STARChatterboxDriver(deck=deck) if chatterbox else STARDriver(deck=deck)
def __init__(
self,
deck: HamiltonDeck,
chatterbox: bool = False,
configuration: Optional[STARConfiguration] = None,
):
driver = (
STARChatterboxDriver(deck=deck, configuration=configuration)
if chatterbox
else STARDriver(deck=deck, configuration=configuration)
)
super().__init__(driver=driver)
self.driver: STARDriver = driver
self.deck = deck
Expand Down Expand Up @@ -212,12 +221,12 @@ async def core_grippers(
class STAR(_HamiltonSTAR):
"""Hamilton STAR liquid handler."""

def __init__(self, chatterbox: bool = False):
super().__init__(deck=STARDeck(), chatterbox=chatterbox)
def __init__(self, chatterbox: bool = False, configuration: Optional[STARConfiguration] = None):
super().__init__(deck=STARDeck(), chatterbox=chatterbox, configuration=configuration)


class STARLet(_HamiltonSTAR):
"""Hamilton STARLet liquid handler."""

def __init__(self, chatterbox: bool = False):
super().__init__(deck=STARLetDeck(), chatterbox=chatterbox)
def __init__(self, chatterbox: bool = False, configuration: Optional[STARConfiguration] = None):
super().__init__(deck=STARLetDeck(), chatterbox=chatterbox, configuration=configuration)
Loading