From c9aba46747d6eeeea1affe95d3fe1da7cd2ad230 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Wed, 10 Jun 2026 09:03:04 +0100 Subject: [PATCH 1/2] Add star.iswap.configuration (v1 equivalent of v0 iSWAPInformation) Introduce the iSWAPConfiguration dataclass holding iSWAP device facts (per-increment resolutions, drive/speed/acceleration ranges, geometric constants) and per-machine calibration, exposed as iSWAPBackend.configuration. This is the foundation the iSWAP read and move commands depend on. Per-machine fields carry Hamilton factory defaults pending EEPROM population at setup. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../hamilton/liquid_handlers/star/iswap.py | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/pylabrobot/hamilton/liquid_handlers/star/iswap.py b/pylabrobot/hamilton/liquid_handlers/star/iswap.py index b92e1021943..ca34a3f1ad5 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/iswap.py +++ b/pylabrobot/hamilton/liquid_handlers/star/iswap.py @@ -4,7 +4,7 @@ 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 @@ -31,6 +31,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 @@ -44,9 +97,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 From e2e38e14fed3233b0cdce18a31dfd3028e37d6d0 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Wed, 10 Jun 2026 11:01:29 +0100 Subject: [PATCH 2/2] Add STARConfiguration injection container and wire iSWAP config through the device Introduce STARConfiguration (driver.py): one optional field per capability, named after the capability attribute (iswap), injected at construction and distributed to each backend as its `.configuration`. Thread it through STARDriver, STARChatterboxDriver, and the STAR/STARLet device, replacing the standalone chatterbox iswap kwarg. The container is injection-only (held privately as `_configuration`); the runtime home stays `star.iswap.configuration`. Add the iswap module docstring. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../liquid_handlers/star/chatterbox.py | 11 ++++--- .../hamilton/liquid_handlers/star/driver.py | 29 +++++++++++++++---- .../hamilton/liquid_handlers/star/iswap.py | 7 +++++ .../hamilton/liquid_handlers/star/star.py | 23 ++++++++++----- 4 files changed, 54 insertions(+), 16 deletions(-) diff --git a/pylabrobot/hamilton/liquid_handlers/star/chatterbox.py b/pylabrobot/hamilton/liquid_handlers/star/chatterbox.py index e7f9e72043d..6f5fb260f71 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/chatterbox.py +++ b/pylabrobot/hamilton/liquid_handlers/star/chatterbox.py @@ -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 @@ -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 @@ -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: diff --git a/pylabrobot/hamilton/liquid_handlers/star/driver.py b/pylabrobot/hamilton/liquid_handlers/star/driver.py index 06b438ec3b5..43fec8ed2e4 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/driver.py +++ b/pylabrobot/hamilton/liquid_handlers/star/driver.py @@ -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 @@ -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 # --------------------------------------------------------------------------- @@ -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, @@ -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 @@ -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 @@ -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", diff --git a/pylabrobot/hamilton/liquid_handlers/star/iswap.py b/pylabrobot/hamilton/liquid_handlers/star/iswap.py index ca34a3f1ad5..666dbd905cd 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/iswap.py +++ b/pylabrobot/hamilton/liquid_handlers/star/iswap.py @@ -1,3 +1,10 @@ +"""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 diff --git a/pylabrobot/hamilton/liquid_handlers/star/star.py b/pylabrobot/hamilton/liquid_handlers/star/star.py index 67a46b54549..9e958198d1e 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/star.py +++ b/pylabrobot/hamilton/liquid_handlers/star/star.py @@ -16,7 +16,7 @@ from .chatterbox import STARChatterboxDriver from .core import CoreGripper -from .driver import STARDriver +from .driver import STARConfiguration, STARDriver class _HamiltonSTAR(Device): @@ -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 @@ -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)