From 86d99e6cc27e17641aa533e4f65fca9ebb295151 Mon Sep 17 00:00:00 2001 From: kelsorj Date: Fri, 21 Aug 2026 10:59:23 -0700 Subject: [PATCH 1/9] Add Agilent Bravo value types, errors, and head geometry Axis, speed level, and head type are Literal types with internal tables mapping them to firmware wire codes. Head geometry covers the 8x12, 16x24, 8x1, and 16x1 heads, and head-mode normalisation reduces every selection to a contiguous rectangular block of barrels at one of four anchor corners. BravoMachineConfig carries the per-machine head, safety, gripper, and per-axis settings a Bravo needs. --- pylabrobot/agilent/bravo/axis_config.py | 148 +++ pylabrobot/agilent/bravo/axis_config_tests.py | 77 ++ pylabrobot/agilent/bravo/config.py | 184 ++++ pylabrobot/agilent/bravo/config_tests.py | 96 ++ pylabrobot/agilent/bravo/errors.py | 235 +++++ pylabrobot/agilent/bravo/errors_tests.py | 73 ++ pylabrobot/agilent/bravo/head_mode.py | 972 ++++++++++++++++++ pylabrobot/agilent/bravo/head_mode_tests.py | 311 ++++++ pylabrobot/agilent/bravo/types.py | 671 ++++++++++++ pylabrobot/agilent/bravo/types_tests.py | 241 +++++ 10 files changed, 3008 insertions(+) create mode 100644 pylabrobot/agilent/bravo/axis_config.py create mode 100644 pylabrobot/agilent/bravo/axis_config_tests.py create mode 100644 pylabrobot/agilent/bravo/config.py create mode 100644 pylabrobot/agilent/bravo/config_tests.py create mode 100644 pylabrobot/agilent/bravo/errors.py create mode 100644 pylabrobot/agilent/bravo/errors_tests.py create mode 100644 pylabrobot/agilent/bravo/head_mode.py create mode 100644 pylabrobot/agilent/bravo/head_mode_tests.py create mode 100644 pylabrobot/agilent/bravo/types.py create mode 100644 pylabrobot/agilent/bravo/types_tests.py diff --git a/pylabrobot/agilent/bravo/axis_config.py b/pylabrobot/agilent/bravo/axis_config.py new file mode 100644 index 00000000000..fd1e504a203 --- /dev/null +++ b/pylabrobot/agilent/bravo/axis_config.py @@ -0,0 +1,148 @@ +"""Per-axis motion configuration and default speed profiles. + +Each motion axis needs a handful of values beyond the physical constants in +:mod:`.types` before a controller can drive it: the encoder scale to use for +tick conversion, the homing direction and register layout the firmware +expects, and a velocity/acceleration pair for each named +:data:`~.types.SpeedLevel`. :class:`AxisConfig` collects all of that into one +typed record per axis, and :func:`default_axis_config` builds one populated +entirely from the constants this module already knows, so a controller +constructed without any caller-supplied configuration still has complete +values for every axis. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .types import ( + AXIS_RANGES, + DEFAULT_W_TICKS_PER_UL, + TICKS_PER_MM, + Axis, + AxisRange, + SpeedLevel, + SpeedProfile, +) + +_TICKS_PER_ENG_UNIT: dict[Axis, float] = {**TICKS_PER_MM, "w": DEFAULT_W_TICKS_PER_UL} +"""Encoder ticks per engineering unit for every axis, including W.""" + + +@dataclass +class AxisConfig: + """Complete motion configuration for one axis. + + Attributes: + axis: The axis this configuration applies to. + ticks_per_eng_unit: Encoder ticks per engineering unit (mm, or uL for + the W axis) for this axis. + range: The axis's travel limits, in engineering units. + homing_offset: The engineering-unit position the axis reports once + homing completes, i.e. its park position. + home_in_positive_direction: Whether the home sensor sits at the + positive end of the axis's travel. Homing departs from the sensor in + the opposite direction from this flag. + home_flag_bitmask: The bit in the firmware's home-sensor status byte + that reports this axis's sensor state. + home_flag_register: The Agile register address that reports whether + homing is in progress for this axis. + home_complete_register: The Agile register whose value confirms this + axis's home flag once homing has completed. + homing_soft_stop_decel: Deceleration used to bring the axis to a + controlled stop at the end of a homing search phase. + min_move_full_accel: The shortest move distance, in engineering units, + for which the axis reaches its full commanded acceleration before + needing to decelerate again. + check_for_alignment: Whether moves on this axis should be rejected + unless the axis has already been homed. + speeds: Velocity/acceleration pairs for this axis, keyed by speed + level. + """ + + axis: Axis + ticks_per_eng_unit: float + range: AxisRange + homing_offset: float = 0.0 + home_in_positive_direction: bool = False + home_flag_bitmask: int = 0 + home_flag_register: int = 0 + home_complete_register: int = 0 + homing_soft_stop_decel: float = 300.0 + min_move_full_accel: float = 0.0 + check_for_alignment: bool = True + speeds: dict[SpeedLevel, SpeedProfile] = field(default_factory=dict) + + +DEFAULT_SPEEDS: dict[Axis, dict[SpeedLevel, SpeedProfile]] = { + "x": { + "fast": SpeedProfile(400.0, 2000.0), + "med": SpeedProfile(200.0, 1000.0), + "slow": SpeedProfile(50.0, 500.0), + "homing": SpeedProfile(50.0, 500.0), + "safe": SpeedProfile(100.0, 500.0), + }, + "y": { + "fast": SpeedProfile(400.0, 2000.0), + "med": SpeedProfile(200.0, 1000.0), + "slow": SpeedProfile(50.0, 500.0), + "homing": SpeedProfile(50.0, 500.0), + "safe": SpeedProfile(100.0, 500.0), + }, + "z": { + "fast": SpeedProfile(150.0, 1500.0), + "med": SpeedProfile(75.0, 750.0), + "slow": SpeedProfile(25.0, 250.0), + "homing": SpeedProfile(25.0, 250.0), + "safe": SpeedProfile(50.0, 500.0), + }, + "w": { + "fast": SpeedProfile(250.0, 2500.0), + "med": SpeedProfile(125.0, 1250.0), + "slow": SpeedProfile(25.0, 250.0), + "homing": SpeedProfile(25.0, 250.0), + "safe": SpeedProfile(50.0, 500.0), + }, + "g": { + "fast": SpeedProfile(50.0, 500.0), + "med": SpeedProfile(25.0, 250.0), + "slow": SpeedProfile(10.0, 100.0), + "homing": SpeedProfile(10.0, 100.0), + "safe": SpeedProfile(10.0, 100.0), + }, + "zg": { + "fast": SpeedProfile(150.0, 1500.0), + "med": SpeedProfile(75.0, 750.0), + "slow": SpeedProfile(25.0, 250.0), + "homing": SpeedProfile(25.0, 250.0), + "safe": SpeedProfile(50.0, 500.0), + }, +} +"""Velocity/acceleration pairs for every axis and speed level.""" + + +def default_axis_config(axis: Axis) -> AxisConfig: + """Build the default configuration for an axis. + + Populates :attr:`AxisConfig.ticks_per_eng_unit`, :attr:`AxisConfig.range`, + and :attr:`AxisConfig.speeds` from :data:`~.types.TICKS_PER_MM`, + :data:`~.types.AXIS_RANGES`, and :data:`DEFAULT_SPEEDS`; every other field + keeps its dataclass default. The W axis has no fixed mm scale in + :data:`~.types.TICKS_PER_MM` (its ticks-per-uL ratio depends on the + installed head), so its encoder scale here is + :data:`~.types.DEFAULT_W_TICKS_PER_UL` -- the same head-independent + default every controller in this package seeds itself with -- rather than + an arbitrary placeholder. + + Args: + axis: The axis to build a default configuration for. + + Returns: + A complete, typed configuration for ``axis``. + """ + return AxisConfig( + axis=axis, + ticks_per_eng_unit=_TICKS_PER_ENG_UNIT[axis], + range=AXIS_RANGES[axis], + speeds=DEFAULT_SPEEDS.get(axis, {}), + ) diff --git a/pylabrobot/agilent/bravo/axis_config_tests.py b/pylabrobot/agilent/bravo/axis_config_tests.py new file mode 100644 index 00000000000..40b78895f52 --- /dev/null +++ b/pylabrobot/agilent/bravo/axis_config_tests.py @@ -0,0 +1,77 @@ +import unittest + +from pylabrobot.agilent.bravo.axis_config import DEFAULT_SPEEDS, AxisConfig, default_axis_config +from pylabrobot.agilent.bravo.types import ( + ALL_AXES, + AXIS_RANGES, + DEFAULT_W_TICKS_PER_UL, + TICKS_PER_MM, +) + + +class DefaultAxisConfigTests(unittest.TestCase): + def test_every_axis_gets_a_complete_default_config(self): + for axis in ALL_AXES: + cfg = default_axis_config(axis) + self.assertEqual(cfg.axis, axis) + self.assertEqual(cfg.range, AXIS_RANGES[axis]) + self.assertIsInstance(cfg.ticks_per_eng_unit, float) + self.assertGreater(cfg.ticks_per_eng_unit, 0.0) + + def test_linear_axes_use_ticks_per_mm(self): + for axis, ticks in TICKS_PER_MM.items(): + self.assertEqual(default_axis_config(axis).ticks_per_eng_unit, ticks) + + def test_w_axis_uses_the_shared_head_independent_default(self): + # W has no fixed mm scale in TICKS_PER_MM (it depends on the installed + # head), so the default config uses DEFAULT_W_TICKS_PER_UL -- the same + # constant every controller in this package seeds itself with -- rather + # than an arbitrary placeholder. + self.assertNotIn("w", TICKS_PER_MM) + self.assertEqual(default_axis_config("w").ticks_per_eng_unit, DEFAULT_W_TICKS_PER_UL) + self.assertEqual(default_axis_config("w").ticks_per_eng_unit, 48.0) + + def test_default_speeds_present_for_every_speed_level(self): + for axis in ALL_AXES: + cfg = default_axis_config(axis) + for level in ("fast", "med", "slow", "homing", "safe"): + self.assertIn(level, cfg.speeds, f"{axis} missing speed level {level}") + + def test_scalar_defaults_match_dataclass_defaults(self): + cfg = default_axis_config("x") + self.assertEqual(cfg.homing_offset, 0.0) + self.assertFalse(cfg.home_in_positive_direction) + self.assertEqual(cfg.home_flag_bitmask, 0) + self.assertEqual(cfg.home_complete_register, 0) + self.assertTrue(cfg.check_for_alignment) + + def test_default_speeds_table_covers_every_axis(self): + for axis in ALL_AXES: + self.assertIn(axis, DEFAULT_SPEEDS) + + +class AxisConfigConstructionTests(unittest.TestCase): + def test_explicit_config_overrides_every_field(self): + cfg = AxisConfig( + axis="zg", + ticks_per_eng_unit=787.4, + range=AXIS_RANGES["zg"], + homing_offset=-20.0, + home_in_positive_direction=True, + home_flag_bitmask=0x02, + home_flag_register=0x10, + home_complete_register=0x5F, + homing_soft_stop_decel=150.0, + min_move_full_accel=5.0, + check_for_alignment=False, + speeds={}, + ) + self.assertEqual(cfg.homing_offset, -20.0) + self.assertTrue(cfg.home_in_positive_direction) + self.assertEqual(cfg.home_flag_bitmask, 0x02) + self.assertEqual(cfg.home_complete_register, 0x5F) + self.assertFalse(cfg.check_for_alignment) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/config.py b/pylabrobot/agilent/bravo/config.py new file mode 100644 index 00000000000..349dfa5741e --- /dev/null +++ b/pylabrobot/agilent/bravo/config.py @@ -0,0 +1,184 @@ +"""Typed machine configuration for the Bravo state-machine task layer. + +The state-machine tasks (:mod:`.state_machine.tasks`) need a handful of +tuned, per-machine values -- which head is installed, how far above a +labware surface to approach before lowering, the plunger and gripper +mechanical offsets, and the per-axis motion configuration -- bundled into +one typed object so a task receives a single argument instead of several +loosely related ones. :class:`BravoMachineConfig` is that bundle. + +Every field here is a tuned physical or operational value, not a computed +default; the numbers come from the same bench measurements and firmware +constants as the rest of this package. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Optional + +from .axis_config import AxisConfig, default_axis_config +from .types import ALL_AXES, Axis, HeadType + + +@dataclass +class HeadConfig: + """Which pipetting head is installed and how its tips are tracked. + + Attributes: + head_type: The head type installed on the gantry. + check_on_init: Whether :class:`~.state_machine.tasks.InitializeTask` + probes the installed head during initialization. + default_tip_capacity: The nominal capacity, in microlitres, of the tip + this head normally runs with. + teach_tip_capacity: The nominal capacity, in microlitres, of the tip + that was on the head when its teachpoints were taught. + default_tip_id: The catalogue id of the tip this head normally runs + with, or ``None`` if unset. + teach_tip_id: The catalogue id of the tip that was on the head when its + teachpoints were taught, or ``None`` if unset. + teach_tip_length_mm: The measured length, in millimetres, of the tip + that was on the head when its teachpoints were taught, or ``None`` if + unset. + """ + + head_type: HeadType = "96_d_70" + check_on_init: bool = True + default_tip_capacity: float = 200.0 + teach_tip_capacity: float = 200.0 + default_tip_id: Optional[str] = None + teach_tip_id: Optional[str] = None + teach_tip_length_mm: Optional[float] = None + + +@dataclass +class GripperConfig: + """Mechanical tuning for the plate gripper. + + Attributes: + grip_current: Current limit, in amps, used when closing on a plate + body. + lid_grip_current: Current limit, in amps, used when closing on a plate + lid. + y_offset: Offset, in millimetres, between the gripper's Y position and + the head's Y position for the same deck location. + gripper_position: The G-axis position, in millimetres, that closes the + gripper onto a plate. + pad_zg_reference_mm: With a tip of length + ``pad_reference_tip_length_mm`` installed, the Zg position at which + the gripper bottom sits in the plate-pad plane. Paired with + ``pad_reference_tip_length_mm``; re-measure both together per + machine rather than assuming the defaults. + pad_reference_tip_length_mm: The tip length, in millimetres, the + ``pad_zg_reference_mm`` measurement was taken with. + """ + + grip_current: float = 0.5 + lid_grip_current: float = 0.3 + y_offset: float = 0.0 + gripper_position: float = 5.0 + pad_zg_reference_mm: float = 7.0 + pad_reference_tip_length_mm: float = 26.1 + + +@dataclass +class SafetyConfig: + """Motion-safety and operational tuning shared across state-machine tasks. + + Attributes: + ignore_plate_sensor: Whether to skip the gripper's plate-presence + sensor rather than acting on its reading. + ignore_w_axis: Whether to skip the W (plunger) axis during + initialization and homing. + simulation_mode: Whether the driver is running against a simulated + instrument rather than real hardware. + z_safe_position: The Z position, in millimetres, that is clear of every + labware height on the deck. + approach_height: Default clearance, in millimetres, to stop above a + target before the final approach move. + always_move_to_safe_z: Whether every location move retracts to + ``z_safe_position`` first, even when the current Z looks clear. + prompt_home_w: Whether initialization pauses for operator confirmation + before homing the W axis. + run_medium_speed: Whether to use the medium speed profile instead of + fast for general motion. + enable_tips_off_tip_touch: Whether Tips Off performs a tip-touch + confirmation move. + is_srt: Whether this machine is an SRT-generation instrument. + tips_off_w_position: The W (plunger) target, in microlitres, during + Tips Off, before returning W to 0. + tips_off_z_offset: Millimetres the head ejects above the seated press + depth during Tips Off. + tips_off_tip_touch_distance: Millimetres of travel for the Tips Off + tip-touch confirmation move. + head_tolerance: ADC counts of tolerance allowed when matching a + resistor-detected head against the expected type. + safe_location: The deck location number used as a neutral safe + position. + prevent_bravo_during_robotic_access: Whether to block Bravo motion + while an external robot is accessing the deck. + tip_press_dwell: Seconds to hold the Tips On force press before + checking whether it is within tolerance. + plate_sensor_transient: Seconds to allow a plate-presence sensor + reading to settle before treating it as final. + allow_tos_fluid_handling: Whether fluid handling is permitted with a + TOS (tip-on-shaft) tool installed. + enable_tips_on_tip_touch: Whether Tips On performs a tip-touch + confirmation move. + pin_tool_tip_type: The pintool tip type label used for pintool heads. + """ + + ignore_plate_sensor: bool = False + ignore_w_axis: bool = False + simulation_mode: bool = False + z_safe_position: float = 0.0 + approach_height: float = 10.0 + always_move_to_safe_z: bool = True + prompt_home_w: bool = True + run_medium_speed: bool = False + enable_tips_off_tip_touch: bool = True + is_srt: bool = False + tips_off_w_position: float = -11.0 + tips_off_z_offset: float = 10.0 + tips_off_tip_touch_distance: float = 314.96 + head_tolerance: int = 25 + safe_location: int = 5 + prevent_bravo_during_robotic_access: bool = True + tip_press_dwell: float = 0.0 + plate_sensor_transient: float = 0.3 + allow_tos_fluid_handling: bool = False + enable_tips_on_tip_touch: bool = False + pin_tool_tip_type: str = "33 mm" + + +def _default_axes() -> Dict[Axis, AxisConfig]: + """Build a default :class:`~.axis_config.AxisConfig` for every axis.""" + return {axis: default_axis_config(axis) for axis in ALL_AXES} + + +@dataclass +class BravoMachineConfig: + """The tuned, per-machine configuration a state-machine task operates from. + + Bundles the head, gripper, and safety configuration together with the + per-axis motion configuration, so a task takes one typed argument in + place of several separately-passed configuration objects. + + Attributes: + head: The installed head and its tip tracking. + gripper: Plate-gripper mechanical tuning. + safety: Motion-safety and operational tuning. + axes: Per-axis motion configuration, keyed by axis. + current_limits: Per-head-family override tables for the Tips On force + press, keyed by table name (``"LT"`` for long-tip heads, ``"ST"`` + for short-tip heads) and then by a string tip count (e.g. ``"96"``) + to a current limit in amps. ``None``, or a table with no entry + compatible with the active tip count, falls back to the built-in + :data:`~.types.LT_TIP_CURRENT_TABLE`/:data:`~.types.ST_TIP_CURRENT_TABLE`. + """ + + head: HeadConfig = field(default_factory=HeadConfig) + gripper: GripperConfig = field(default_factory=GripperConfig) + safety: SafetyConfig = field(default_factory=SafetyConfig) + axes: Dict[Axis, AxisConfig] = field(default_factory=_default_axes) + current_limits: Optional[Dict[str, Dict[str, float]]] = None diff --git a/pylabrobot/agilent/bravo/config_tests.py b/pylabrobot/agilent/bravo/config_tests.py new file mode 100644 index 00000000000..5705ac969df --- /dev/null +++ b/pylabrobot/agilent/bravo/config_tests.py @@ -0,0 +1,96 @@ +import unittest + +from pylabrobot.agilent.bravo.config import ( + BravoMachineConfig, + GripperConfig, + HeadConfig, + SafetyConfig, +) +from pylabrobot.agilent.bravo.types import ALL_AXES + + +class HeadConfigTests(unittest.TestCase): + def test_defaults_match_source_profile(self): + cfg = HeadConfig() + self.assertEqual(cfg.head_type, "96_d_70") + self.assertTrue(cfg.check_on_init) + self.assertEqual(cfg.default_tip_capacity, 200.0) + self.assertEqual(cfg.teach_tip_capacity, 200.0) + self.assertIsNone(cfg.default_tip_id) + self.assertIsNone(cfg.teach_tip_id) + self.assertIsNone(cfg.teach_tip_length_mm) + + +class GripperConfigTests(unittest.TestCase): + def test_defaults_match_source_profile(self): + cfg = GripperConfig() + self.assertEqual(cfg.grip_current, 0.5) + self.assertEqual(cfg.lid_grip_current, 0.3) + self.assertEqual(cfg.y_offset, 0.0) + self.assertEqual(cfg.gripper_position, 5.0) + self.assertEqual(cfg.pad_zg_reference_mm, 7.0) + self.assertEqual(cfg.pad_reference_tip_length_mm, 26.1) + + +class SafetyConfigTests(unittest.TestCase): + def test_defaults_match_source_profile(self): + cfg = SafetyConfig() + self.assertFalse(cfg.ignore_plate_sensor) + self.assertFalse(cfg.ignore_w_axis) + self.assertFalse(cfg.simulation_mode) + self.assertEqual(cfg.z_safe_position, 0.0) + self.assertEqual(cfg.approach_height, 10.0) + self.assertTrue(cfg.always_move_to_safe_z) + self.assertTrue(cfg.prompt_home_w) + self.assertFalse(cfg.run_medium_speed) + self.assertTrue(cfg.enable_tips_off_tip_touch) + self.assertFalse(cfg.is_srt) + self.assertEqual(cfg.tips_off_w_position, -11.0) + self.assertEqual(cfg.tips_off_z_offset, 10.0) + self.assertEqual(cfg.tips_off_tip_touch_distance, 314.96) + self.assertEqual(cfg.head_tolerance, 25) + self.assertEqual(cfg.safe_location, 5) + self.assertTrue(cfg.prevent_bravo_during_robotic_access) + self.assertFalse(cfg.allow_tos_fluid_handling) + self.assertFalse(cfg.enable_tips_on_tip_touch) + self.assertEqual(cfg.pin_tool_tip_type, "33 mm") + + def test_millisecond_fields_are_converted_to_seconds(self): + # Source profile.py: tip_press_dwell_time: int = 0 (milliseconds). + cfg = SafetyConfig() + self.assertEqual(cfg.tip_press_dwell, 0.0) + self.assertIsInstance(cfg.tip_press_dwell, float) + + def test_plate_sensor_transient_defaults_to_300ms_in_seconds(self): + # Source profile.py: plate_sensor_transient_ms: int = 300. + cfg = SafetyConfig() + self.assertEqual(cfg.plate_sensor_transient, 0.3) + + +class BravoMachineConfigTests(unittest.TestCase): + def test_default_construction(self): + config = BravoMachineConfig() + self.assertIsInstance(config.head, HeadConfig) + self.assertIsInstance(config.gripper, GripperConfig) + self.assertIsInstance(config.safety, SafetyConfig) + self.assertEqual(set(config.axes.keys()), set(ALL_AXES)) + + def test_current_limits_defaults_to_none(self): + config = BravoMachineConfig() + self.assertIsNone(config.current_limits) + + def test_axes_are_independent_per_instance(self): + a = BravoMachineConfig() + b = BravoMachineConfig() + a.axes["x"].homing_offset = 123.0 + self.assertNotEqual(a.axes["x"].homing_offset, b.axes["x"].homing_offset) + + def test_sub_configs_are_independent_per_instance(self): + a = BravoMachineConfig() + b = BravoMachineConfig() + a.head.default_tip_id = "st_10ul" + self.assertIsNone(b.head.default_tip_id) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/errors.py b/pylabrobot/agilent/bravo/errors.py new file mode 100644 index 00000000000..855f2f60d02 --- /dev/null +++ b/pylabrobot/agilent/bravo/errors.py @@ -0,0 +1,235 @@ +"""Structured errors for Bravo hardware and driver failures. + +The Rabbit motion controller reports faults as a single status byte in its +response frame; this module turns that byte (or a locally detected failure, +such as a connection timeout) into a typed :class:`BravoError` that callers +can inspect programmatically instead of parsing message text. +""" + +from __future__ import annotations + +from enum import IntEnum +from typing import Optional + +from .types import _AXIS_BY_CODE, Axis, axis_display_name, axis_label + + +class ErrorType(IntEnum): + """All Bravo hardware and driver error categories.""" + + NO_ERROR = 0 + COULD_NOT_CONNECT = 1 + COULD_NOT_PING = 2 + COULD_NOT_QUERY_FIRMWARE = 3 + COULD_NOT_QUERY_STATE = 4 + COULD_NOT_QUERY_GO_BUTTON = 5 + UNIQUE_VALUE = 6 + REGISTER_NOT_READ = 7 + COULD_NOT_ALIGN = 8 + STOP_COMMAND = 9 + CONTROLLER_UNIDENTIFIED = 10 + ROBOT_DISABLE = 11 + MOTOR_POWER = 12 + MOVE_POSITION = 13 + MOVE_TIMEOUT = 14 + EXCEEDED_DEST = 15 + UNABLE_TO_REACH_DEST = 16 + AMP_SHORT_CIRCUIT = 17 + ENCODER = 18 + CONTROLLER_FATAL = 19 + CONTROLLER_INTERNAL = 20 + CONTROLLER_QUEUE = 21 + CONTROLLER_BRAKE = 22 + CONTROLLER_STACK = 23 + INVALID_DEST = 24 + NOT_HOMED = 25 + COULD_NOT_SEND_COMMAND = 26 + NO_RESPONSE = 27 + RABBIT_AGILE_COMM = 28 + AGILE_RABBIT_CRC = 29 + RABBIT_UNKNOWN_COMMAND = 30 + AGILE_UNKNOWN_ERROR = 31 + INVALID_AGILE_RESPONSE = 32 + DETECT_PUMPS = 33 + INVALID_NMC = 34 + UNKNOWN_RABBIT_ERROR = 35 + INVALID_TIP_TYPE = 37 + UNRESPONSIVE_NMC_MODULE = 38 + ROBOT_DISABLE_BUTTON = 39 + COULD_NOT_DETECT_HEAD = 40 + COULD_NOT_DETECT_GRIPPER = 41 + COULD_NOT_CLEAR_MOTOR_POWER = 42 + COULD_NOT_HOME = 43 + COULD_NOT_MOVE_TO_POSITION = 44 + COULD_NOT_ENABLE_MOTOR = 45 + COULD_NOT_DISABLE_MOTOR = 46 + COULD_NOT_READ_POSITION = 47 + COULD_NOT_SET_LIGHT = 48 + GRIP_POSITION = 49 + COULD_NOT_DETECT_SMART_HEAD = 50 + NODEZERO_NO_SERIAL_COMM = 51 + DARWIN_SOFTWARE_INTERNAL = 52 + DARWIN_GENERIC = 53 + + +_ERROR_MESSAGES: dict[ErrorType, str] = { + ErrorType.NO_ERROR: "No error.", + ErrorType.COULD_NOT_CONNECT: "Could not connect to device.", + ErrorType.COULD_NOT_PING: "Could not ping device.", + ErrorType.COULD_NOT_QUERY_FIRMWARE: "Could not query firmware version.", + ErrorType.COULD_NOT_QUERY_STATE: "Could not query device state.", + ErrorType.COULD_NOT_QUERY_GO_BUTTON: "Could not query Go button state.", + ErrorType.UNIQUE_VALUE: "Processor-controller communication validation failed.", + ErrorType.REGISTER_NOT_READ: "Could not read register.", + ErrorType.COULD_NOT_ALIGN: "Could not align motor.", + ErrorType.STOP_COMMAND: "Motion was stopped.", + ErrorType.CONTROLLER_UNIDENTIFIED: "Unidentified controller error.", + ErrorType.ROBOT_DISABLE: "Robot safety interlock is active (E-stop).", + ErrorType.MOTOR_POWER: "Motor power fault detected.", + ErrorType.MOVE_POSITION: "Position error during move.", + ErrorType.MOVE_TIMEOUT: "Timeout while moving to position.", + ErrorType.EXCEEDED_DEST: "Exceeded destination position.", + ErrorType.UNABLE_TO_REACH_DEST: "Unable to reach destination position.", + ErrorType.AMP_SHORT_CIRCUIT: "Amplifier short circuit detected.", + ErrorType.ENCODER: "Encoder failure.", + ErrorType.CONTROLLER_FATAL: "Fatal controller error.", + ErrorType.CONTROLLER_INTERNAL: "Internal controller error.", + ErrorType.CONTROLLER_QUEUE: "Controller command queue error.", + ErrorType.CONTROLLER_BRAKE: "Controller brake error.", + ErrorType.CONTROLLER_STACK: "Controller stack error.", + ErrorType.INVALID_DEST: "Invalid destination position.", + ErrorType.NOT_HOMED: "Axis is not homed.", + ErrorType.COULD_NOT_SEND_COMMAND: "Could not send command to device.", + ErrorType.NO_RESPONSE: "No response from device.", + ErrorType.RABBIT_AGILE_COMM: "Rabbit-to-Agile communication failure.", + ErrorType.AGILE_RABBIT_CRC: "Agile-to-Rabbit CRC mismatch.", + ErrorType.RABBIT_UNKNOWN_COMMAND: "Unknown command sent to Rabbit.", + ErrorType.AGILE_UNKNOWN_ERROR: "Unknown Agile controller error.", + ErrorType.INVALID_AGILE_RESPONSE: "Invalid response from Agile controller.", + ErrorType.DETECT_PUMPS: "Could not detect pumps.", + ErrorType.INVALID_NMC: "Invalid NMC module.", + ErrorType.UNKNOWN_RABBIT_ERROR: "Unknown Rabbit firmware error.", + ErrorType.INVALID_TIP_TYPE: "Invalid tip type for this head.", + ErrorType.UNRESPONSIVE_NMC_MODULE: "NMC module is unresponsive.", + ErrorType.ROBOT_DISABLE_BUTTON: "Robot disable button circuitry failure.", + ErrorType.COULD_NOT_DETECT_HEAD: "Could not detect pipette head.", + ErrorType.COULD_NOT_DETECT_GRIPPER: "Could not detect gripper.", + ErrorType.COULD_NOT_CLEAR_MOTOR_POWER: "Could not clear motor power fault.", + ErrorType.COULD_NOT_HOME: "Could not home axis.", + ErrorType.COULD_NOT_MOVE_TO_POSITION: "Could not move to position.", + ErrorType.COULD_NOT_ENABLE_MOTOR: "Could not enable motor.", + ErrorType.COULD_NOT_DISABLE_MOTOR: "Could not disable motor.", + ErrorType.COULD_NOT_READ_POSITION: "Could not read axis position.", + ErrorType.COULD_NOT_SET_LIGHT: "Could not set indicator light.", + ErrorType.GRIP_POSITION: "Gripper position error — is the plate missing?", + ErrorType.COULD_NOT_DETECT_SMART_HEAD: "Could not detect smart head.", + ErrorType.NODEZERO_NO_SERIAL_COMM: ( + "Node Zero does not support serial communication (use ethernet)." + ), + ErrorType.DARWIN_SOFTWARE_INTERNAL: "Darwin controller internal software error.", + ErrorType.DARWIN_GENERIC: "Error from the Gemini API.", +} + + +class BravoError(Exception): + """A structured error raised from the Bravo hardware or driver. + + Carries the :class:`ErrorType` category and, when the fault is + axis-specific, the affected :data:`~pylabrobot.agilent.bravo.types.Axis`. + """ + + def __init__( + self, + error_type: ErrorType, + axis: Optional[Axis] = None, + custom_text: Optional[str] = None, + ): + """Create a structured Bravo error. + + Args: + error_type: The error category. + axis: The axis the error applies to, if any. + custom_text: A message to use instead of the default for + ``error_type``. + """ + self.error_type = error_type + self.axis = axis + self.custom_text = custom_text + super().__init__(str(self)) + + def __str__(self) -> str: + """Return the human-readable error message.""" + if self.custom_text: + return self.custom_text + msg = _ERROR_MESSAGES.get(self.error_type, f"Unknown error ({self.error_type}).") + if self.axis is not None: + msg = f"{msg} ({axis_label(self.axis)})" + return msg + + def __repr__(self) -> str: + """Return an unambiguous, debugging-oriented representation.""" + parts = [f"error_type={self.error_type.name}"] + if self.axis is not None: + parts.append(f"axis={axis_display_name(self.axis)}") + if self.custom_text: + parts.append(f"custom_text={self.custom_text!r}") + return f"BravoError({', '.join(parts)})" + + +class RabbitErrorCode(IntEnum): + """Error codes returned by the Rabbit firmware in response byte 0.""" + + NONE = 0x00 + BAD_COMMUNICATION = 0x01 + UNKNOWN_COMMAND = 0x03 + AGILE_CRC = 0x04 + AGILE_UNKNOWN = 0x05 + BAD_ARGS = 0x06 + ROBOT_DISABLE = 0x07 + MOTOR_POWER_FAULT = 0x08 + PUMP_INIT = 0x09 + INVALID_NMC_MODULE = 0x0A + UNRESPONSIVE_NMC_MODULE = 0x0B + ROBOT_DISABLE_BUTTON = 0x0C + GRIP_POSITION = 0x0D + # 0x20-0x25: NOT_HOMED + axis offset + NOT_HOMED_X = 0x20 + NOT_HOMED_Y = 0x21 + NOT_HOMED_Z = 0x22 + NOT_HOMED_W = 0x23 + NOT_HOMED_G = 0x24 + NOT_HOMED_ZG = 0x25 + + +def rabbit_error_to_bravo_error(code: int) -> BravoError: + """Convert a Rabbit firmware error code to a :class:`BravoError`. + + Args: + code: The raw error byte from a Rabbit response frame. + + Returns: + The corresponding structured error. An unrecognized code maps to + :attr:`ErrorType.UNKNOWN_RABBIT_ERROR`. + """ + if code == RabbitErrorCode.NONE: + return BravoError(ErrorType.NO_ERROR) + if 0x20 <= code <= 0x25: + axis = _AXIS_BY_CODE[code - 0x20] + return BravoError(ErrorType.NOT_HOMED, axis=axis) + + _mapping: dict[int, ErrorType] = { + 0x01: ErrorType.RABBIT_AGILE_COMM, + 0x03: ErrorType.RABBIT_UNKNOWN_COMMAND, + 0x04: ErrorType.AGILE_RABBIT_CRC, + 0x05: ErrorType.AGILE_UNKNOWN_ERROR, + 0x06: ErrorType.INVALID_AGILE_RESPONSE, + 0x07: ErrorType.ROBOT_DISABLE, + 0x08: ErrorType.MOTOR_POWER, + 0x09: ErrorType.DETECT_PUMPS, + 0x0A: ErrorType.INVALID_NMC, + 0x0B: ErrorType.UNRESPONSIVE_NMC_MODULE, + 0x0C: ErrorType.ROBOT_DISABLE_BUTTON, + 0x0D: ErrorType.GRIP_POSITION, + } + error_type = _mapping.get(code, ErrorType.UNKNOWN_RABBIT_ERROR) + return BravoError(error_type) diff --git a/pylabrobot/agilent/bravo/errors_tests.py b/pylabrobot/agilent/bravo/errors_tests.py new file mode 100644 index 00000000000..7712a58f265 --- /dev/null +++ b/pylabrobot/agilent/bravo/errors_tests.py @@ -0,0 +1,73 @@ +import unittest + +from pylabrobot.agilent.bravo.errors import ( + BravoError, + ErrorType, + RabbitErrorCode, + rabbit_error_to_bravo_error, +) + + +class BravoErrorTests(unittest.TestCase): + def test_default_message_for_error_type(self): + err = BravoError(ErrorType.ROBOT_DISABLE) + self.assertEqual(str(err), "Robot safety interlock is active (E-stop).") + + def test_message_includes_axis_label(self): + err = BravoError(ErrorType.NOT_HOMED, axis="zg") + self.assertEqual(str(err), "Axis is not homed. (Zg-axis)") + + def test_custom_text_overrides_default_message(self): + err = BravoError(ErrorType.NOT_HOMED, axis="x", custom_text="totally custom") + self.assertEqual(str(err), "totally custom") + + def test_repr_includes_error_type_and_axis(self): + err = BravoError(ErrorType.NOT_HOMED, axis="zg") + self.assertEqual(repr(err), "BravoError(error_type=NOT_HOMED, axis=Zg)") + + def test_repr_omits_absent_axis_and_custom_text(self): + err = BravoError(ErrorType.NO_ERROR) + self.assertEqual(repr(err), "BravoError(error_type=NO_ERROR)") + + def test_is_a_real_exception(self): + with self.assertRaises(BravoError): + raise BravoError(ErrorType.COULD_NOT_CONNECT) + + +class RabbitErrorConversionTests(unittest.TestCase): + def test_none_maps_to_no_error(self): + err = rabbit_error_to_bravo_error(RabbitErrorCode.NONE) + self.assertEqual(err.error_type, ErrorType.NO_ERROR) + + def test_not_homed_range_maps_to_correct_axis(self): + cases = { + 0x20: "x", + 0x21: "y", + 0x22: "z", + 0x23: "w", + 0x24: "g", + 0x25: "zg", + } + for code, axis in cases.items(): + err = rabbit_error_to_bravo_error(code) + self.assertEqual(err.error_type, ErrorType.NOT_HOMED) + self.assertEqual(err.axis, axis) + + def test_known_code_maps_to_mapped_error_type(self): + err = rabbit_error_to_bravo_error(RabbitErrorCode.ROBOT_DISABLE) + self.assertEqual(err.error_type, ErrorType.ROBOT_DISABLE) + + def test_unknown_code_falls_back(self): + err = rabbit_error_to_bravo_error(0x7F) + self.assertEqual(err.error_type, ErrorType.UNKNOWN_RABBIT_ERROR) + + +class ErrorTypeValueTests(unittest.TestCase): + def test_invalid_tip_type_is_37(self): + # 36 is deliberately skipped in the source numbering; pin this value so + # an accidental renumber (e.g. inserting a member above it) is caught. + self.assertEqual(ErrorType.INVALID_TIP_TYPE, 37) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/head_mode.py b/pylabrobot/agilent/bravo/head_mode.py new file mode 100644 index 00000000000..613f569a5ee --- /dev/null +++ b/pylabrobot/agilent/bravo/head_mode.py @@ -0,0 +1,972 @@ +"""Head-mode geometry: which barrels of the pipetting head are active, and where. + +The Bravo head is a fixed rectangular grid of barrels (8x1, 16x1, 8x12, or +16x24 depending on the installed :class:`~pylabrobot.agilent.bravo.types.HeadType`). +Every operation the head performs — full-plate aspirate, a single column, +one barrel — is a contiguous rectangular block of that grid anchored at one +of its four corners (``back_left``, ``back_right``, ``front_left``, +``front_right``). This module is the single source of truth for that +geometry: normalising a caller's requested subset into a concrete +:class:`HeadMode`, computing which barrels are active and where they sit +relative to a tipbox or plate, and enumerating which tipbox/plate anchor +positions are physically reachable given tips or wells already consumed. + +Row 0 is the physical back row and column 0 is the physical left column, +matching the deck's coordinate frame; "front"/"back" and "left"/"right" in +this module always refer to that physical orientation, not to array index +direction. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Optional, Union + +from .types import HeadType, head_type_channels + +_FRONT_ORIENTATIONS = {"front_left", "front_right"} +_LEFT_ORIENTATIONS = {"front_left", "back_left"} + +# 384-family heads: on a 384-pitch plate the barrel-to-well mapping is 1:1, +# so plate-phase counting treats them differently from the 96-family heads. +_HEAD_384_FAMILY = frozenset( + { + "384_d_70", + "384_d_70_s2", + "384_f_50", + "384_pintool", + } +) + + +@dataclass(frozen=True) +class HeadGeometry: + """The physical barrel grid of an installed head. + + Attributes: + rows: Number of barrel rows. + columns: Number of barrel columns. + pitch_x_mm: Barrel-to-barrel spacing along columns, in millimetres. + pitch_y_mm: Barrel-to-barrel spacing along rows, in millimetres. + """ + + rows: int + columns: int + pitch_x_mm: float + pitch_y_mm: float + + +@dataclass(frozen=True) +class HeadMode: + """A normalised description of which head barrels are active. + + Always the output of :func:`normalize_head_mode` — never construct one + directly, since ``row_count``/``column_count`` must already be clamped to + the installed head's geometry for the rest of this module to behave + correctly. + + Attributes: + subset_type: One of ``"all_barrels"``, ``"row"``, ``"column"``, + ``"rectangle"``, or ``"single_barrel"``. + subset_config: The anchor corner: ``"front_left"``, ``"front_right"``, + ``"back_left"``, or ``"back_right"``. + row_count: Number of active barrel rows. + column_count: Number of active barrel columns. + """ + + subset_type: str = "all_barrels" + subset_config: str = "front_left" + row_count: int = 0 + column_count: int = 0 + + @property + def num_channels(self) -> int: + """Return the number of active barrels.""" + return int(self.row_count) * int(self.column_count) + + def to_dict(self) -> dict[str, object]: + """Return this mode as a plain dict, for logging or serialization. + + Returns: + The dataclass fields plus ``num_channels`` and a human-readable + ``display_text``. + """ + data = asdict(self) + data["num_channels"] = self.num_channels + data["display_text"] = describe_head_mode(self) + return data + + +@dataclass(frozen=True) +class TipSelection: + """A tipbox anchor position paired with the head mode picking from it. + + Attributes: + location: The deck location of the tipbox. + row: Zero-based row of the anchor cell in the tipbox. + col: Zero-based column of the anchor cell in the tipbox. + row_count: Number of tipbox rows the selection spans. + column_count: Number of tipbox columns the selection spans. + mirror_corner: The tipbox corner the selection is measured from. + head_anchor: The head corner that aligns with the tipbox anchor cell. + """ + + location: int + row: int + col: int + row_count: int = 1 + column_count: int = 1 + mirror_corner: str = "back_left" + head_anchor: str = "back_left" + + def to_dict(self) -> dict[str, Union[int, str]]: + """Return this selection as a plain dict, including the resolved anchor cell. + + Returns: + The dataclass fields plus ``anchor_row``/``anchor_col``, the tipbox + cell that aligns with the active head anchor. + """ + anchor_row, anchor_col = tipbox_anchor_cell(self) + return { + "location": self.location, + "row": self.row, + "col": self.col, + "row_count": self.row_count, + "column_count": self.column_count, + "mirror_corner": self.mirror_corner, + "head_anchor": self.head_anchor, + "anchor_row": anchor_row, + "anchor_col": anchor_col, + } + + +@dataclass(frozen=True) +class PlateSelection: + """A single anchor cell on a plate. + + Attributes: + location: The deck location of the plate. + row: Zero-based row of the anchor well. + col: Zero-based column of the anchor well. + """ + + location: int + row: int + col: int + + def to_dict(self) -> dict[str, int]: + """Return this selection as a plain dict. + + Returns: + The dataclass fields as a dict. + """ + return { + "location": self.location, + "row": self.row, + "col": self.col, + } + + +@dataclass(frozen=True) +class TipAnchor: + """A legal tipbox anchor position, without a specific deck location. + + Attributes: + row: Zero-based row of the anchor cell in the tipbox. + col: Zero-based column of the anchor cell in the tipbox. + row_count: Number of tipbox rows the selection spans. + column_count: Number of tipbox columns the selection spans. + mirror_corner: The tipbox corner the selection is measured from. + head_anchor: The head corner that aligns with the tipbox anchor cell. + """ + + row: int + col: int + + row_count: int + column_count: int + mirror_corner: str + head_anchor: str = "back_left" + + def to_dict(self) -> dict[str, Union[int, str]]: + """Return this anchor as a plain dict, including the resolved anchor cell. + + Returns: + The dataclass fields plus ``anchor_row``/``anchor_col``, the tipbox + cell that aligns with the active head anchor. + """ + anchor_row, anchor_col = tipbox_anchor_cell( + TipSelection( + location=0, + row=self.row, + col=self.col, + row_count=self.row_count, + column_count=self.column_count, + mirror_corner=self.mirror_corner, + head_anchor=self.head_anchor, + ) + ) + return { + "row": self.row, + "col": self.col, + "row_count": self.row_count, + "column_count": self.column_count, + "mirror_corner": self.mirror_corner, + "head_anchor": self.head_anchor, + "anchor_row": anchor_row, + "anchor_col": anchor_col, + } + + +def head_geometry_for_type(head_type: HeadType) -> HeadGeometry: + """Return the physical barrel grid for an installed head type. + + Args: + head_type: The installed head type. + + Returns: + The head's row/column count and barrel pitch. Every 96-channel head + (including ``"unknown"``, which is treated as a 96-head default) shares + the same 8x12, 9 mm grid. + """ + if head_type in _HEAD_384_FAMILY: + return HeadGeometry(rows=16, columns=24, pitch_x_mm=4.5, pitch_y_mm=4.5) + if head_type == "1536_pintool": + return HeadGeometry(rows=32, columns=48, pitch_x_mm=2.25, pitch_y_mm=2.25) + if head_type == "16_d_st": + return HeadGeometry(rows=16, columns=1, pitch_x_mm=4.5, pitch_y_mm=4.5) + if head_type == "8_d_lt": + return HeadGeometry(rows=8, columns=1, pitch_x_mm=9.0, pitch_y_mm=9.0) + return HeadGeometry(rows=8, columns=12, pitch_x_mm=9.0, pitch_y_mm=9.0) + + +def normalize_head_mode( + head_type: HeadType, + subset_type: Optional[str], + subset_config: Optional[str], + row_count: Optional[int] = None, + column_count: Optional[int] = None, +) -> HeadMode: + """Resolve a caller's requested head subset into a valid :class:`HeadMode`. + + Unrecognised or missing values fall back to sensible defaults rather than + raising, since this is the boundary where free-form input (a web request, + a saved protocol) becomes a value the rest of this module can trust: + ``quadrant`` becomes a half-size ``rectangle``; a subset type the current + head cannot support (e.g. ``row`` on a single-column head) falls back to + ``all_barrels``; and row/column counts are clamped to the head's geometry. + + Args: + head_type: The installed head type. + subset_type: The requested subset kind, e.g. ``"row"``, ``"column"``, + ``"rectangle"``, ``"single_barrel"``, ``"quadrant"``, or + ``"all_barrels"``. Anything else falls back to ``"all_barrels"``. + subset_config: The requested anchor corner. Anything other than + ``"front_left"``, ``"front_right"``, ``"back_left"``, or + ``"back_right"`` falls back to ``"back_left"``. + row_count: Requested active row count, for ``"row"``/``"rectangle"``. + column_count: Requested active column count, for + ``"column"``/``"rectangle"``. + + Returns: + A :class:`HeadMode` valid for ``head_type``. + """ + geometry = head_geometry_for_type(head_type) + normalized_type = str(subset_type or "all_barrels").strip().lower() + normalized_config = str(subset_config or "back_left").strip().lower() + if normalized_config not in {"front_left", "front_right", "back_left", "back_right"}: + normalized_config = "back_left" + + if normalized_type == "quadrant": + normalized_type = "rectangle" + if row_count is None: + row_count = max(1, geometry.rows // 2) + if column_count is None: + column_count = max(1, geometry.columns // 2) + + if geometry.rows <= 1 and normalized_type in {"row", "rectangle"}: + normalized_type = "all_barrels" + if geometry.columns <= 1 and normalized_type in {"column", "rectangle"}: + normalized_type = "all_barrels" + if normalized_type not in {"all_barrels", "row", "column", "single_barrel", "rectangle"}: + normalized_type = "all_barrels" + if normalized_type == "all_barrels": + normalized_config = "back_left" + + selected_rows = geometry.rows + selected_columns = geometry.columns + if normalized_type == "row": + selected_rows = max(1, min(geometry.rows, int(row_count or 1))) + elif normalized_type == "column": + selected_columns = max(1, min(geometry.columns, int(column_count or 1))) + elif normalized_type == "rectangle": + selected_rows = max(1, min(geometry.rows, int(row_count or 1))) + selected_columns = max(1, min(geometry.columns, int(column_count or 1))) + elif normalized_type == "single_barrel": + selected_rows = 1 + selected_columns = 1 + + return HeadMode( + subset_type=normalized_type, + subset_config=normalized_config, + row_count=selected_rows, + column_count=selected_columns, + ) + + +def head_selected_ranges( + head_type: HeadType, mode: HeadMode +) -> tuple[tuple[int, int], tuple[int, int]]: + """Return the active barrel range as ``(row_start, row_stop), (col_start, col_stop)``. + + Args: + head_type: The installed head type. + mode: A normalised head mode. + + Returns: + Half-open ``(start, stop)`` ranges for rows and for columns. + """ + geometry = head_geometry_for_type(head_type) + row_start, row_stop = _selected_range( + geometry.rows, + mode.row_count, + front_selected=mode.subset_config not in _FRONT_ORIENTATIONS, + ) + col_start, col_stop = _selected_range( + geometry.columns, + mode.column_count, + front_selected=mode.subset_config in _LEFT_ORIENTATIONS, + ) + return (row_start, row_stop), (col_start, col_stop) + + +def head_anchor_cell(head_type: HeadType, mode: HeadMode) -> tuple[int, int]: + """Return the (row, col) of the head's single reference barrel for this mode. + + Args: + head_type: The installed head type. + mode: A normalised head mode. + + Returns: + The active block's corner barrel closest to ``mode.subset_config``. + """ + if mode.subset_type == "all_barrels": + return 0, 0 + (row_start, row_stop), (col_start, col_stop) = head_selected_ranges(head_type, mode) + if mode.subset_type == "column": + row = 0 + col = col_start if mode.subset_config in _LEFT_ORIENTATIONS else col_stop - 1 + return row, col + if mode.subset_type == "row": + row = row_stop - 1 if mode.subset_config in _FRONT_ORIENTATIONS else row_start + col = 0 + return row, col + row = row_stop - 1 if mode.subset_config in _FRONT_ORIENTATIONS else row_start + col = col_start if mode.subset_config in _LEFT_ORIENTATIONS else col_stop - 1 + return row, col + + +def head_mode_offsets_mm(head_type: HeadType, mode: HeadMode) -> tuple[float, float]: + """Return the (x, y) offset from the head's origin barrel to the active block's origin. + + Args: + head_type: The installed head type. + mode: A normalised head mode. + + Returns: + The offset in millimetres. + """ + geometry = head_geometry_for_type(head_type) + (row_start, _), (col_start, _) = head_selected_ranges(head_type, mode) + return col_start * geometry.pitch_x_mm, row_start * geometry.pitch_y_mm + + +def active_head_wells(head_type: HeadType, mode: HeadMode) -> list[tuple[int, int]]: + """Return every (row, col) barrel position active under this mode. + + Args: + head_type: The installed head type. + mode: A normalised head mode. + + Returns: + All active barrel positions. + """ + (row_start, row_stop), (col_start, col_stop) = head_selected_ranges(head_type, mode) + return [(row, col) for row in range(row_start, row_stop) for col in range(col_start, col_stop)] + + +def tipbox_mirror_corner(mode: HeadMode) -> str: + """Return the tipbox corner a head mode should pick tips from. + + The tipbox side is the mirror image of the head's own anchor corner: a + head anchored at its own left picks from the tipbox's right, and so on, + since the head reaches across to the tips rather than starting flush + against them. + + Args: + mode: A normalised head mode. + + Returns: + One of ``"front_left"``, ``"front_right"``, ``"back_left"``, or + ``"back_right"``. + """ + if mode.subset_type == "all_barrels": + return "back_left" + if mode.subset_type == "column": + # Left head -> pick from right tipbox side, Right head -> pick from left + return "back_left" if mode.subset_config.endswith("right") else "back_right" + if mode.subset_type == "row": + return "front_left" if mode.subset_config.startswith("back") else "back_left" + front = mode.subset_config in _FRONT_ORIENTATIONS + left = mode.subset_config in _LEFT_ORIENTATIONS + tipbox_front = not front + tipbox_left = not left + return f"{'front' if tipbox_front else 'back'}_{'left' if tipbox_left else 'right'}" + + +def head_anchor_corner(mode: HeadMode) -> str: + """Return the tipbox corner where the head's reference barrel aligns. + + Args: + mode: A normalised head mode. + + Returns: + One of ``"front_left"``, ``"front_right"``, ``"back_left"``, or + ``"back_right"``. + """ + if mode.subset_type == "all_barrels": + return "back_left" + if mode.subset_type == "column": + return "back_left" if mode.subset_config.endswith("left") else "back_right" + if mode.subset_type == "row": + return "back_left" if mode.subset_config.startswith("back") else "front_left" + front = mode.subset_config in _FRONT_ORIENTATIONS + left = mode.subset_config in _LEFT_ORIENTATIONS + return f"{'front' if front else 'back'}_{'left' if left else 'right'}" + + +def tipbox_selection( + location: int, + row: int, + col: int, + mode: HeadMode, +) -> TipSelection: + """Build a :class:`TipSelection` for picking tips at a tipbox anchor cell. + + Args: + location: The deck location of the tipbox. + row: Zero-based row of the anchor cell in the tipbox. + col: Zero-based column of the anchor cell in the tipbox. + mode: A normalised head mode. + + Returns: + The selection, sized and oriented to match ``mode``. + """ + return TipSelection( + location=location, + row=int(row), + col=int(col), + row_count=max(1, int(mode.row_count)), + column_count=max(1, int(mode.column_count)), + mirror_corner=tipbox_mirror_corner(mode), + head_anchor=head_anchor_corner(mode), + ) + + +def plate_selection( + location: int, + row: int, + col: int, +) -> PlateSelection: + """Build a :class:`PlateSelection` for a plate anchor cell. + + Args: + location: The deck location of the plate. + row: Zero-based row of the anchor well. + col: Zero-based column of the anchor well. + + Returns: + The selection. + """ + return PlateSelection(location=location, row=int(row), col=int(col)) + + +def selected_anchor_ranges( + total_rows: int, + total_cols: int, + selection: TipSelection, +) -> tuple[tuple[int, int], tuple[int, int]]: + """Return the tipbox cell range a selection covers, clamped to the tipbox. + + Args: + total_rows: Number of rows in the tipbox. + total_cols: Number of columns in the tipbox. + selection: The anchor and span to resolve. + + Returns: + Half-open ``(row_start, row_stop), (col_start, col_stop)`` ranges. + """ + row_start = max(0, min(total_rows - selection.row_count, int(selection.row))) + col_start = max(0, min(total_cols - selection.column_count, int(selection.col))) + return (row_start, row_start + selection.row_count), ( + col_start, + col_start + selection.column_count, + ) + + +def tipbox_anchor_cell(selection: TipSelection) -> tuple[int, int]: + """Return the physical tipbox cell that aligns with the active head anchor. + + Args: + selection: The tip selection to resolve. + + Returns: + The (row, col) of the tipbox cell under the head's reference barrel. + """ + anchor_row = ( + selection.row + selection.row_count - 1 + if selection.head_anchor.startswith("front") + else selection.row + ) + anchor_col = ( + selection.col + selection.column_count - 1 + if selection.head_anchor.endswith("right") + else selection.col + ) + return anchor_row, anchor_col + + +def selected_tip_wells( + total_rows: int, + total_cols: int, + selection: TipSelection, +) -> list[tuple[int, int]]: + """Return every tipbox cell a selection covers. + + Args: + total_rows: Number of rows in the tipbox. + total_cols: Number of columns in the tipbox. + selection: The anchor and span to resolve. + + Returns: + All covered (row, col) cells. + """ + (row_start, row_stop), (col_start, col_stop) = selected_anchor_ranges( + total_rows, + total_cols, + selection, + ) + return [(row, col) for row in range(row_start, row_stop) for col in range(col_start, col_stop)] + + +def describe_head_mode(mode: HeadMode) -> str: + """Return a short human-readable description of a head mode. + + Args: + mode: The head mode to describe. + + Returns: + A string such as ``"Rectangle (Back Left, 4x6)"``. + """ + label_map = { + "all_barrels": "All barrels", + "row": "Full row", + "column": "Full column", + "rectangle": "Rectangle", + "single_barrel": "Single barrel", + } + orientation = mode.subset_config.replace("_", " ").title() + if mode.subset_type == "all_barrels": + return "All barrels" + if mode.subset_type == "row": + row_word = "row" if mode.row_count == 1 else "rows" + return ( + f"{label_map.get(mode.subset_type, mode.subset_type)} " + f"({orientation}, {mode.row_count} {row_word})" + ) + if mode.subset_type == "column": + col_word = "column" if mode.column_count == 1 else "columns" + return ( + f"{label_map.get(mode.subset_type, mode.subset_type)} " + f"({orientation}, {mode.column_count} {col_word})" + ) + if mode.subset_type == "rectangle": + return ( + f"{label_map.get(mode.subset_type, mode.subset_type)} " + f"({orientation}, {mode.row_count}x{mode.column_count})" + ) + return f"{label_map.get(mode.subset_type, mode.subset_type)} ({orientation})" + + +def suggested_head_mode(head_type: HeadType, wells: Optional[int]) -> HeadMode: + """Suggest a head mode that covers a target well count on a plate. + + Args: + head_type: The installed head type. + wells: The number of wells the operation targets, if known. + + Returns: + ``"all_barrels"`` when ``wells`` is unknown or matches the head's own + channel count; a partial-head mode sized to reach a denser well grid + (e.g. a 96 head striping a 384 plate); ``"all_barrels"`` as the + fallback for any combination not otherwise handled. + """ + channel_count = head_type_channels(head_type) + if not wells or wells <= 0: + return normalize_head_mode(head_type, "all_barrels", "front_left") + if wells == channel_count: + return normalize_head_mode(head_type, "all_barrels", "front_left") + if channel_count == 96 and wells == 384: + return normalize_head_mode(head_type, "rectangle", "front_left", row_count=8, column_count=12) + if channel_count == 8 and wells in {96, 384}: + return normalize_head_mode(head_type, "column", "front_left") + if channel_count == 16 and wells == 384: + return normalize_head_mode(head_type, "row", "front_left") + return normalize_head_mode(head_type, "all_barrels", "front_left") + + +def _selected_range(total: int, selected: int, *, front_selected: bool) -> tuple[int, int]: + """Return the half-open range of ``selected`` items out of ``total``, from one end. + + Args: + total: The full size along this axis. + selected: How many of ``total`` are active. + front_selected: If True, the range starts at index 0; otherwise it ends + at ``total``. + + Returns: + A half-open ``(start, stop)`` range. + """ + if selected >= total: + return 0, total + if front_selected: + return 0, selected + return total - selected, total + + +def legal_plate_anchors( + head_type: HeadType, + mode: HeadMode, + plate_rows: int, + plate_cols: int, + pitch_x_mm: float, + pitch_y_mm: float, +) -> list[PlateSelection]: + """Return every plate anchor cell where this head mode's footprint fits the plate. + + Args: + head_type: The installed head type. + mode: A normalised head mode. + plate_rows: Number of well rows on the plate. + plate_cols: Number of well columns on the plate. + pitch_x_mm: Plate well spacing along columns, in millimetres. + pitch_y_mm: Plate well spacing along rows, in millimetres. + + Returns: + Every legal anchor, as a :class:`PlateSelection` with ``location=0``. + """ + if plate_rows <= 0 or plate_cols <= 0: + return [] + anchors: list[PlateSelection] = [] + for row in range(plate_rows): + for col in range(plate_cols): + if is_legal_plate_anchor( + head_type, + mode, + plate_rows, + plate_cols, + pitch_x_mm, + pitch_y_mm, + row, + col, + ): + anchors.append(PlateSelection(location=0, row=row, col=col)) + return anchors + + +def is_legal_plate_anchor( + head_type: HeadType, + mode: HeadMode, + plate_rows: int, + plate_cols: int, + pitch_x_mm: float, + pitch_y_mm: float, + anchor_row: int, + anchor_col: int, + *, + tolerance: float = 1e-6, +) -> bool: + """Return whether a head mode's footprint fits the plate when anchored at a cell. + + Args: + head_type: The installed head type. + mode: A normalised head mode. + plate_rows: Number of well rows on the plate. + plate_cols: Number of well columns on the plate. + pitch_x_mm: Plate well spacing along columns, in millimetres. + pitch_y_mm: Plate well spacing along rows, in millimetres. + anchor_row: Zero-based row of the candidate anchor well. + anchor_col: Zero-based column of the candidate anchor well. + tolerance: Maximum deviation allowed when checking that the head pitch + is an integer multiple of the plate pitch. + + Returns: + True if every active barrel maps onto a well within the plate. + """ + return bool( + plate_footprint_wells( + head_type, + mode, + plate_rows, + plate_cols, + pitch_x_mm, + pitch_y_mm, + anchor_row, + anchor_col, + tolerance=tolerance, + ) + ) + + +def plate_footprint_wells( + head_type: HeadType, + mode: HeadMode, + plate_rows: int, + plate_cols: int, + pitch_x_mm: float, + pitch_y_mm: float, + anchor_row: int, + anchor_col: int, + *, + tolerance: float = 1e-6, +) -> list[tuple[int, int]]: + """Map a head mode's active barrels onto plate wells, anchored at a cell. + + Args: + head_type: The installed head type. + mode: A normalised head mode. + plate_rows: Number of well rows on the plate. + plate_cols: Number of well columns on the plate. + pitch_x_mm: Plate well spacing along columns, in millimetres. + pitch_y_mm: Plate well spacing along rows, in millimetres. + anchor_row: Zero-based row of the anchor well. + anchor_col: Zero-based column of the anchor well. + tolerance: Maximum deviation allowed when checking that the head pitch + is an integer multiple of the plate pitch. + + Returns: + The mapped (row, col) well for every active barrel, in the same order + as :func:`active_head_wells`. Empty if the head pitch is not an integer + multiple of the plate pitch, or if any mapped well would fall outside + the plate. + """ + if plate_rows <= 0 or plate_cols <= 0 or pitch_x_mm <= 0 or pitch_y_mm <= 0: + return [] + geometry = head_geometry_for_type(head_type) + step_row = _near_integer(geometry.pitch_y_mm / pitch_y_mm, tolerance) + step_col = _near_integer(geometry.pitch_x_mm / pitch_x_mm, tolerance) + if step_row is None or step_col is None or step_row <= 0 or step_col <= 0: + return [] + (sel_row_start, _), (sel_col_start, _) = head_selected_ranges(head_type, mode) + mapped: list[tuple[int, int]] = [] + for barrel_row, barrel_col in active_head_wells(head_type, mode): + mapped_row = int(anchor_row) + (barrel_row - sel_row_start) * step_row + mapped_col = int(anchor_col) + (barrel_col - sel_col_start) * step_col + if mapped_row < 0 or mapped_row >= plate_rows or mapped_col < 0 or mapped_col >= plate_cols: + return [] + mapped.append((mapped_row, mapped_col)) + return mapped + + +def _near_integer(value: float, tolerance: float) -> Optional[int]: + """Round ``value`` to the nearest integer if it is within ``tolerance`` of one. + + Args: + value: The value to check. + tolerance: Maximum allowed deviation from the nearest integer. + + Returns: + The rounded integer, or None if ``value`` is not close enough to one. + """ + rounded = int(round(value)) + if abs(value - rounded) > tolerance: + return None + return rounded + + +def legal_tipbox_anchors( + total_rows: int, + total_cols: int, + mode: HeadMode, + occupied_wells: set[tuple[int, int]], + *, + purpose: str, +) -> list[TipAnchor]: + """Return every legal tipbox anchor for picking up or returning tips. + + Iterates from the mode's mirror corner inward, so the first legal anchor + in the returned list is the one closest to that corner. + + Args: + total_rows: Number of rows in the tipbox. + total_cols: Number of columns in the tipbox. + mode: A normalised head mode. + occupied_wells: The tipbox cells that currently hold a tip. + purpose: ``"pickup"`` to find anchors where every covered cell already + holds a tip, or ``"return"`` to find anchors where every covered cell + is empty. + + Returns: + Every legal anchor, as a :class:`TipAnchor`. + + Raises: + ValueError: If ``purpose`` is neither ``"pickup"`` nor ``"return"``. + """ + if total_rows <= 0 or total_cols <= 0: + return [] + occupied = set(occupied_wells) + anchors: list[TipAnchor] = [] + max_row = max(0, total_rows - mode.row_count) + max_col = max(0, total_cols - mode.column_count) + + # Determine iteration order based on the mirror corner so the first + # legal anchor is on the correct side of the tipbox. + mirror = tipbox_mirror_corner(mode) + col_range = range(max_col, -1, -1) if mirror.endswith("right") else range(max_col + 1) + row_range = range(max_row, -1, -1) if mirror.startswith("front") else range(max_row + 1) + + for row in row_range: + for col in col_range: + selection = tipbox_selection(0, row, col, mode) + if _is_legal_tipbox_anchor(total_rows, total_cols, occupied, selection, purpose=purpose): + anchors.append( + TipAnchor( + row=row, + col=col, + row_count=selection.row_count, + column_count=selection.column_count, + mirror_corner=selection.mirror_corner, + head_anchor=selection.head_anchor, + ) + ) + return anchors + + +def is_legal_tipbox_anchor( + total_rows: int, + total_cols: int, + mode: HeadMode, + occupied_wells: set[tuple[int, int]], + selection_row: int, + selection_col: int, + *, + purpose: str, +) -> bool: + """Return whether a specific tipbox anchor is legal for pickup or return. + + Args: + total_rows: Number of rows in the tipbox. + total_cols: Number of columns in the tipbox. + mode: A normalised head mode. + occupied_wells: The tipbox cells that currently hold a tip. + selection_row: Zero-based row of the candidate anchor cell. + selection_col: Zero-based column of the candidate anchor cell. + purpose: ``"pickup"`` or ``"return"``; see :func:`legal_tipbox_anchors`. + + Returns: + True if the anchor is legal. + """ + if total_rows <= 0 or total_cols <= 0: + return False + selection = tipbox_selection(0, selection_row, selection_col, mode) + return _is_legal_tipbox_anchor( + total_rows, + total_cols, + set(occupied_wells), + selection, + purpose=purpose, + ) + + +def _is_legal_tipbox_anchor( + total_rows: int, + total_cols: int, + occupied: set[tuple[int, int]], + selection: TipSelection, + *, + purpose: str, +) -> bool: + """Return whether a resolved tipbox selection is legal for pickup or return. + + Args: + total_rows: Number of rows in the tipbox. + total_cols: Number of columns in the tipbox. + occupied: The tipbox cells that currently hold a tip. + selection: The anchor and span to check. + purpose: ``"pickup"`` or ``"return"``; see :func:`legal_tipbox_anchors`. + + Returns: + True if the anchor is legal. + + Raises: + ValueError: If ``purpose`` is neither ``"pickup"`` nor ``"return"``. + """ + (row_start, row_stop), (col_start, col_stop) = selected_anchor_ranges( + total_rows, + total_cols, + selection, + ) + selected = { + (row, col) for row in range(row_start, row_stop) for col in range(col_start, col_stop) + } + if not selected: + return False + if purpose == "pickup": + if any(well not in occupied for well in selected): + return False + elif purpose == "return": + if any(well in occupied for well in selected): + return False + else: + raise ValueError(f"Unknown tipbox anchor purpose: {purpose}") + + if purpose == "return": + # Returns are anchored by the operator, not by the box. The first + # ejection into an empty box may go anywhere, and every later one has + # to sit flush against what is already there — so the head walks + # steadily across the box in whichever direction that first choice + # implied, and the filled region stays contiguous. The pickup rule below + # (outboard side must be empty) does not apply here: demanding an empty + # outboard side on every return would reject all but the very first + # return into a box, since every later return has occupied cells + # outboard of it by construction. + if not occupied: + return True + # Step along whichever axis the block does not already span: a + # full-column head walks across columns, a full-row head down rows. + if (row_stop - row_start) >= total_rows: + band = {col for _, col in occupied} + start, stop = col_start, col_stop + else: + band = {row for row, _ in occupied} + start, stop = row_start, row_stop + return stop == min(band) or start == max(band) + 1 + + # Pickup: consume from the mirror corner inward, so everything outboard of + # the block must already be empty. Keeps the head taking the outermost + # remaining block rather than orphaning tips behind it. + if selection.mirror_corner.endswith("left"): + boundary_cols = range(0, col_start) + else: + boundary_cols = range(col_stop, total_cols) + if any((row, col) in occupied for row in range(row_start, row_stop) for col in boundary_cols): + return False + + if selection.mirror_corner.startswith("front"): + boundary_rows = range(row_stop, total_rows) + else: + boundary_rows = range(0, row_start) + if any((row, col) in occupied for row in boundary_rows for col in range(col_start, col_stop)): + return False + + return True diff --git a/pylabrobot/agilent/bravo/head_mode_tests.py b/pylabrobot/agilent/bravo/head_mode_tests.py new file mode 100644 index 00000000000..cf0db5fd2b1 --- /dev/null +++ b/pylabrobot/agilent/bravo/head_mode_tests.py @@ -0,0 +1,311 @@ +import unittest + +from pylabrobot.agilent.bravo.head_mode import ( + HeadGeometry, + HeadMode, + TipSelection, + active_head_wells, + describe_head_mode, + head_anchor_cell, + head_geometry_for_type, + head_selected_ranges, + is_legal_tipbox_anchor, + legal_tipbox_anchors, + normalize_head_mode, + plate_footprint_wells, + selected_anchor_ranges, + suggested_head_mode, + tipbox_anchor_cell, + tipbox_mirror_corner, +) +from pylabrobot.agilent.bravo.types import HeadType + + +class HeadGeometryTests(unittest.TestCase): + def test_96_head_is_8x12_at_9mm(self): + self.assertEqual( + head_geometry_for_type("96_d_70"), + HeadGeometry(rows=8, columns=12, pitch_x_mm=9.0, pitch_y_mm=9.0), + ) + + def test_8_d_lt_is_8x1_at_9mm(self): + self.assertEqual( + head_geometry_for_type("8_d_lt"), + HeadGeometry(rows=8, columns=1, pitch_x_mm=9.0, pitch_y_mm=9.0), + ) + + def test_16_d_st_is_16x1_at_4_5mm(self): + self.assertEqual( + head_geometry_for_type("16_d_st"), + HeadGeometry(rows=16, columns=1, pitch_x_mm=4.5, pitch_y_mm=4.5), + ) + + def test_384_head_is_16x24(self): + geometry = head_geometry_for_type("384_d_70") + self.assertEqual(geometry.rows, 16) + self.assertEqual(geometry.columns, 24) + + def test_384_family_all_share_the_same_geometry(self): + expected = head_geometry_for_type("384_d_70") + for head_type in ("384_d_70_s2", "384_f_50", "384_pintool"): + self.assertEqual(head_geometry_for_type(head_type), expected) + + def test_unknown_head_falls_back_to_96_geometry(self): + self.assertEqual(head_geometry_for_type("unknown"), head_geometry_for_type("96_d_70")) + + +class NormalizeHeadModeTests(unittest.TestCase): + def test_all_barrels_selects_the_full_head(self): + mode = normalize_head_mode("96_d_70", "all_barrels", "front_left") + self.assertEqual(mode.subset_type, "all_barrels") + self.assertEqual(mode.row_count, 8) + self.assertEqual(mode.column_count, 12) + # all_barrels always normalizes to back_left regardless of the request. + self.assertEqual(mode.subset_config, "back_left") + + def test_single_barrel_is_1x1(self): + mode = normalize_head_mode("96_d_70", "single_barrel", "front_left") + self.assertEqual(mode.row_count, 1) + self.assertEqual(mode.column_count, 1) + + def test_column_keeps_all_rows(self): + mode = normalize_head_mode("96_d_70", "column", "front_left", column_count=3) + self.assertEqual(mode.subset_type, "column") + self.assertEqual(mode.row_count, 8) + self.assertEqual(mode.column_count, 3) + + def test_row_keeps_all_columns(self): + mode = normalize_head_mode("96_d_70", "row", "front_left", row_count=2) + self.assertEqual(mode.subset_type, "row") + self.assertEqual(mode.row_count, 2) + self.assertEqual(mode.column_count, 12) + + def test_quadrant_normalizes_to_half_size_rectangle(self): + mode = normalize_head_mode("96_d_70", "quadrant", "front_left") + self.assertEqual(mode.subset_type, "rectangle") + self.assertEqual(mode.row_count, 4) + self.assertEqual(mode.column_count, 6) + + def test_quadrant_respects_explicit_counts(self): + mode = normalize_head_mode("96_d_70", "quadrant", "front_left", row_count=2, column_count=2) + self.assertEqual(mode.subset_type, "rectangle") + self.assertEqual(mode.row_count, 2) + self.assertEqual(mode.column_count, 2) + + def test_unknown_subset_type_falls_back_to_all_barrels(self): + mode = normalize_head_mode("96_d_70", "not-a-real-subset", "front_left") + self.assertEqual(mode.subset_type, "all_barrels") + + def test_missing_subset_type_falls_back_to_all_barrels(self): + mode = normalize_head_mode("96_d_70", None, None) + self.assertEqual(mode.subset_type, "all_barrels") + + def test_column_on_single_column_head_falls_back_to_all_barrels(self): + # 8_d_lt has 1 column, so a "column" subset (which selects among columns) + # cannot be satisfied and collapses to the whole head. + mode = normalize_head_mode("8_d_lt", "column", "front_left", column_count=1) + self.assertEqual(mode.subset_type, "all_barrels") + + def test_row_on_single_column_head_stays_row(self): + # 8_d_lt has 8 rows, so "row" (which selects among rows) is unaffected + # by the single-column collapse rule, which only fires for + # "column"/"rectangle". + mode = normalize_head_mode("8_d_lt", "row", "front_left", row_count=2) + self.assertEqual(mode.subset_type, "row") + self.assertEqual(mode.row_count, 2) + + def test_unrecognized_subset_config_falls_back_to_back_left(self): + mode = normalize_head_mode("96_d_70", "rectangle", "not-a-corner", row_count=2, column_count=2) + self.assertEqual(mode.subset_config, "back_left") + + def test_row_count_is_clamped_to_head_geometry(self): + mode = normalize_head_mode("96_d_70", "row", "front_left", row_count=99) + self.assertEqual(mode.row_count, 8) + + def test_column_count_is_clamped_to_head_geometry(self): + mode = normalize_head_mode("96_d_70", "column", "front_left", column_count=99) + self.assertEqual(mode.column_count, 12) + + def test_zero_row_count_is_clamped_up_to_one(self): + mode = normalize_head_mode("96_d_70", "row", "front_left", row_count=0) + self.assertEqual(mode.row_count, 1) + + def test_negative_row_count_is_clamped_up_to_one(self): + mode = normalize_head_mode("96_d_70", "row", "front_left", row_count=-3) + self.assertEqual(mode.row_count, 1) + + def test_rectangle_row_and_column_counts_are_clamped_to_head_geometry(self): + mode = normalize_head_mode("96_d_70", "rectangle", "back_left", row_count=99, column_count=99) + self.assertEqual((mode.row_count, mode.column_count), (8, 12)) + + +class AnchorTests(unittest.TestCase): + """head_selected_ranges/head_anchor_cell for each of the four head corners.""" + + HEAD_TYPE: HeadType = "96_d_70" + + def _mode(self, corner: str) -> HeadMode: + return normalize_head_mode(self.HEAD_TYPE, "rectangle", corner, row_count=3, column_count=4) + + def test_back_left(self): + mode = self._mode("back_left") + self.assertEqual(head_selected_ranges(self.HEAD_TYPE, mode), ((0, 3), (0, 4))) + self.assertEqual(head_anchor_cell(self.HEAD_TYPE, mode), (0, 0)) + + def test_back_right(self): + mode = self._mode("back_right") + self.assertEqual(head_selected_ranges(self.HEAD_TYPE, mode), ((0, 3), (8, 12))) + self.assertEqual(head_anchor_cell(self.HEAD_TYPE, mode), (0, 11)) + + def test_front_left(self): + mode = self._mode("front_left") + self.assertEqual(head_selected_ranges(self.HEAD_TYPE, mode), ((5, 8), (0, 4))) + self.assertEqual(head_anchor_cell(self.HEAD_TYPE, mode), (7, 0)) + + def test_front_right(self): + mode = self._mode("front_right") + self.assertEqual(head_selected_ranges(self.HEAD_TYPE, mode), ((5, 8), (8, 12))) + self.assertEqual(head_anchor_cell(self.HEAD_TYPE, mode), (7, 11)) + + def test_all_barrels_anchor_is_origin(self): + mode = normalize_head_mode(self.HEAD_TYPE, "all_barrels", "front_left") + self.assertEqual(head_anchor_cell(self.HEAD_TYPE, mode), (0, 0)) + + def test_column_anchor_row_is_always_zero(self): + mode = normalize_head_mode(self.HEAD_TYPE, "column", "front_right", column_count=2) + row, _ = head_anchor_cell(self.HEAD_TYPE, mode) + self.assertEqual(row, 0) + + def test_row_anchor_col_is_always_zero(self): + mode = normalize_head_mode(self.HEAD_TYPE, "row", "front_right", row_count=2) + _, col = head_anchor_cell(self.HEAD_TYPE, mode) + self.assertEqual(col, 0) + + +class ActiveHeadWellsTests(unittest.TestCase): + def test_all_barrels_covers_the_whole_grid(self): + mode = normalize_head_mode("8_d_lt", "all_barrels", "front_left") + wells = active_head_wells("8_d_lt", mode) + self.assertEqual(len(wells), 8) + self.assertEqual(set(wells), {(r, 0) for r in range(8)}) + + def test_rectangle_covers_exactly_its_block(self): + mode = normalize_head_mode("96_d_70", "rectangle", "back_left", row_count=2, column_count=3) + wells = active_head_wells("96_d_70", mode) + self.assertEqual(set(wells), {(r, c) for r in range(2) for c in range(3)}) + + +class TipboxMirrorAndAnchorTests(unittest.TestCase): + def test_all_barrels_mirrors_to_back_left(self): + mode = normalize_head_mode("96_d_70", "all_barrels", "front_left") + self.assertEqual(tipbox_mirror_corner(mode), "back_left") + + def test_rectangle_mirror_is_opposite_corner(self): + mode = normalize_head_mode("96_d_70", "rectangle", "back_left", row_count=2, column_count=2) + self.assertEqual(tipbox_mirror_corner(mode), "front_right") + + def test_tipbox_anchor_cell_front_head_anchor(self): + selection = TipSelection( + location=1, row=2, col=3, row_count=2, column_count=2, head_anchor="front_right" + ) + self.assertEqual(tipbox_anchor_cell(selection), (3, 4)) + + def test_tipbox_anchor_cell_back_head_anchor(self): + selection = TipSelection( + location=1, row=2, col=3, row_count=2, column_count=2, head_anchor="back_left" + ) + self.assertEqual(tipbox_anchor_cell(selection), (2, 3)) + + +class SelectedAnchorRangesTests(unittest.TestCase): + def test_clamps_to_stay_in_bounds(self): + selection = TipSelection(location=0, row=10, col=10, row_count=2, column_count=2) + row_range, col_range = selected_anchor_ranges(8, 12, selection) + self.assertEqual(row_range, (6, 8)) + self.assertEqual(col_range, (10, 12)) + + def test_negative_anchor_is_clamped_up_to_zero(self): + selection = TipSelection(location=0, row=-5, col=-5, row_count=2, column_count=2) + row_range, col_range = selected_anchor_ranges(8, 12, selection) + self.assertEqual(row_range, (0, 2)) + self.assertEqual(col_range, (0, 2)) + + +class DescribeHeadModeTests(unittest.TestCase): + def test_all_barrels_description(self): + mode = normalize_head_mode("96_d_70", "all_barrels", "front_left") + self.assertEqual(describe_head_mode(mode), "All barrels") + + def test_rectangle_description_includes_dimensions(self): + mode = normalize_head_mode("96_d_70", "rectangle", "back_left", row_count=2, column_count=3) + self.assertIn("2x3", describe_head_mode(mode)) + + +class SuggestedHeadModeTests(unittest.TestCase): + def test_no_wells_selects_all_barrels(self): + mode = suggested_head_mode("96_d_70", None) + self.assertEqual(mode.subset_type, "all_barrels") + + def test_matching_well_count_selects_all_barrels(self): + mode = suggested_head_mode("96_d_70", 96) + self.assertEqual(mode.subset_type, "all_barrels") + + def test_96_head_striping_a_384_plate(self): + mode = suggested_head_mode("96_d_70", 384) + self.assertEqual(mode.subset_type, "rectangle") + self.assertEqual((mode.row_count, mode.column_count), (8, 12)) + + def test_8_head_on_a_96_plate_requests_a_column(self): + # 8_d_lt is already a single column of 8, so the requested "column" + # subset normalizes to all_barrels: there is nothing narrower than the + # whole head to select among a head with only one column. + mode = suggested_head_mode("8_d_lt", 96) + self.assertEqual(mode.subset_type, "all_barrels") + self.assertEqual((mode.row_count, mode.column_count), (8, 1)) + + def test_16_head_on_a_384_plate_selects_a_row(self): + mode = suggested_head_mode("16_d_st", 384) + self.assertEqual(mode.subset_type, "row") + + +class LegalTipboxAnchorTests(unittest.TestCase): + def test_pickup_requires_every_covered_cell_occupied(self): + mode = normalize_head_mode("96_d_70", "column", "front_left", column_count=1) + occupied = {(r, 0) for r in range(8)} + self.assertTrue(is_legal_tipbox_anchor(8, 1, mode, occupied, 0, 0, purpose="pickup")) + + def test_pickup_rejects_anchor_missing_a_tip(self): + mode = normalize_head_mode("96_d_70", "column", "front_left", column_count=1) + occupied = {(r, 0) for r in range(8) if r != 3} + self.assertFalse(is_legal_tipbox_anchor(8, 1, mode, occupied, 0, 0, purpose="pickup")) + + def test_return_to_empty_box_allows_any_anchor(self): + mode = normalize_head_mode("96_d_70", "single_barrel", "back_left") + anchors = legal_tipbox_anchors(8, 12, mode, set(), purpose="return") + self.assertEqual(len(anchors), 96) + + def test_unknown_purpose_raises(self): + mode = normalize_head_mode("96_d_70", "single_barrel", "back_left") + with self.assertRaises(ValueError): + is_legal_tipbox_anchor(8, 12, mode, set(), 0, 0, purpose="not-a-purpose") + + +class PlateFootprintTests(unittest.TestCase): + def test_full_head_on_matching_plate_maps_1_to_1(self): + mode = normalize_head_mode("96_d_70", "all_barrels", "front_left") + wells = plate_footprint_wells("96_d_70", mode, 8, 12, 9.0, 9.0, 0, 0) + self.assertEqual(set(wells), {(r, c) for r in range(8) for c in range(12)}) + + def test_out_of_bounds_anchor_returns_empty(self): + mode = normalize_head_mode("96_d_70", "all_barrels", "front_left") + wells = plate_footprint_wells("96_d_70", mode, 8, 12, 9.0, 9.0, 5, 5) + self.assertEqual(wells, []) + + def test_incompatible_pitch_returns_empty(self): + mode = normalize_head_mode("96_d_70", "single_barrel", "back_left") + wells = plate_footprint_wells("96_d_70", mode, 8, 12, 7.0, 7.0, 0, 0) + self.assertEqual(wells, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/types.py b/pylabrobot/agilent/bravo/types.py new file mode 100644 index 00000000000..3434c02ab2b --- /dev/null +++ b/pylabrobot/agilent/bravo/types.py @@ -0,0 +1,671 @@ +"""Core value types and physical constants for the Bravo pipetting head and deck. + +The Bravo is a fixed-head liquid handler: a single gantry carries a pipetting +head (and, on gripper-equipped units, a plate gripper) over a small deck of +labware locations. This module has no hardware-communication logic of its +own; it defines the vocabulary that the transport, protocol, and motion +layers share — which motion axis is which, which head hardware is installed, +how fast a move should run, and the physical dimensions (deck spacing, +encoder scale, axis travel limits) that come from the instrument's +mechanical design rather than from any single command. + +Numeric constants and wire codes here come directly from the firmware and +mechanical drawings; do not adjust them without a source. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +from enum import IntEnum, IntFlag, auto +from typing import Literal + +# --------------------------------------------------------------------------- +# Axis definitions +# --------------------------------------------------------------------------- + +Axis = Literal["x", "y", "z", "w", "g", "zg"] +"""A single motion axis. + +``x``/``y`` are the horizontal gantry, ``z`` is the pipette head's vertical +travel, ``w`` is the plunger (fluid displacement), and ``g``/``zg`` are the +gripper's jaw and vertical travel. Values are the lowercase firmware axis +letters. +""" + +ALL_AXES: tuple[Axis, ...] = ("x", "y", "z", "w", "g", "zg") +"""Every axis, in firmware declaration order.""" + +_AXIS_CODES: dict[Axis, int] = { + "x": 0, + "y": 1, + "z": 2, + "w": 3, + "g": 4, + "zg": 5, +} + +_AXIS_BY_CODE: dict[int, Axis] = {code: axis for axis, code in _AXIS_CODES.items()} + +_AXIS_DISPLAY_NAMES: dict[Axis, str] = { + "x": "X", + "y": "Y", + "z": "Z", + "w": "W", + "g": "G", + "zg": "Zg", +} + + +def axis_code(axis: Axis) -> int: + """Return the firmware wire code for an axis. + + Args: + axis: The axis to encode. + + Returns: + The integer code the firmware uses to identify this axis. + """ + return _AXIS_CODES[axis] + + +def axis_display_name(axis: Axis) -> str: + """Return the short display form of an axis, e.g. ``"Zg"``. + + Args: + axis: The axis to name. + + Returns: + The short mixed-case name, or the raw axis string if it is unrecognized. + """ + return _AXIS_DISPLAY_NAMES.get(axis, axis) + + +def axis_label(axis: Axis) -> str: + """Return the human-readable label used in log and error messages. + + Args: + axis: The axis to label. + + Returns: + A string such as ``"Zg-axis"``. + """ + return f"{axis_display_name(axis)}-axis" + + +# Homing order that keeps the head and gripper clear of the deck. +# +# Homing drives axes to their limits, and on a cold start no position is +# trustworthy yet — the head may be anywhere, possibly down in labware. Moving +# X or Y before the head and gripper are lifted drags them sideways through +# whatever is on the deck, so vertical clearance always comes first: +# +# z lifts the pipette head +# zg lifts the gripper +# g jaws — cannot strike the deck, but belongs with the gripper +# x,y lateral gantry, only safe once the above are clear +# w plunger, internal to the head, no collision path +# +# This is a safety invariant, not a preference. Do not reorder without +# understanding what is physically above the deck at each step. +SAFE_HOME_ORDER: tuple[Axis, ...] = ("z", "zg", "g", "x", "y", "w") + + +def safe_home_order(axes: Iterable[Axis]) -> list[Axis]: + """Order axes so vertical clearance happens before any lateral motion. + + Duplicates are dropped. Any axis not in :data:`SAFE_HOME_ORDER` is placed + last rather than discarded, so an unknown axis is still homed. + + Args: + axes: The axes to home, in any order. + + Returns: + The same axes, ordered so homing them in sequence is safe. + """ + rank = {axis: index for index, axis in enumerate(SAFE_HOME_ORDER)} + return sorted(dict.fromkeys(axes), key=lambda a: rank.get(a, len(rank))) + + +NUM_AXES_NO_GRIPPER = 4 +NUM_AXES_WITH_GRIPPER = 6 +AXIS_NAMES: dict[Axis, str] = {axis: axis_label(axis) for axis in ALL_AXES} +"""Human-readable label for every axis, e.g. ``{"zg": "Zg-axis"}``.""" + + +# --------------------------------------------------------------------------- +# Deck layout +# --------------------------------------------------------------------------- + +MIN_LOCATION = 1 +MAX_ROWS = 3 +MAX_COLS = 3 +MAX_LOCATIONS = MAX_ROWS * MAX_COLS # 9 +DEFAULT_NUM_LOCATIONS = 9 + +# Spacing between deck positions (mm) +X_TO_X_DISTANCE = 186.690 +Y_TO_Y_DISTANCE = 109.093 + + +def location_to_row_col(location: int) -> tuple[int, int]: + """Convert a 1-based deck location number to a 0-based (row, col) pair. + + Args: + location: The deck location, numbered 1 through :data:`MAX_LOCATIONS`. + + Returns: + The location's zero-based (row, col) position in the 3x3 deck grid. + + Raises: + ValueError: If ``location`` is outside the valid range. + """ + if not (MIN_LOCATION <= location <= MAX_LOCATIONS): + raise ValueError(f"Location must be {MIN_LOCATION}-{MAX_LOCATIONS}, got {location}") + idx = location - 1 + return idx // MAX_COLS, idx % MAX_COLS + + +def row_col_to_location(row: int, col: int) -> int: + """Convert a 0-based (row, col) deck position to a 1-based location number. + + Args: + row: Zero-based row in the deck grid. + col: Zero-based column in the deck grid. + + Returns: + The 1-based deck location number. + """ + return row * MAX_COLS + col + 1 + + +# --------------------------------------------------------------------------- +# Encoder scales (ticks per engineering unit) +# --------------------------------------------------------------------------- + +TICKS_PER_MM: dict[Axis, float] = { + "x": 314.96, + "y": 314.96, + "z": 1600.0, + "g": 944.88, + "zg": 787.40, +} +"""Encoder ticks per millimetre for each linear axis. ``w`` has no fixed +mm scale — its units depend on the installed head — so it is omitted.""" + +DEFAULT_W_TICKS_PER_UL = 48.0 +"""Encoder ticks per microlitre for the W (plunger) axis, before head detection. + +The W axis's real scale depends on the installed head's syringe volume and +is set from head-specific data once a head is detected. This is the value +used until then, and the value a controller falls back to if no +head-specific scale is ever supplied. +""" + +# --------------------------------------------------------------------------- +# Axis ranges (mm) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class AxisRange: + """The travel limits of one motion axis, in millimetres.""" + + min_pos: float + max_pos: float + + +AXIS_RANGES: dict[Axis, AxisRange] = { + "x": AxisRange(0.0, 390.0), + "y": AxisRange(0.5, 231.0), + "z": AxisRange(-1.8, 150.0), + "w": AxisRange(0.0, 250.0), # varies by head, this is max + "g": AxisRange(-4.0, 10.0), + "zg": AxisRange(-20.0, 105.0), +} + +# --------------------------------------------------------------------------- +# Safety constants (mm) +# --------------------------------------------------------------------------- + +Z_CLEARANCE = 20.0 # Teleshake clearance for pick-and-place +Z_CLEARANCE_NOT_PICKANDPLACE = 3.0 +Z_CLEARANCE_NOT_PICKANDPLACE_SRT = 2.0 +COLLISION_BUFFER = 20.0 +GRIPPER_THICKNESS = 18.7452 +GRIPPER_TO_BASE_OF_HEAD_GAP = 0.79 +MAX_HEAD_X_SIZE_GRIPPER = 211.0 +MAX_HEAD_X_SIZE_NOGRIPPER = 172.0 +MAX_HEAD_Y_SIZE_GRIPPER = 107.12 +MAX_HEAD_Y_SIZE_NOGRIPPER = 105.11 +MIN_PLATE_WIDTH = 77.53 +MIN_PLATE_THICKNESS = 3.0 + +# --------------------------------------------------------------------------- +# Motion constants +# --------------------------------------------------------------------------- + +Z_SAFE_POSITION_DEFAULT = 0.0 +APPROACH_HEIGHT_DEFAULT = 10.0 +MAX_Z_AXIS_CURRENT_PERCENT = 0.67 +MAX_G_AXIS_CURRENT_PERCENT = 0.5 +GRIP_JOG_TOLERANCE = 5.0 +OPEN_GRIPPER_POSITION = 0.0 +VACUUM_OPEN_GRIPPER_POSITION = -4.0 +VACUUM_CLOSED_GRIPPER_POSITION = -2.0 +GRIP_POSITION_TOLERANCE = 4 # encoder counts +TIPBOX_JOG_TOLERANCE = 5.0 +HEAD_TYPE_TOLERANCE = 20 # ADC counts + +EPSILON = 1e-6 +VOLUME_EPSILON = 0.001 # uL +AXIS_EPSILON = 0.07 # mm + +# Homing +HOMING_OFFSET_PEAK_CURRENT = 0.03 +HOMING_OFFSET_JOG_TOLERANCE = -5.0 +HOMING_OFFSET_MAX_JOG_POSITION = -5.0 +HOMING_OFFSET_SAFETY_BUFFER = 1.0 + +# SRT +SRT_250_PAD_HEIGHT = 2.4 +HEIGHT_DIFF_96AM_TO_96LT = 4.71 + +# --------------------------------------------------------------------------- +# Head types +# --------------------------------------------------------------------------- + +HeadType = Literal[ + "unknown", + "8_d_lt", + "8_f_50", + "16_d_st", + "96_d_70", + "96_d_70_s2", + "96_d_200", + "96_d_200_s2", + "96_f_50", + "96_f_200", + "96_pintool", + "96_assaymap", + "384_d_70", + "384_d_70_s2", + "384_f_50", + "384_pintool", + "1536_pintool", +] +"""The pipetting head hardware installed on the gantry. + +Disposable-tip heads carry ``_d_`` (e.g. ``"96_d_70"``, a 96-channel head for +70 uL disposable tips); fixed-tip heads carry ``_f_``; pintool and AssayMAP +heads are named directly. ``"unknown"`` means no head has been identified +yet. +""" + +ALL_HEAD_TYPES: tuple[HeadType, ...] = ( + "unknown", + "8_d_lt", + "8_f_50", + "16_d_st", + "96_d_70", + "96_d_70_s2", + "96_d_200", + "96_d_200_s2", + "96_f_50", + "96_f_200", + "96_pintool", + "96_assaymap", + "384_d_70", + "384_d_70_s2", + "384_f_50", + "384_pintool", + "1536_pintool", +) +"""Every head type, in firmware declaration order.""" + +_HEAD_TYPE_CODES: dict[HeadType, int] = { + "unknown": -1, + "8_d_lt": 0, + "8_f_50": 1, + "16_d_st": 2, + "96_d_70": 3, + "96_d_70_s2": 4, + "96_d_200": 5, + "96_d_200_s2": 6, + "96_f_50": 7, + "96_f_200": 8, + "96_pintool": 9, + "96_assaymap": 10, + "384_d_70": 11, + "384_d_70_s2": 12, + "384_f_50": 13, + "384_pintool": 14, + "1536_pintool": 15, +} + +_HEAD_TYPE_CHANNELS: dict[HeadType, int] = { + "unknown": 96, # permissive default before head detection; not a firmware-declared value. + "8_d_lt": 8, + "8_f_50": 8, + "16_d_st": 16, + "96_d_70": 96, + "96_d_70_s2": 96, + "96_d_200": 96, + "96_d_200_s2": 96, + "96_f_50": 96, + "96_f_200": 96, + "96_pintool": 96, + "96_assaymap": 96, + "384_d_70": 384, + "384_d_70_s2": 384, + "384_f_50": 384, + "384_pintool": 384, + "1536_pintool": 1536, +} + +TipKind = Literal["disposable", "fixed", "pintool", "assaymap", "none"] +"""The kind of tip (or tip-like tool) a head type carries.""" + +_HEAD_TYPE_TIP_KIND: dict[HeadType, TipKind] = { + "unknown": "none", + "8_d_lt": "disposable", + "8_f_50": "fixed", + "16_d_st": "disposable", + "96_d_70": "disposable", + "96_d_70_s2": "disposable", + "96_d_200": "disposable", + "96_d_200_s2": "disposable", + "96_f_50": "fixed", + "96_f_200": "fixed", + "96_pintool": "pintool", + "96_assaymap": "assaymap", + "384_d_70": "disposable", + "384_d_70_s2": "disposable", + "384_f_50": "fixed", + "384_pintool": "pintool", + "1536_pintool": "pintool", +} + + +def head_type_code(head_type: HeadType) -> int: + """Return the firmware wire code for a head type. + + Args: + head_type: The head type to encode. + + Returns: + The integer code the firmware uses to identify this head, or ``-1`` for + ``"unknown"``. + """ + return _HEAD_TYPE_CODES[head_type] + + +def head_type_channels(head_type: HeadType) -> int: + """Return the number of pipetting channels a head type has. + + ``"unknown"`` returns 96, a permissive default so geometry lookups do not + raise before a head has been identified. This module does not reject + ``"unknown"``; a caller that needs to guarantee a real head is installed + before pipetting should check for it at the operation boundary. + + Args: + head_type: The head type to look up. + + Returns: + The channel count (8, 16, 96, 384, or 1536). + """ + return _HEAD_TYPE_CHANNELS[head_type] + + +def head_type_tip_kind(head_type: HeadType) -> TipKind: + """Return the kind of tip (or tip-like tool) a head type carries. + + Args: + head_type: The head type to check. + + Returns: + ``"disposable"``, ``"fixed"``, ``"pintool"``, ``"assaymap"``, or + ``"none"`` for ``"unknown"``. + """ + return _HEAD_TYPE_TIP_KIND[head_type] + + +def head_type_is_disposable(head_type: HeadType) -> bool: + """Return whether a head type uses disposable tips. + + Args: + head_type: The head type to check. + + Returns: + True if the head is a disposable-tip head. + """ + return _HEAD_TYPE_TIP_KIND[head_type] == "disposable" + + +def head_type_is_fixed(head_type: HeadType) -> bool: + """Return whether a head type has fixed (non-disposable) tips. + + Args: + head_type: The head type to check. + + Returns: + True if the head is a fixed-tip head. + """ + return _HEAD_TYPE_TIP_KIND[head_type] == "fixed" + + +def head_type_is_pintool(head_type: HeadType) -> bool: + """Return whether a head type is a pintool head. + + Args: + head_type: The head type to check. + + Returns: + True if the head is a pintool head. + """ + return _HEAD_TYPE_TIP_KIND[head_type] == "pintool" + + +def head_type_is_assaymap(head_type: HeadType) -> bool: + """Return whether a head type is an AssayMAP head. + + Args: + head_type: The head type to check. + + Returns: + True if the head is an AssayMAP head. + """ + return _HEAD_TYPE_TIP_KIND[head_type] == "assaymap" + + +# --------------------------------------------------------------------------- +# Speed profiles +# --------------------------------------------------------------------------- + +SpeedLevel = Literal["fast", "med", "slow", "homing", "safe"] +"""A named motion speed profile. + +Callers select a move speed by name; each level maps to a velocity and +acceleration pair that varies by axis and installed head (see +``DEFAULT_SPEEDS`` in the motion layer). +""" + +ALL_SPEED_LEVELS: tuple[SpeedLevel, ...] = ("fast", "med", "slow", "homing", "safe") +"""Every speed level, in firmware declaration order.""" + +_SPEED_LEVEL_CODES: dict[SpeedLevel, int] = { + "fast": 0, + "med": 1, + "slow": 2, + "homing": 3, + "safe": 4, +} + + +def speed_level_code(speed: SpeedLevel) -> int: + """Return the firmware wire code for a speed level. + + Args: + speed: The speed level to encode. + + Returns: + The integer code the firmware uses to identify this speed level. + """ + return _SPEED_LEVEL_CODES[speed] + + +@dataclass(frozen=True) +class SpeedProfile: + """A velocity/acceleration pair for one axis at one speed level.""" + + velocity: float # mm/s (or uL/s for w) + acceleration: float # mm/s^2 (or uL/s^2 for w) + + +# --------------------------------------------------------------------------- +# Light control +# --------------------------------------------------------------------------- + + +class LightColor(IntFlag): + """The indicator light's color channels, combinable with ``|``.""" + + RED = 0x01 + YELLOW = 0x02 + GREEN = 0x04 + BLUE = 0x08 + + +class LightState(IntEnum): + """The driver's high-level indicator light state.""" + + OFF = 0 + IDLE = auto() + PROTOCOL = auto() + ERROR = auto() + INITIALIZING = auto() + + +@dataclass +class LightCommand: + """A command to the indicator light: a color, blink period, and duty cycle. + + Attributes: + color: The color channel(s) to light, combinable with ``|``. + period: Blink period, in seconds. ``0`` means solid (no blinking). + duty_cycle: Fraction of each period the light is on, from 0.0 to 1.0; + ``1.0`` means always on. + + Note: + The wire protocol encodes the period as a 32-bit count of milliseconds, + not seconds. Use :func:`light_command_period_ms` to convert before + sending a command, rather than truncating ``period`` directly — + ``int(period)`` silently rounds any sub-second period to 0, which the + firmware reads as solid instead of blinking. + """ + + color: LightColor + period: float = 0.0 + duty_cycle: float = 1.0 + + +def light_command_period_ms(command: LightCommand) -> int: + """Return a light command's blink period in the wire protocol's units. + + Args: + command: The light command to convert. + + Returns: + The blink period in whole milliseconds, rounded to the nearest + millisecond. + """ + return round(command.period * 1000) + + +# --------------------------------------------------------------------------- +# Device state flags (from CMD_QUERY_STATE response) +# --------------------------------------------------------------------------- + + +class DeviceStateFlag(IntFlag): + """Bit flags reported by the device in its ``CMD_QUERY_STATE`` response.""" + + ROBOT_DISABLE = 0x01 + MOTOR_POWER = 0x02 + GO_BUTTON = 0x04 + ROBOT_DISABLE_BUTTON = 0x08 + + +# --------------------------------------------------------------------------- +# Gripper state +# --------------------------------------------------------------------------- + + +class GripperDetectionState(IntEnum): + """Whether the gripper accessory has been detected on the gantry.""" + + NOT_YET_DETECTED = 0 + DETECTED = 1 + NOT_DETECTED = 2 + + +# --------------------------------------------------------------------------- +# Location types +# --------------------------------------------------------------------------- + + +class LocationType(IntEnum): + """The kind of fixture occupying a deck location.""" + + STANDARD = 0 + ACCESSORY = 2 + SRT250PAD = 3 + + +# --------------------------------------------------------------------------- +# Tip current limits (amps vs tip count, for interpolation) +# --------------------------------------------------------------------------- + +LT_TIP_CURRENT_TABLE: list[tuple[int, float]] = [ + (1, 0.04), + (8, 0.07), + (12, 0.10), + (96, 0.60), +] + +ST_TIP_CURRENT_TABLE: list[tuple[int, float]] = [ + (1, 0.04), + (16, 0.10), + (384, 0.80), +] + + +def interpolate_tip_current(table: list[tuple[int, float]], tip_count: int) -> float: + """Linearly interpolate the tip-pressing current limit for a tip count. + + Args: + table: Sorted ``(tip_count, current_amps)`` breakpoints. + tip_count: The number of tips being pressed. + + Returns: + The interpolated current limit in amps, clamped to the table's range. + """ + if tip_count <= table[0][0]: + return table[0][1] + if tip_count >= table[-1][0]: + return table[-1][1] + for i in range(len(table) - 1): + n0, c0 = table[i] + n1, c1 = table[i + 1] + if n0 <= tip_count <= n1: + t = (tip_count - n0) / (n1 - n0) + return c0 + t * (c1 - c0) + return table[-1][1] + + +# --------------------------------------------------------------------------- +# Concurrency +# --------------------------------------------------------------------------- + +NUM_EXTERNAL_ROBOTS = 4 +HEAD_RESOURCE_ID = 100 # virtual resource ID for location locking diff --git a/pylabrobot/agilent/bravo/types_tests.py b/pylabrobot/agilent/bravo/types_tests.py new file mode 100644 index 00000000000..7d4027b8049 --- /dev/null +++ b/pylabrobot/agilent/bravo/types_tests.py @@ -0,0 +1,241 @@ +import unittest +from typing import List, cast + +from pylabrobot.agilent.bravo.types import ( + ALL_AXES, + ALL_HEAD_TYPES, + LT_TIP_CURRENT_TABLE, + X_TO_X_DISTANCE, + Y_TO_Y_DISTANCE, + Axis, + DeviceStateFlag, + LightColor, + LightCommand, + axis_code, + axis_label, + head_type_channels, + head_type_code, + head_type_is_assaymap, + head_type_is_disposable, + head_type_is_fixed, + head_type_is_pintool, + head_type_tip_kind, + interpolate_tip_current, + light_command_period_ms, + location_to_row_col, + row_col_to_location, + safe_home_order, + speed_level_code, +) + +# The complete channel-count table for every head type, keyed exactly as +# _HEAD_TYPE_CHANNELS in types.py. Checking every row (rather than a sample) +# is deliberate: num_channels derives directly from this table. +_EXPECTED_CHANNELS = { + "unknown": 96, + "8_d_lt": 8, + "8_f_50": 8, + "16_d_st": 16, + "96_d_70": 96, + "96_d_70_s2": 96, + "96_d_200": 96, + "96_d_200_s2": 96, + "96_f_50": 96, + "96_f_200": 96, + "96_pintool": 96, + "96_assaymap": 96, + "384_d_70": 384, + "384_d_70_s2": 384, + "384_f_50": 384, + "384_pintool": 384, + "1536_pintool": 1536, +} + +# The complete tip-kind table for every head type, keyed exactly as +# _HEAD_TYPE_TIP_KIND in types.py. +_EXPECTED_TIP_KIND = { + "unknown": "none", + "8_d_lt": "disposable", + "8_f_50": "fixed", + "16_d_st": "disposable", + "96_d_70": "disposable", + "96_d_70_s2": "disposable", + "96_d_200": "disposable", + "96_d_200_s2": "disposable", + "96_f_50": "fixed", + "96_f_200": "fixed", + "96_pintool": "pintool", + "96_assaymap": "assaymap", + "384_d_70": "disposable", + "384_d_70_s2": "disposable", + "384_f_50": "fixed", + "384_pintool": "pintool", + "1536_pintool": "pintool", +} + + +class AxisTests(unittest.TestCase): + def test_axis_codes(self): + self.assertEqual(axis_code("x"), 0) + self.assertEqual(axis_code("y"), 1) + self.assertEqual(axis_code("z"), 2) + self.assertEqual(axis_code("w"), 3) + self.assertEqual(axis_code("g"), 4) + self.assertEqual(axis_code("zg"), 5) + + def test_axis_labels(self): + self.assertEqual(axis_label("x"), "X-axis") + self.assertEqual(axis_label("zg"), "Zg-axis") + + def test_all_axes_declaration_order(self): + self.assertEqual(ALL_AXES, ("x", "y", "z", "w", "g", "zg")) + + def test_safe_home_order_lifts_before_lateral_motion(self): + ordered = safe_home_order(["w", "x", "y", "zg", "g", "z"]) + self.assertEqual(ordered, ["z", "zg", "g", "x", "y", "w"]) + + def test_safe_home_order_drops_duplicates(self): + ordered = safe_home_order(["x", "x", "z"]) + self.assertEqual(ordered, ["z", "x"]) + + def test_safe_home_order_places_unknown_axis_last(self): + # "not-an-axis" is not a valid Axis literal; the cast deliberately lies + # to the type checker to exercise safe_home_order's fallback path for an + # axis outside SAFE_HOME_ORDER. + axes = cast(List[Axis], ["x", "not-an-axis", "z"]) + ordered = safe_home_order(axes) + self.assertEqual(ordered, ["z", "x", "not-an-axis"]) + + +class LocationTests(unittest.TestCase): + def test_location_to_row_col(self): + self.assertEqual(location_to_row_col(1), (0, 0)) + self.assertEqual(location_to_row_col(2), (0, 1)) + self.assertEqual(location_to_row_col(3), (0, 2)) + self.assertEqual(location_to_row_col(5), (1, 1)) + self.assertEqual(location_to_row_col(9), (2, 2)) + + def test_location_to_row_col_rejects_out_of_range(self): + with self.assertRaises(ValueError): + location_to_row_col(0) + with self.assertRaises(ValueError): + location_to_row_col(10) + + def test_row_col_to_location(self): + self.assertEqual(row_col_to_location(0, 0), 1) + self.assertEqual(row_col_to_location(1, 1), 5) + self.assertEqual(row_col_to_location(2, 2), 9) + + def test_location_roundtrip(self): + for loc in range(1, 10): + row, col = location_to_row_col(loc) + self.assertEqual(row_col_to_location(row, col), loc) + + def test_deck_spacing(self): + self.assertEqual(X_TO_X_DISTANCE, 186.690) + self.assertEqual(Y_TO_Y_DISTANCE, 109.093) + + +class HeadTypeTests(unittest.TestCase): + def test_head_type_codes(self): + self.assertEqual(head_type_code("unknown"), -1) + self.assertEqual(head_type_code("8_d_lt"), 0) + self.assertEqual(head_type_code("16_d_st"), 2) + self.assertEqual(head_type_code("384_d_70"), 11) + self.assertEqual(head_type_code("1536_pintool"), 15) + + def test_all_head_types_declaration_order_matches_codes(self): + for head_type in ALL_HEAD_TYPES: + if head_type == "unknown": + continue + self.assertGreaterEqual(head_type_code(head_type), 0) + + def test_head_type_channels_for_every_head_type(self): + # Pins the whole table, not a sample: num_channels derives from it. + for head_type in ALL_HEAD_TYPES: + with self.subTest(head_type=head_type): + self.assertEqual(head_type_channels(head_type), _EXPECTED_CHANNELS[head_type]) + + def test_head_type_channels_unknown_is_a_permissive_default(self): + self.assertEqual(head_type_channels("unknown"), 96) + + def test_head_type_tip_kind_for_every_head_type(self): + for head_type in ALL_HEAD_TYPES: + with self.subTest(head_type=head_type): + self.assertEqual(head_type_tip_kind(head_type), _EXPECTED_TIP_KIND[head_type]) + + def test_head_type_is_disposable_matches_tip_kind_table(self): + for head_type in ALL_HEAD_TYPES: + with self.subTest(head_type=head_type): + expected = _EXPECTED_TIP_KIND[head_type] == "disposable" + self.assertEqual(head_type_is_disposable(head_type), expected) + + def test_head_type_is_fixed_matches_tip_kind_table(self): + for head_type in ALL_HEAD_TYPES: + with self.subTest(head_type=head_type): + expected = _EXPECTED_TIP_KIND[head_type] == "fixed" + self.assertEqual(head_type_is_fixed(head_type), expected) + + def test_head_type_is_pintool_matches_tip_kind_table(self): + for head_type in ALL_HEAD_TYPES: + with self.subTest(head_type=head_type): + expected = _EXPECTED_TIP_KIND[head_type] == "pintool" + self.assertEqual(head_type_is_pintool(head_type), expected) + + def test_head_type_is_assaymap_matches_tip_kind_table(self): + for head_type in ALL_HEAD_TYPES: + with self.subTest(head_type=head_type): + expected = _EXPECTED_TIP_KIND[head_type] == "assaymap" + self.assertEqual(head_type_is_assaymap(head_type), expected) + + +class SpeedLevelTests(unittest.TestCase): + def test_speed_level_codes(self): + self.assertEqual(speed_level_code("fast"), 0) + self.assertEqual(speed_level_code("med"), 1) + self.assertEqual(speed_level_code("slow"), 2) + self.assertEqual(speed_level_code("homing"), 3) + self.assertEqual(speed_level_code("safe"), 4) + + +class FlagTests(unittest.TestCase): + def test_light_color_flags_combine(self): + combined = LightColor.RED | LightColor.GREEN + self.assertEqual(combined & LightColor.RED, LightColor.RED) + self.assertEqual(combined & LightColor.GREEN, LightColor.GREEN) + self.assertEqual(combined & LightColor.BLUE, 0) + + def test_device_state_flags_combine(self): + state = DeviceStateFlag.ROBOT_DISABLE | DeviceStateFlag.GO_BUTTON + self.assertEqual(state & DeviceStateFlag.ROBOT_DISABLE, DeviceStateFlag.ROBOT_DISABLE) + self.assertEqual(state & DeviceStateFlag.GO_BUTTON, DeviceStateFlag.GO_BUTTON) + self.assertEqual(state & DeviceStateFlag.MOTOR_POWER, 0) + + +class LightCommandPeriodTests(unittest.TestCase): + def test_half_second_period_in_milliseconds(self): + self.assertEqual(light_command_period_ms(LightCommand(LightColor.RED, period=0.5)), 500) + + def test_solid_period_in_milliseconds(self): + self.assertEqual(light_command_period_ms(LightCommand(LightColor.RED, period=0.0)), 0) + + def test_two_second_period_in_milliseconds(self): + self.assertEqual(light_command_period_ms(LightCommand(LightColor.RED, period=2.0)), 2000) + + +class TipCurrentTests(unittest.TestCase): + def test_interpolate_at_breakpoints(self): + self.assertEqual(interpolate_tip_current(LT_TIP_CURRENT_TABLE, 1), 0.04) + self.assertEqual(interpolate_tip_current(LT_TIP_CURRENT_TABLE, 96), 0.60) + + def test_interpolate_between_breakpoints(self): + mid = interpolate_tip_current(LT_TIP_CURRENT_TABLE, 4) + self.assertTrue(0.04 < mid < 0.07) + + def test_interpolate_clamps_outside_table(self): + self.assertEqual(interpolate_tip_current(LT_TIP_CURRENT_TABLE, 0), 0.04) + self.assertEqual(interpolate_tip_current(LT_TIP_CURRENT_TABLE, 1000), 0.60) + + +if __name__ == "__main__": + unittest.main() From 6d81c6ea56183aea69fe94a9d9c202cbe71cfbe5 Mon Sep 17 00:00:00 2001 From: kelsorj Date: Fri, 21 Aug 2026 10:59:23 -0700 Subject: [PATCH 2/9] Add Bravo transport over pylabrobot.io Bravo controllers are synchronous; PyLabRobot's io layer is asynchronous. AsyncTransportBase bridges them: a controller running inside asyncio.to_thread submits its coroutine to the event loop that owns the connection and blocks until it completes. receive returns b"" on timeout; receive_exact raises TimeoutError. The outer bound is the cumulative ceiling on a call, since per-chunk timeouts do not sum. Socket and serial implementations share the bridge so their semantics cannot drift. --- .../agilent/bravo/transport/__init__.py | 3 + pylabrobot/agilent/bravo/transport/_bridge.py | 256 ++++++++++++ .../agilent/bravo/transport/_bridge_tests.py | 218 ++++++++++ pylabrobot/agilent/bravo/transport/base.py | 125 ++++++ .../agilent/bravo/transport/base_tests.py | 175 ++++++++ .../bravo/transport/benchmark_tests.py | 367 +++++++++++++++++ pylabrobot/agilent/bravo/transport/serial.py | 260 ++++++++++++ .../agilent/bravo/transport/serial_tests.py | 381 ++++++++++++++++++ pylabrobot/agilent/bravo/transport/socket.py | 72 ++++ .../agilent/bravo/transport/socket_tests.py | 183 +++++++++ 10 files changed, 2040 insertions(+) create mode 100644 pylabrobot/agilent/bravo/transport/__init__.py create mode 100644 pylabrobot/agilent/bravo/transport/_bridge.py create mode 100644 pylabrobot/agilent/bravo/transport/_bridge_tests.py create mode 100644 pylabrobot/agilent/bravo/transport/base.py create mode 100644 pylabrobot/agilent/bravo/transport/base_tests.py create mode 100644 pylabrobot/agilent/bravo/transport/benchmark_tests.py create mode 100644 pylabrobot/agilent/bravo/transport/serial.py create mode 100644 pylabrobot/agilent/bravo/transport/serial_tests.py create mode 100644 pylabrobot/agilent/bravo/transport/socket.py create mode 100644 pylabrobot/agilent/bravo/transport/socket_tests.py diff --git a/pylabrobot/agilent/bravo/transport/__init__.py b/pylabrobot/agilent/bravo/transport/__init__.py new file mode 100644 index 00000000000..572788dce5a --- /dev/null +++ b/pylabrobot/agilent/bravo/transport/__init__.py @@ -0,0 +1,3 @@ +from .base import Transport +from .serial import SerialTransport +from .socket import SocketTransport diff --git a/pylabrobot/agilent/bravo/transport/_bridge.py b/pylabrobot/agilent/bravo/transport/_bridge.py new file mode 100644 index 00000000000..f885488b62f --- /dev/null +++ b/pylabrobot/agilent/bravo/transport/_bridge.py @@ -0,0 +1,256 @@ +"""Shared synchronous-to-asynchronous bridge for Bravo transports. + +Bravo controllers are synchronous and run inside ``asyncio.to_thread``, while +PyLabRobot's I/O layer is asynchronous. Every concrete transport crosses that +boundary the same way: it submits a coroutine to the event loop that owns its +connection and blocks the calling worker thread until the coroutine completes. +That crossing, its lifecycle, and the timeout accounting it needs live here, so +a concrete transport supplies only its own I/O object and its read/write bodies. +""" + +import asyncio +import concurrent.futures +import logging +from abc import abstractmethod +from typing import Any, Coroutine, Optional, TypeVar + +from .base import Transport + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + +# Slack added on top of a coroutine's own internal timeout when bounding it via +# concurrent.futures.Future.result(). Named and centralized, rather than a handful +# of copy-pasted "+ 1.0" literals, because it is also the real cumulative ceiling on +# a call's duration whenever the coroutine's own timeout is not itself cumulative. +# SocketTransport documents the concrete numbers this implies for that transport. +_LOOP_HANDOFF_GRACE_S = 1.0 + +# Read size for receive(). Framed instrument protocols spoken over this bridge +# (this driver's Gemini protocol carries payloads up to roughly 512 bytes) fit +# comfortably in one read() call at this size, with headroom for framing and +# protocol overhead, instead of silently truncating the way the underlying I/O +# layer's much smaller defaults would. +_RECEIVE_BUFFER_SIZE = 4096 + + +class _OuterBoundTimeout(TimeoutError): + """The future-level bound firing, rather than a coroutine's own timeout. + + A ``TimeoutError`` like any other to a caller, which is what the contract + promises; the distinct class exists so that the bridge itself can tell the two + apart, since by the time one is caught the wording is all that separates them. + """ + + +def _outer_bound(timeout: float) -> float: + """The future-level bound for a call whose coroutine enforces ``timeout`` itself. + + Deliberately a module-level function rather than a method, so a transport can + use it without inheriting anything. + + Args: + timeout: The timeout the coroutine was built with. + + Returns: + ``timeout`` plus a fixed grace period, so that the coroutine's own timeout is + what normally fires. + """ + return timeout + _LOOP_HANDOFF_GRACE_S + + +class AsyncTransportBase(Transport): + """A synchronous byte channel backed by an asynchronous PyLabRobot I/O object. + + Each call submits its coroutine to the event loop that owns the connection, via + ``asyncio.run_coroutine_threadsafe``, and blocks the calling worker thread until + the coroutine completes. This cannot deadlock: the caller is never the loop + thread, so the loop remains free to run the coroutine. + + Subclasses own their I/O object, open and close it through :meth:`_open_io` and + :meth:`_close_io`, and implement :meth:`Transport.send`, :meth:`Transport.receive` + and :meth:`Transport.receive_exact` in terms of :meth:`_run` and + :meth:`_run_receive`. + """ + + def __init__(self, transport_name: str, endpoint: str): + """Record how this transport identifies itself in logs and error messages. + + Args: + transport_name: Short name of the transport kind, e.g. ``"socket"``. + endpoint: Identifier of the far end, e.g. ``"192.168.0.1:8000"``. + """ + self._transport_name = transport_name + self._endpoint = endpoint + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._connected = False + + @abstractmethod + async def _open_io(self) -> None: + """Open the underlying I/O object.""" + + @abstractmethod + async def _close_io(self) -> None: + """Close the underlying I/O object.""" + + async def setup(self) -> None: + """Open the connection and capture the owning event loop. + + Raises: + RuntimeError: If the transport is already set up. Opening a second time + would strand whatever the first open allocated -- an I/O object holding a + thread pool would leak its executor -- and leave the loop recorded here + pointing at a connection nothing else can reach. + """ + if self._connected: + raise RuntimeError("Transport is already set up. Call stop() before setting up again.") + loop = asyncio.get_running_loop() + await self._open_io() + self._loop = loop + self._connected = True + logger.debug("[%s] Bravo %s transport connected", self._endpoint, self._transport_name) + + async def stop(self) -> None: + """Close the connection and release the owning event loop.""" + try: + await self._close_io() + logger.debug("[%s] Bravo %s transport disconnected", self._endpoint, self._transport_name) + finally: + self._connected = False + self._loop = None + + def _run(self, coro: Coroutine[Any, Any, T], timeout: float) -> T: + """Run a coroutine on the owning loop and block until it completes. + + ``self._loop`` is read exactly once into a local variable, and that local is + used for both the not-set-up check and the ``run_coroutine_threadsafe`` call. + Reading it twice would leave a window in which a concurrent ``stop()`` could + clear ``self._loop`` between the check and the call, turning a would-be + ``RuntimeError`` into an ``AttributeError`` from inside asyncio and leaking the + un-awaited coroutine. + + ``future.result(timeout)`` raises ``concurrent.futures.TimeoutError`` both when + the future itself does not complete within ``timeout``, and -- from Python 3.11 + onward, where ``concurrent.futures.TimeoutError`` *is* the builtin + ``TimeoutError`` -- when the coroutine completes with its own inner + ``TimeoutError``. Those two cases must not be confused: only the first is this + bound firing. Class alone cannot tell them apart on 3.11+, and the caught + exception itself is not trustworthy evidence either: the future can complete in + the narrow window between ``result(timeout)`` raising and ``future.done()`` + being checked, so a caught exception that looks like the coroutine's own may in + fact be this outer bound, or vice versa. So when the future is already done, + this does not re-raise the caught exception; it calls ``future.result()`` again, + with no timeout, to ask the future itself what actually happened -- its real + result if the coroutine succeeded, or its real exception, with its own message + and ``__cause__``, if it did not. Only an undone future means this outer bound + genuinely fired. + + One thing this crossing does not carry: from Python 3.11 on, where + ``concurrent.futures.TimeoutError`` is the builtin ``TimeoutError``, asyncio + rebuilds an exception of exactly that class while copying it onto the future + handed back here, so a coroutine's ``TimeoutError`` reaches the caller with + its message intact but as a different object, stripped of its ``__cause__``. + Anything that needs to tell one timeout from another must therefore go by the + message, which is why each transport gives its timeouts distinct wording. + + Args: + coro: The coroutine to run. + timeout: The future-level bound, in seconds, and so the ceiling on the total + duration of the call. For a coroutine that enforces a timeout of its own, + this must sit above that timeout, and :meth:`_run_bounded` is the way to + say so -- passing the coroutine's own timeout here would leave the two + racing, and this bound's generic message would start winning. + + Returns: + The coroutine's result. + + Raises: + RuntimeError: If the transport has not been set up. + TimeoutError: If the coroutine does not complete within ``timeout``. + """ + loop = self._loop + if loop is None: + coro.close() + raise RuntimeError("Transport is not set up. Call setup() first.") + future = asyncio.run_coroutine_threadsafe(coro, loop) + try: + return future.result(timeout) + except concurrent.futures.TimeoutError as exc: + if future.done(): + # The exception result(timeout) raised is not necessarily the coroutine's + # own: the future can finish in the window between that raise and this + # check, so re-raising `exc` could surface the outer timeout even though + # the coroutine actually succeeded or failed differently. Ask the future + # itself, which returns the real result or raises the real exception. + return future.result() + future.cancel() + raise _OuterBoundTimeout( + f"Bravo {self._transport_name} transport call did not complete within {timeout} seconds" + ) from exc + + def _run_bounded(self, coro: Coroutine[Any, Any, T], coro_timeout: float) -> T: + """Run a coroutine that enforces ``coro_timeout`` itself, bounded above it. + + The grace period between the two is applied here rather than at each call + site, so that no transport has to remember the arithmetic. Getting it wrong + by passing the coroutine's own timeout as the bound leaves the two racing, + and this bound's generic message starts displacing the specific one the + coroutine would have raised. + + Args: + coro: The coroutine to run. + coro_timeout: The timeout ``coro`` was built with, in seconds. + + Returns: + The coroutine's result. + + Raises: + RuntimeError: If the transport has not been set up. + TimeoutError: If the coroutine does not complete within the bound. + """ + return self._run(coro, _outer_bound(coro_timeout)) + + def _run_receive(self, coro: Coroutine[Any, Any, bytes], coro_timeout: float) -> bytes: + """Run a ``receive`` coroutine, honoring the contract's return-on-timeout rule. + + Shared by every transport so that the difference between ``receive`` and + ``receive_exact`` -- the former returns ``b""`` on timeout, the latter raises + -- cannot drift between transports. + + Args: + coro: The coroutine that performs the read. + coro_timeout: The timeout ``coro`` was built with, in seconds. + + Returns: + The bytes ``coro`` produced, or ``b""`` if it timed out. + """ + try: + return self._run_bounded(coro, coro_timeout) + except _OuterBoundTimeout: + # The coroutine's own timeout should have fired first and did not, so + # something upstream of the device is wrong -- a stalled loop, or a read + # that outran its own bound. Logged apart from the ordinary case, and + # louder, because the b"" this returns is otherwise indistinguishable from + # the device simply having nothing to say. + logger.warning( + "[%s] receive() outer bound fired after %.3fs; returning b'' per Transport contract", + self._endpoint, + _outer_bound(coro_timeout), + ) + return b"" + except TimeoutError: + # Deliberate: base.Transport.receive's contract is to return b"" on timeout + # rather than raise. This is the expected case -- the device said nothing + # within the time it was given. + logger.debug( + "[%s] receive() timed out after %.3fs; returning b'' per Transport contract", + self._endpoint, + coro_timeout, + ) + return b"" + + @property + def is_connected(self) -> bool: + """Whether the transport has been set up and not yet stopped.""" + return self._connected diff --git a/pylabrobot/agilent/bravo/transport/_bridge_tests.py b/pylabrobot/agilent/bravo/transport/_bridge_tests.py new file mode 100644 index 00000000000..407256cd8c0 --- /dev/null +++ b/pylabrobot/agilent/bravo/transport/_bridge_tests.py @@ -0,0 +1,218 @@ +import asyncio +import concurrent.futures +import gc +import unittest +import warnings +from unittest.mock import patch + +from pylabrobot.agilent.bravo.transport._bridge import AsyncTransportBase + + +class _StubIO: + """Stands in for a pylabrobot.io object that opens and closes without a device.""" + + def __init__(self): + self.open_calls = 0 + self.close_calls = 0 + + async def setup(self) -> None: + self.open_calls += 1 + + async def stop(self) -> None: + self.close_calls += 1 + + +class _StubTransport(AsyncTransportBase): + """The bridge with a device-free I/O object, so _run can be exercised directly.""" + + def __init__(self): + super().__init__(transport_name="stub", endpoint="stub-endpoint") + self._io = _StubIO() + + async def _open_io(self) -> None: + await self._io.setup() + + async def _close_io(self) -> None: + await self._io.stop() + + def send(self, data: bytes) -> None: + raise NotImplementedError + + def receive(self, timeout: float = 2.0) -> bytes: + raise NotImplementedError + + def receive_exact(self, num_bytes: int, timeout: float = 2.0) -> bytes: + raise NotImplementedError + + +class _RacedFuture: + """Stands in for the concurrent.futures.Future that run_coroutine_threadsafe returns. + + Pins the race _run must handle: result(timeout) raises concurrent.futures.TimeoutError, + but by the time done() is checked the future has a real outcome behind it -- a value + or the coroutine's own exception -- decided before that check. + """ + + def __init__(self, outcome_kind: str, outcome_value): + self._kind = outcome_kind # "value" or "exception" + self._value = outcome_value + self.cancel_calls = 0 + + def result(self, timeout=None): + if timeout is not None: + raise concurrent.futures.TimeoutError() + if self._kind == "value": + return self._value + raise self._value + + def done(self) -> bool: + return True + + def cancel(self) -> bool: + self.cancel_calls += 1 + return False + + +class AsyncTransportBaseTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.transport = _StubTransport() + await self.transport.setup() + self.addAsyncCleanup(self.transport.stop) + + async def test_run_converts_future_timeout_to_builtin_timeout_error(self): + async def slow() -> bytes: + await asyncio.sleep(0.5) + return b"x" + + def blocking_call() -> bytes: + return self.transport._run(slow(), 0.05) + + with self.assertRaises(TimeoutError) as ctx: + await asyncio.to_thread(blocking_call) + + # This is the outer bound genuinely firing (the coroutine never got + # anywhere near completing), so it must carry the outer-bound message. + self.assertIn("did not complete within", str(ctx.exception)) + + async def test_setup_twice_raises(self): + # Opening again would strand what the first open allocated -- an I/O object + # holding a thread pool would leak its executor -- and leave the loop recorded + # here pointing at a connection nothing else can reach. + with self.assertRaises(RuntimeError) as ctx: + await self.transport.setup() + + self.assertIn("already set up", str(ctx.exception)) + self.assertEqual(self.transport._io.open_calls, 1) + + async def test_run_receive_returns_empty_bytes_when_the_outer_bound_fires(self): + # A coroutine with no timeout of its own, so the outer, future-level bound is + # the only one that can fire. Python 3.9 surfaces that bound from + # result(timeout) as a concurrent.futures.TimeoutError, which is a different + # class from the builtin TimeoutError there; _run has to have converted it, + # or _run_receive would raise where Transport.receive says return b"". + async def stalled() -> bytes: + await asyncio.sleep(5) + return b"x" + + def blocking_call() -> bytes: + return self.transport._run_receive(stalled(), 0.05) + + with self.assertLogs("pylabrobot.agilent.bravo.transport._bridge", "WARNING") as logs: + result = await asyncio.to_thread(blocking_call) + + self.assertEqual(result, b"") + # Logged apart from an ordinary read timeout, and louder: this b"" means the + # bridge failed, not that the device had nothing to say, and the two are + # otherwise indistinguishable to whoever reads the log. + self.assertIn("outer bound fired", "\n".join(logs.output)) + + async def test_run_receive_allows_the_coroutine_its_own_timeout_plus_grace(self): + # The future is bounded above the timeout the coroutine was built with, so + # that the coroutine's own timeout is the one that fires. Bounding it at that + # timeout instead would throw away a result that arrived within the grace + # period, and report it as the b"" that means no data came. + async def slower_than_its_own_timeout() -> bytes: + await asyncio.sleep(0.3) + return b"late but real" + + def blocking_call() -> bytes: + return self.transport._run_receive(slower_than_its_own_timeout(), 0.05) + + result = await asyncio.to_thread(blocking_call) + self.assertEqual(result, b"late but real") + + async def test_run_raises_runtime_error_not_attribute_error_when_loop_is_none(self): + # Reproducing the actual TOCTOU race (stop() clearing self._loop between + # _run's check and its run_coroutine_threadsafe call) requires winning a + # narrow, non-deterministic interleaving. Instead, this pins the + # observable end state of that race -- self._loop already None when + # _run is entered -- and asserts the two properties the bug broke: + # a RuntimeError (not an AttributeError from inside asyncio), and no + # leaked, un-awaited coroutine. + self.transport._loop = None + + async def coro() -> bytes: + return b"x" + + pending = coro() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with self.assertRaises(RuntimeError): + self.transport._run(pending, 1.0) + del pending + gc.collect() + never_awaited = [w for w in caught if "was never awaited" in str(w.message)] + self.assertEqual(never_awaited, []) + + async def test_run_returns_real_result_when_future_races_to_success(self): + # Pins the race the future.done() branch must resolve correctly: the + # coroutine actually succeeded, but result(timeout) still raised + # concurrent.futures.TimeoutError because it fired in the narrow window + # before the future was marked done. _run must return the real result, + # not the outer timeout. + fake_future = _RacedFuture("value", b"real result") + + async def coro() -> bytes: + return b"unused" + + pending = coro() + + def blocking_call() -> bytes: + with patch.object(asyncio, "run_coroutine_threadsafe", return_value=fake_future): + return self.transport._run(pending, 1.0) + + try: + result = await asyncio.to_thread(blocking_call) + finally: + pending.close() + + self.assertEqual(result, b"real result") + self.assertEqual(fake_future.cancel_calls, 0) + + async def test_run_reraises_coroutines_own_exception_when_future_races_to_failure(self): + # Same race, but the coroutine itself failed with a non-timeout error. + # _run must surface that real exception, not the outer + # concurrent.futures.TimeoutError caught from result(timeout). + inner_exc = ValueError("the coroutine's own failure") + fake_future = _RacedFuture("exception", inner_exc) + + async def coro() -> bytes: + return b"unused" + + pending = coro() + + def blocking_call() -> None: + with patch.object(asyncio, "run_coroutine_threadsafe", return_value=fake_future): + self.transport._run(pending, 1.0) + + try: + with self.assertRaises(ValueError) as ctx: + await asyncio.to_thread(blocking_call) + finally: + pending.close() + + self.assertIs(ctx.exception, inner_exc) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/transport/base.py b/pylabrobot/agilent/bravo/transport/base.py new file mode 100644 index 00000000000..87b8475db9c --- /dev/null +++ b/pylabrobot/agilent/bravo/transport/base.py @@ -0,0 +1,125 @@ +"""Synchronous byte transport for Bravo controllers. + +Bravo controllers are synchronous and run inside ``asyncio.to_thread``. This +interface is the boundary at which those synchronous calls reach PyLabRobot's +asynchronous I/O layer. +""" + +import logging +import time +from abc import ABC, abstractmethod + +logger = logging.getLogger(__name__) + +# How long each read waits while draining: long enough for a byte already on its +# way to land, short enough that draining a quiet connection is not a stall. +_DRAIN_READ_TIMEOUT_S = 0.1 + +# Total ceiling on one drain, so a device streaming continuously cannot hold one +# open indefinitely. Bounded by elapsed time rather than by a byte count, because +# how many stale bytes a wedged device produces says nothing about how long the +# caller recovering from an error can afford to spend discarding them. +_DRAIN_BUDGET_S = 2.0 + + +class Transport(ABC): + """A synchronous byte channel to a Bravo instrument. + + An implementation supplies :meth:`send`, :meth:`receive`, :meth:`receive_exact` + and :attr:`is_connected`. :meth:`drain` is not among them: it is written here in + terms of :meth:`receive`, so every transport answers it identically and none has + to write it. + """ + + @abstractmethod + def send(self, data: bytes) -> None: + """Send raw bytes to the device. + + Args: + data: The bytes to send. + + Raises: + TimeoutError: If the bytes cannot be handed to the device in time. Every + transport reports a send timeout this way, whatever its underlying I/O + layer raises, so that a caller can handle one across all of them. + """ + + @abstractmethod + def receive(self, timeout: float = 2.0) -> bytes: + """Receive whatever response bytes are available. + + If no bytes arrive before ``timeout`` elapses, returns ``b""`` rather + than raising. This is deliberately different from ``receive_exact``, + which raises ``TimeoutError`` on timeout instead of returning a partial + result. + + Reading a framed protocol wants ``receive_exact``. This is for reading + whatever is pending without knowing how much of it there is, which is what + discarding a stale buffer after a protocol error needs. + + Args: + timeout: Maximum time to wait, in seconds. + + Returns: + The bytes received, or ``b""`` if none arrived before the timeout + elapsed. + """ + + @abstractmethod + def receive_exact(self, num_bytes: int, timeout: float = 2.0) -> bytes: + """Receive exactly ``num_bytes``, blocking until they arrive. + + Args: + num_bytes: The exact number of bytes to read. + timeout: Maximum time to wait, in seconds. + + Returns: + Exactly ``num_bytes`` bytes. + + Raises: + TimeoutError: If the full count does not arrive within the timeout. + """ + + @property + @abstractmethod + def is_connected(self) -> bool: + """Whether the transport is currently connected.""" + + def drain(self) -> int: + """Discard whatever the device has already sent, and report how much. + + This exists for error recovery. A protocol error can leave the receive buffer + holding the tail of a frame that was misread, or a whole frame that landed + after the caller stopped listening. Either way the next framed read would take + those stale bytes for the head of its own frame and stay out of step from + there, so recovery means throwing them away first. + + Reads until one comes back empty, so a buffer holding more than a single read + is emptied rather than merely shortened, and gives up after ``_DRAIN_BUDGET_S`` + in total so that a device sending continuously cannot hold a drain open. + + This is also the one caller for which :meth:`receive` is the right + tool: it wants whatever is pending, without knowing or caring how much, which + is exactly what ``receive`` offers and ``receive_exact`` cannot. + + Returns: + The number of bytes discarded, which is zero when the device had nothing + pending. An idle connection is the ordinary case here, not an error. + + Raises: + RuntimeError: If the transport has not been set up. + """ + deadline = time.monotonic() + _DRAIN_BUDGET_S + discarded = 0 + while time.monotonic() < deadline: + stale = self.receive(timeout=_DRAIN_READ_TIMEOUT_S) + if len(stale) == 0: + return discarded + discarded += len(stale) + logger.warning( + "%s.drain() gave up after %.1fs with bytes still arriving; discarded %d", + type(self).__name__, + _DRAIN_BUDGET_S, + discarded, + ) + return discarded diff --git a/pylabrobot/agilent/bravo/transport/base_tests.py b/pylabrobot/agilent/bravo/transport/base_tests.py new file mode 100644 index 00000000000..833ab9928dd --- /dev/null +++ b/pylabrobot/agilent/bravo/transport/base_tests.py @@ -0,0 +1,175 @@ +import asyncio +import unittest +from typing import TYPE_CHECKING, Tuple + +from pylabrobot.agilent.bravo.transport._bridge import _RECEIVE_BUFFER_SIZE, AsyncTransportBase +from pylabrobot.agilent.bravo.transport.base import Transport + + +class ConcreteTransport(Transport): + def __init__(self): + self.sent = b"" + + def send(self, data: bytes) -> None: + self.sent += data + + def receive(self, timeout: float = 2.0) -> bytes: + return b"ok" + + def receive_exact(self, num_bytes: int, timeout: float = 2.0) -> bytes: + return b"o" * num_bytes + + @property + def is_connected(self) -> bool: + return True + + +class TransportInterfaceTests(unittest.TestCase): + def test_concrete_subclass_satisfies_interface(self): + t = ConcreteTransport() + t.send(b"hello") + self.assertEqual(t.sent, b"hello") + self.assertEqual(t.receive(), b"ok") + self.assertEqual(t.receive_exact(3), b"ooo") + self.assertTrue(t.is_connected) + + def test_incomplete_subclass_cannot_be_instantiated(self): + class Incomplete(Transport): + def send(self, data: bytes) -> None: + pass + + with self.assertRaises(TypeError): + Incomplete() # type: ignore[abstract] + + +if TYPE_CHECKING: + # Typing sees a TestCase, so the assertions below resolve; at runtime the base + # is object, which keeps this class out of collection and out of the run. + _ContractTestsBase = unittest.IsolatedAsyncioTestCase +else: + _ContractTestsBase = object + + +class TransportContractTests(_ContractTestsBase): + """What a transport owes its callers, whatever I/O it is built on. + + A concrete transport's test class mixes this in alongside + ``unittest.IsolatedAsyncioTestCase`` and supplies the three hooks below. The + point is that these are answers the :class:`Transport` contract requires -- plus + the setup/stop lifecycle :class:`AsyncTransportBase` adds around it -- and not + observations about one transport, so a transport added later inherits them + rather than reimplementing them and getting one subtly wrong. Tests that turn + on how a particular transport works belong beside that transport instead. + """ + + async def connected_transport(self) -> AsyncTransportBase: + """A set-up transport whose device echoes back whatever is sent to it. + + Cleanup is the hook's responsibility. + """ + raise NotImplementedError + + async def chunked_transport(self) -> AsyncTransportBase: + """A set-up, echoing transport whose device answers in several short reads. + + Cleanup is the hook's responsibility. + """ + raise NotImplementedError + + def unconnected_transport(self) -> AsyncTransportBase: + """A transport that has not been set up.""" + raise NotImplementedError + + async def test_send_before_setup_raises(self): + transport = self.unconnected_transport() + with self.assertRaises(RuntimeError): + await asyncio.to_thread(transport.send, b"x") + + async def test_is_connected_reflects_lifecycle(self): + transport = await self.connected_transport() + self.assertTrue(transport.is_connected) + await transport.stop() + self.assertFalse(transport.is_connected) + await transport.setup() + self.assertTrue(transport.is_connected) + + async def test_send_reaches_the_device(self): + transport = await self.connected_transport() + + def blocking_roundtrip() -> bytes: + transport.send(b"ping") + return transport.receive_exact(4) + + self.assertEqual(await asyncio.to_thread(blocking_roundtrip), b"ping") + + async def test_receive_returns_what_the_device_sent(self): + transport = await self.connected_transport() + + def blocking_roundtrip() -> bytes: + transport.send(b"pong") + return transport.receive() + + self.assertEqual(await asyncio.to_thread(blocking_roundtrip), b"pong") + + async def test_receive_returns_empty_bytes_on_timeout(self): + transport = await self.connected_transport() + self.assertEqual(await asyncio.to_thread(transport.receive, 0.2), b"") + + async def test_receive_exact_assembles_across_reads(self): + transport = await self.chunked_transport() + + def blocking_roundtrip() -> bytes: + transport.send(b"12345678") + return transport.receive_exact(8) + + self.assertEqual(await asyncio.to_thread(blocking_roundtrip), b"12345678") + + async def test_drain_returns_zero_on_an_idle_connection(self): + # Nothing pending is the ordinary case for a drain, not a failure: recovery + # code calls it without knowing whether the device left anything behind. + transport = await self.connected_transport() + self.assertEqual(await asyncio.to_thread(transport.drain), 0) + + async def test_drain_discards_everything_pending_and_leaves_the_device_readable(self): + # Stale bytes amounting to more than one receive() can return, so emptying + # the buffer genuinely takes more than one read. They are sent as a single + # burst rather than dribbled out over time, because a device that is still + # sending is one no timeout-bounded drain can promise to have caught up with + # -- that is what the budget is for -- whereas bytes already sitting in the + # buffer are exactly what this is meant to clear. + transport = await self.connected_transport() + stale = b"x" * (_RECEIVE_BUFFER_SIZE + 200) + + def blocking_call() -> Tuple[int, bytes]: + transport.send(stale) + discarded = transport.drain() + transport.send(b"fresh") + return discarded, transport.receive_exact(5) + + discarded, framed = await asyncio.to_thread(blocking_call) + + # The half that matters: whatever the count says, the next framed read must + # see its own frame and not the tail of the one that was discarded. + self.assertEqual(framed, b"fresh") + self.assertEqual(discarded, len(stale)) + + async def test_receive_exact_timeout_names_the_read_not_the_outer_bound(self): + transport = await self.connected_transport() + + def blocking_call() -> bytes: + transport.send(b"ab") + return transport.receive_exact(4, timeout=0.2) + + with self.assertRaises(TimeoutError) as ctx: + await asyncio.to_thread(blocking_call) + + # The read's own timeout, not the outer future-level bound. Pinned by message + # because class cannot tell them apart: concurrent.futures.TimeoutError is the + # builtin TimeoutError from Python 3.11 onward. + message = str(ctx.exception) + self.assertIn("0.2 seconds", message) + self.assertNotIn("did not complete within", message) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/transport/benchmark_tests.py b/pylabrobot/agilent/bravo/transport/benchmark_tests.py new file mode 100644 index 00000000000..9b22032effb --- /dev/null +++ b/pylabrobot/agilent/bravo/transport/benchmark_tests.py @@ -0,0 +1,367 @@ +"""Measures the async-transport bridge's per-call handoff cost, isolated from I/O. + +Bravo controllers are synchronous and run inside ``asyncio.to_thread``, so every +byte-level operation crosses ``AsyncTransportBase._run`` -- a thread-to-loop hop +via ``asyncio.run_coroutine_threadsafe`` followed by ``future.result(timeout)`` -- +on its way to PyLabRobot's async I/O layer and back. This module measures the +cost of that crossing, which is what the design document's decision gate is +about. + +An earlier version of this benchmark measured that cost by timing a full +``SocketTransport`` round trip over loopback TCP on both the bridged and +direct-async paths and subtracting. That does not work on a machine that stays +busy with other work: a loopback TCP round trip is itself a multi-syscall +operation with its own preemption variance, and on a loaded machine that +variance is large enough to dwarf the handoff cost being measured. Two +independently noisy, I/O-inclusive measurements do not cancel to a clean signal +on subtraction -- delta-at-min swung roughly 3x between runs of that version. + +This version isolates the handoff with no I/O at all: a trivial coroutine +(``_noop``, below) is timed via ``run_coroutine_threadsafe(...).result()`` from a +worker thread, and via a bare ``await`` on the loop with no thread hop. A no-op +is short enough to often complete inside a single OS scheduling quantum, so the +low-end order statistics of many samples actually find calls that ran +uncontended, recovering the handoff's true cost instead of two I/O noise floors. + +The end-to-end ``SocketTransport`` round trip is still measured here and +printed, but strictly as context: it is explicitly not the bridge's cost (it +also includes loopback TCP), it is contention-dominated on a busy machine, and +it does not feed the homing-sequence projection below. + +Contention does not vanish for the handoff measurement either -- it is real CPU +contention, not just I/O noise, so the same low-end-order-statistic reasoning +applies here too: min and the low percentiles (p1, p5, p10) are the figure of +merit, and p95/p99/max are kept only for visibility into how loaded the machine +was, not as an estimate of the handoff's cost. See ``_summarize``. + +This is a timing measurement, not a correctness test, so it carries the +``hardware`` marker and does not run in the default test suite. Run it +explicitly with, e.g.:: + + env/bin/python -m pytest -m hardware -s \ + pylabrobot/agilent/bravo/transport/benchmark_tests.py +""" + +import asyncio +import os +import statistics +import time +import unittest +from typing import Dict, List + +import pytest + +from pylabrobot.agilent.bravo.transport.socket import SocketTransport +from pylabrobot.agilent.bravo.transport.socket_tests import EchoServer + +# The handoff measurement performs no I/O, so it is cheap even at a high sample +# count; a large count gives the low-end estimator more chances to catch a call +# that ran without being preempted, which is the whole point of reading the +# distribution's low end on a contended machine. +_HANDOFF_WARMUP_CALLS = 1000 +_HANDOFF_MEASURED_CALLS = 20000 + +# Generous bound on the handoff's own future.result(), purely to keep this +# benchmark from hanging; it is not a statement about SocketTransport's timeout +# policy, since no transport is involved in this measurement. +_HANDOFF_RESULT_TIMEOUT_S = 5.0 + +# The end-to-end measurement performs real loopback TCP I/O per sample, so it is +# kept at the original, more modest sample count to keep runtime tolerable. +_E2E_WARMUP_CALLS = 300 +_E2E_MEASURED_CALLS = 10000 + +# Small, fixed payload for the end-to-end block: representative of a short +# instrument command/reply, and identical on both of that block's paths so they +# differ only in how the call reaches the event loop, not in how much data +# crosses it. +_PAYLOAD = b"A" * 32 +_IO_TIMEOUT_S = 2.0 + +# Explicit, visible assumption for the projection below. Darwin-generation +# controllers poll per-axis motor state in a tight loop during commutation and +# homing; the real count depends on axis count, polling cadence, and how long +# homing takes on real hardware, none of which have been measured here. This +# number is a stated order-of-magnitude placeholder for "thousands of polls in +# one homing sequence", not a measurement -- substitute the real count once it +# is known. +# Upper bound on motor-state polls in a full multi-axis homing sequence, derived +# from the axis state machines rather than guessed. Darwin axes poll at 200 ms +# with an explicit sleep between reads, bounded by a 20 s homing timeout and a +# 15 s commutation timeout per axis (40 s and 30 s for the W and G axes). One +# axis therefore cannot exceed ~100 homing polls, and a sequence driving every +# axis to its timeout tops out near this figure -- while sleeping ~200 s to do +# it. These are 5 Hz polls, not a tight loop. +_ASSUMED_HOMING_POLL_COUNT = 1000 + +# Keys read as the figure of merit under contention: the low end of the +# distribution, least contaminated by preemption. p95/p99/max are computed and +# printed for visibility only -- see the module docstring. +_LOW_END_KEYS = ("min", "p1", "p5", "p10", "median") +_CONTENTION_KEYS = ("p95", "p99", "max") + + +async def _noop() -> bytes: + """A coroutine that performs no I/O, so the only cost timed is the handoff. + + Returns: + An empty byte string, never inspected; only the completion is timed. + """ + return b"" + + +def _percentile(sorted_values: List[float], pct: float) -> float: + """Linearly interpolated percentile of an already-sorted sequence. + + Args: + sorted_values: Values in ascending order. + pct: Percentile to compute, in ``[0, 100]``. + + Returns: + The interpolated value at ``pct``. + """ + if len(sorted_values) == 1: + return sorted_values[0] + rank = (len(sorted_values) - 1) * (pct / 100.0) + lower = int(rank) + upper = min(lower + 1, len(sorted_values) - 1) + frac = rank - lower + return sorted_values[lower] * (1 - frac) + sorted_values[upper] * frac + + +def _summarize(samples_s: List[float]) -> Dict[str, float]: + """Reduces a list of durations, in seconds, to a microsecond summary. + + Args: + samples_s: Per-call durations, in seconds. + + Returns: + A dict with ``min``, ``p1``, ``p5``, ``p10``, ``median``, ``p95``, ``p99``, + ``max``, and ``mean`` keys, each in microseconds. + """ + values_us = sorted(v * 1e6 for v in samples_s) + return { + "min": values_us[0], + "p1": _percentile(values_us, 1), + "p5": _percentile(values_us, 5), + "p10": _percentile(values_us, 10), + "median": statistics.median(values_us), + "p95": _percentile(values_us, 95), + "p99": _percentile(values_us, 99), + "max": values_us[-1], + "mean": statistics.fmean(values_us), + } + + +def _format_low_row(label: str, summary: Dict[str, float]) -> str: + """Formats the low-end (figure-of-merit) fields of a summary. + + Args: + label: Row label, e.g. ``"bridged"``. + summary: A summary as produced by :func:`_summarize`. + + Returns: + One formatted line. + """ + fields = " ".join(f"{key}={summary[key]:9.2f}us" for key in _LOW_END_KEYS) + return f" {label:<10} {fields}" + + +def _format_contention_row(label: str, summary: Dict[str, float]) -> str: + """Formats the upper-tail (contention-dominated) fields of a summary. + + Args: + label: Row label, e.g. ``"bridged"``. + summary: A summary as produced by :func:`_summarize`. + + Returns: + One formatted line. + """ + fields = " ".join(f"{key}={summary[key]:10.2f}us" for key in _CONTENTION_KEYS) + return f" {label:<10} {fields} mean={summary['mean']:9.2f}us" + + +def _load_average_note() -> str: + """Best-effort description of contention for CPU at the time of the run. + + Returns: + A one-line note; a placeholder if load average is unavailable on this + platform. + """ + try: + one, five, fifteen = os.getloadavg() + cpus = os.cpu_count() or 0 + return f"load average: {one:.2f} {five:.2f} {fifteen:.2f} over 1/5/15 min ({cpus} logical CPUs)" + except (AttributeError, OSError): + return "load average: unavailable on this platform" + + +class BridgeOverheadBenchmark(unittest.IsolatedAsyncioTestCase): + @pytest.mark.hardware + async def test_bridge_overhead(self): + loop = asyncio.get_running_loop() + + # --------------------------------------------------------------------- + # Block 1: bridge handoff cost, no I/O at all -- the figure of merit. + # --------------------------------------------------------------------- + # The bridged path, exercised from inside asyncio.to_thread: this is the + # real calling context every controller call runs in. A single to_thread + # call hosts the whole measured loop, exactly as a single controller + # method hosts many sequential transport calls -- not one to_thread call + # per handoff, which would time thread dispatch instead of the bridge. + def run_handoff_bridged() -> List[float]: + for _ in range(_HANDOFF_WARMUP_CALLS): + asyncio.run_coroutine_threadsafe(_noop(), loop).result(_HANDOFF_RESULT_TIMEOUT_S) + samples = [] + for _ in range(_HANDOFF_MEASURED_CALLS): + start = time.perf_counter() + asyncio.run_coroutine_threadsafe(_noop(), loop).result(_HANDOFF_RESULT_TIMEOUT_S) + samples.append(time.perf_counter() - start) + return samples + + handoff_bridged_samples = await asyncio.to_thread(run_handoff_bridged) + + # The direct-async baseline: the same no-op coroutine, awaited straight on + # the event loop with no thread hop and no run_coroutine_threadsafe call. + for _ in range(_HANDOFF_WARMUP_CALLS): + await _noop() + handoff_direct_samples = [] + for _ in range(_HANDOFF_MEASURED_CALLS): + start = time.perf_counter() + await _noop() + handoff_direct_samples.append(time.perf_counter() - start) + + handoff_bridged = _summarize(handoff_bridged_samples) + handoff_direct = _summarize(handoff_direct_samples) + handoff_delta = {key: handoff_bridged[key] - handoff_direct[key] for key in handoff_bridged} + + # The homing projection is computed from this block's deltas only -- see + # the module docstring for why the end-to-end block below is not used. + projected_min_s = handoff_delta["min"] * _ASSUMED_HOMING_POLL_COUNT / 1e6 + projected_p5_s = handoff_delta["p5"] * _ASSUMED_HOMING_POLL_COUNT / 1e6 + + # --------------------------------------------------------------------- + # Block 2: end-to-end round trip through SocketTransport -- context only. + # Includes loopback TCP; not the bridge's cost; not used for the + # projection below. See the module docstring. + # --------------------------------------------------------------------- + server = EchoServer() + await server.start() + self.addAsyncCleanup(server.stop) + + transport = SocketTransport( + human_readable_device_name="benchmark bravo", + host="127.0.0.1", + port=server.port, + ) + await transport.setup() + self.addAsyncCleanup(transport.stop) + + def run_e2e_bridged() -> List[float]: + for _ in range(_E2E_WARMUP_CALLS): + transport.send(_PAYLOAD) + transport.receive_exact(len(_PAYLOAD)) + samples = [] + for _ in range(_E2E_MEASURED_CALLS): + start = time.perf_counter() + transport.send(_PAYLOAD) + transport.receive_exact(len(_PAYLOAD)) + samples.append(time.perf_counter() - start) + return samples + + e2e_bridged_samples = await asyncio.to_thread(run_e2e_bridged) + + # The same underlying Socket object the transport above wraps, driven + # straight from the event loop with no thread hop. Same server, same + # connection, same payload, same pair of operations (write, read_exact) -- + # the only variable is whether the call crosses + # run_coroutine_threadsafe/future.result or not. + io = transport._io + for _ in range(_E2E_WARMUP_CALLS): + await io.write(_PAYLOAD, timeout=_IO_TIMEOUT_S) + await io.read_exact(len(_PAYLOAD), timeout=_IO_TIMEOUT_S) + e2e_direct_samples = [] + for _ in range(_E2E_MEASURED_CALLS): + start = time.perf_counter() + await io.write(_PAYLOAD, timeout=_IO_TIMEOUT_S) + await io.read_exact(len(_PAYLOAD), timeout=_IO_TIMEOUT_S) + e2e_direct_samples.append(time.perf_counter() - start) + + e2e_bridged = _summarize(e2e_bridged_samples) + e2e_direct = _summarize(e2e_direct_samples) + e2e_delta = {key: e2e_bridged[key] - e2e_direct[key] for key in e2e_bridged} + + # --------------------------------------------------------------------- + # Report. + # --------------------------------------------------------------------- + print() + print("=" * 88) + print("Bravo transport bridge benchmark") + print("=" * 88) + print(f" {_load_average_note()}") + print("=" * 88) + print( + "BLOCK 1 -- bridge handoff cost, no I/O. THIS IS THE FIGURE OF MERIT: the " + "number the design document's decision gate is about." + ) + print( + f" calls measured per path : {_HANDOFF_MEASURED_CALLS} " + f"(plus {_HANDOFF_WARMUP_CALLS} warm-up, discarded)" + ) + print("-" * 88) + print("Low end of the distribution -- least contaminated by preemption:") + print(_format_low_row("bridged", handoff_bridged)) + print(_format_low_row("direct", handoff_direct)) + print(_format_low_row("delta", handoff_delta)) + print("-" * 88) + print("Upper tail -- contention-dominated, NOT the figure of merit, visibility only:") + print(_format_contention_row("bridged", handoff_bridged)) + print(_format_contention_row("direct", handoff_direct)) + print(_format_contention_row("delta", handoff_delta)) + print("-" * 88) + print( + f" handoff delta at min: {handoff_delta['min']:.2f}us " + f"handoff delta at p5: {handoff_delta['p5']:.2f}us" + ) + print("=" * 88) + print( + "BLOCK 2 -- end-to-end round trip through SocketTransport, over loopback " + "TCP. CONTEXT ONLY: not the bridge's cost (includes real socket I/O on " + "both paths), and contention-dominated on a busy machine. Not used below." + ) + print( + f" calls measured per path : {_E2E_MEASURED_CALLS} " + f"(plus {_E2E_WARMUP_CALLS} warm-up, discarded)" + ) + print(f" payload size : {len(_PAYLOAD)} bytes, echoed back") + print("-" * 88) + print("Low end of the distribution:") + print(_format_low_row("bridged", e2e_bridged)) + print(_format_low_row("direct", e2e_direct)) + print(_format_low_row("delta", e2e_delta)) + print("-" * 88) + print("Upper tail -- contention-dominated, visibility only:") + print(_format_contention_row("bridged", e2e_bridged)) + print(_format_contention_row("direct", e2e_direct)) + print(_format_contention_row("delta", e2e_delta)) + print("=" * 88) + print( + f"Projected addition to a Darwin homing sequence, ASSUMING " + f"{_ASSUMED_HOMING_POLL_COUNT} poll round trips (stated assumption, not " + f"measured -- see module docstring / _ASSUMED_HOMING_POLL_COUNT), computed " + f"from the BLOCK 1 handoff deltas (not the end-to-end block):" + ) + print(f" at handoff delta-at-min: {projected_min_s * 1000:.2f} ms total") + print(f" at handoff delta-at-p5 : {projected_p5_s * 1000:.2f} ms total") + print("=" * 88) + + # Sanity: this benchmark's own premise. Fail loudly, rather than printing a + # silently-meaningless comparison, if any path produced no samples. + self.assertEqual(len(handoff_bridged_samples), _HANDOFF_MEASURED_CALLS) + self.assertEqual(len(handoff_direct_samples), _HANDOFF_MEASURED_CALLS) + self.assertEqual(len(e2e_bridged_samples), _E2E_MEASURED_CALLS) + self.assertEqual(len(e2e_direct_samples), _E2E_MEASURED_CALLS) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/transport/serial.py b/pylabrobot/agilent/bravo/transport/serial.py new file mode 100644 index 00000000000..a97e3f2eb07 --- /dev/null +++ b/pylabrobot/agilent/bravo/transport/serial.py @@ -0,0 +1,260 @@ +"""Serial transport for legacy Bravo instruments, over PyLabRobot's serial I/O.""" + +import asyncio +import time +from typing import Optional + +from pylabrobot.io.serial import Serial + +from ._bridge import _RECEIVE_BUFFER_SIZE, AsyncTransportBase + +# Bits on the wire per character at the settings this transport configures: eight +# data bits, one start bit, one stop bit, no parity. +_BITS_PER_CHARACTER = 10 + +# How long receive() keeps draining once a response has started, expressed in +# character times so that it follows the line speed rather than the hardware. +_DRAIN_CHARACTER_TIMES = 2 + + +class SerialTransport(AsyncTransportBase): + """A synchronous byte channel over :class:`pylabrobot.io.serial.Serial`. + + ``Serial.read`` takes no timeout argument. It honors whatever read timeout the + port currently carries, and on expiry returns however many bytes did arrive -- + possibly none -- rather than raising. Both read paths therefore drive that port + timeout through ``Serial.temporary_timeout``, and turn the short read it can + return into the behavior :class:`Transport` specifies: ``receive`` yields ``b""`` + when nothing arrived, ``receive_exact`` raises ``TimeoutError``. + + Where ``SocketTransport`` leans on ``Socket.read_exact``, whose timeout applies + per chunk, ``receive_exact`` here holds a single cumulative deadline across every + read it issues, so ``timeout`` is the true ceiling on the read itself and the + outer, future-level bound is only loop-handoff slack. + + Every path to the port -- both reads and the write behind :meth:`send` -- holds + ``_port_lock`` for the whole of a call, for two reasons that compound. The port's + read timeout is a single piece of state shared by all of them, so two overlapping + reads would each install their own timeout over the other's and restore the wrong + value on the way out. And ``Serial`` runs every port call on one + ``ThreadPoolExecutor(max_workers=1)``, so a read already occupies that worker for + the whole of its timeout: an unserialized write would queue behind it and blow + its own, much smaller, bound against a port that is working perfectly. Serializing + makes the queueing explicit and charges the wait to the caller that is waiting, + which is also how a request/response instrument protocol behaves anyway. + + A send the port cannot complete raises pyserial's ``SerialTimeoutException``, + which :meth:`send` translates to the ``TimeoutError`` :meth:`Transport.send` + specifies. Only the timeout is translated: a ``SerialException`` from, say, an + unplugged adapter is not a timeout, and reporting it as one would invite a + caller to retry against a device that is gone. + """ + + def __init__( + self, + human_readable_device_name: str, + port: str, + baudrate: int = 9600, + timeout: float = 2.0, + ): + """Create a serial transport for the given port. + + Args: + human_readable_device_name: Name used in PyLabRobot's I/O logs. + port: The serial port the instrument is on, e.g. ``/dev/ttyUSB0`` or ``COM3``. + baudrate: Line speed, in bits per second. Also sets how long :meth:`receive` + keeps draining a response that has started; see :meth:`_drain_timeout`. + timeout: Default time to wait for a send to complete, in seconds. Also the + port's initial read timeout, which every read then overrides for its own + duration. + """ + super().__init__(transport_name="serial", endpoint=port) + self._io = Serial( + human_readable_device_name=human_readable_device_name, + port=port, + baudrate=baudrate, + write_timeout=timeout, + timeout=timeout, + ) + self._baudrate = baudrate + self._timeout = timeout + # Built in _open_io rather than here, for two reasons. An asyncio.Lock captures + # a loop when it is constructed on Python 3.9. And on every version a lock that + # has been contended on one loop refuses to be contended on another, so a + # transport stopped and set up again -- or used across two asyncio.run() calls, + # each of which brings its own loop -- needs a lock belonging to whichever loop + # owns the connection for that session. + self._port_lock: Optional[asyncio.Lock] = None + + async def _open_io(self) -> None: + """Open the serial port.""" + self._port_lock = asyncio.Lock() + await self._io.setup() + + async def _close_io(self) -> None: + """Close the serial port.""" + await self._io.stop() + + def _require_port_lock(self) -> asyncio.Lock: + """The port lock, which exists for as long as a loop owns the connection. + + Returns: + The lock built by :meth:`_open_io`. + + Raises: + RuntimeError: If the transport has not been set up. + """ + lock = self._port_lock + if lock is None: + raise RuntimeError("Transport is not set up. Call setup() first.") + return lock + + def _drain_timeout(self) -> float: + """How long :meth:`receive` keeps draining once a response has started. + + Draining with the timeout at zero would return only what the port happened to + hold at that instant, which is a property of the cabling rather than of the + protocol: a USB adapter with a latency timer hands back a batch, a directly + attached UART hands back nothing and ``receive`` degrades to a byte per call. + A couple of character times at the configured line speed is short enough to + keep ``receive`` prompt and long enough to behave the same either way. + + This bounds the drain as a whole, not the gap between bytes: ``Serial`` exposes + the port's total read timeout, not pyserial's ``inter_byte_timeout``. It is + therefore a floor on what a drain collects, not a promise of a whole frame. + + Returns: + The drain timeout, in seconds. + """ + return _DRAIN_CHARACTER_TIMES * _BITS_PER_CHARACTER / self._baudrate + + async def _read_available(self, timeout: float) -> bytes: + """Wait up to ``timeout`` for a first byte, then drain what follows it. + + One blocking read of ``_RECEIVE_BUFFER_SIZE`` bytes would instead wait out the + full timeout on every call, since a response that large essentially never + arrives; waiting for a single byte and then draining briefly returns as soon as + there is anything to return. + + Args: + timeout: Maximum time to wait for the first byte, in seconds, counted from + the call rather than from when the lock is won, so that ``timeout`` bounds + what the caller waits. + + Returns: + The bytes read, or ``b""`` if none arrived. + """ + deadline = time.monotonic() + timeout + async with self._require_port_lock(): + with self._io.temporary_timeout(max(deadline - time.monotonic(), 0.0)): + first = await self._io.read(num_bytes=1) + if len(first) == 0: + return b"" + with self._io.temporary_timeout(self._drain_timeout()): + rest = await self._io.read(num_bytes=_RECEIVE_BUFFER_SIZE - 1) + return first + rest + + async def _read_exact(self, num_bytes: int, timeout: float) -> bytes: + """Read ``num_bytes``, accumulating short reads against one cumulative deadline. + + A read that comes back short is not on its own proof of a timeout, so the + deadline -- not the length of any single read -- decides when to give up, and + each further read gets only the time left rather than a fresh ``timeout``. + + Args: + num_bytes: The exact number of bytes to read. + timeout: Maximum total time to wait, in seconds, counted from the call + rather than from when the lock is won, so that ``timeout`` bounds what + the caller waits. + + Returns: + Exactly ``num_bytes`` bytes. + + Raises: + TimeoutError: If the full count does not arrive before the deadline. + """ + deadline = time.monotonic() + timeout + data = bytearray() + async with self._require_port_lock(): + while len(data) < num_bytes: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError( + f"Timeout while reading from serial port after {timeout} seconds, " + f"{len(data)} of {num_bytes} bytes received" + ) + with self._io.temporary_timeout(remaining): + data.extend(await self._io.read(num_bytes=num_bytes - len(data))) + return bytes(data) + + async def _write(self, data: bytes, timeout: float) -> None: + """Write ``data``, holding the port for the write. + + Waiting for the port is charged to ``timeout``, as it is on the read paths. + Unlike them there is no inner timeout left to shrink afterwards -- ``Serial`` + fixes the write timeout when the port is opened -- so the wait is bounded here + instead, which keeps a busy port reporting this method's own timeout rather + than the outer bound's generic one. + + Args: + data: The bytes to write. + timeout: Maximum time to wait for the port and the write, in seconds. + + Raises: + TimeoutError: If the port does not come free within ``timeout``. + """ + deadline = time.monotonic() + timeout + lock = self._require_port_lock() + unavailable = f"Timeout while waiting for the serial port after {timeout} seconds" + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(unavailable) + try: + await asyncio.wait_for(lock.acquire(), remaining) + except asyncio.TimeoutError as exc: + raise TimeoutError(unavailable) from exc + try: + await self._io.write(data) + finally: + lock.release() + + def send(self, data: bytes) -> None: + """Send raw bytes to the device. See :meth:`Transport.send`. + + A write the port cannot complete raises pyserial's ``SerialTimeoutException``, + which is reported as the ``TimeoutError`` the contract calls for. Any other + ``OSError``, ``SerialException`` included, is left alone: it is a failure of + the connection rather than of timing, and the two want different handling. + """ + try: + self._run_bounded(self._write(data, self._timeout), self._timeout) + except TimeoutError: + # Already the contract's exception, whether it came from the wait for the + # port or from the outer bound. Taken first because TimeoutError is itself + # an OSError, and this path must not need pyserial. + raise + except OSError as exc: + # pyserial is installed by construction here: Serial.setup() raises without + # it, and _run refuses to submit a coroutine before setup() has run. + import serial + + if isinstance(exc, serial.SerialTimeoutException): + raise TimeoutError( + f"Timeout while writing to serial port after {self._timeout} seconds" + ) from exc + raise + + def receive(self, timeout: float = 2.0) -> bytes: + """Receive whatever bytes have arrived, up to 4096 bytes. + + See :meth:`Transport.receive`. Waits up to ``timeout`` for the response to + begin, then drains for a couple of character times. As on a socket, what comes + back is not guaranteed to be a whole frame -- a serial line delivers bytes one + at a time, so a response can easily be split across calls -- and reassembling + one is the caller's responsibility. + """ + return self._run_receive(self._read_available(timeout), timeout) + + def receive_exact(self, num_bytes: int, timeout: float = 2.0) -> bytes: + """Receive exactly ``num_bytes``. See :meth:`Transport.receive_exact`.""" + return self._run_bounded(self._read_exact(num_bytes, timeout), timeout) diff --git a/pylabrobot/agilent/bravo/transport/serial_tests.py b/pylabrobot/agilent/bravo/transport/serial_tests.py new file mode 100644 index 00000000000..85d498288ce --- /dev/null +++ b/pylabrobot/agilent/bravo/transport/serial_tests.py @@ -0,0 +1,381 @@ +import asyncio +import time +import unittest +from typing import List, Optional, Tuple + +from pylabrobot.agilent.bravo.transport._bridge import AsyncTransportBase +from pylabrobot.agilent.bravo.transport.base_tests import TransportContractTests +from pylabrobot.agilent.bravo.transport.serial import SerialTransport +from pylabrobot.io.serial import HAS_SERIAL, Serial + + +class _FakePort: + """The pyserial port object that Serial's timeout helpers read and write through.""" + + def __init__(self, timeout: float): + self.timeout = timeout + + +class FakeSerial(Serial): + """A Serial whose port is a scheduled byte queue rather than a device. + + ``read`` reproduces pyserial's contract, which is what the transport is written + against: block until ``num_bytes`` have arrived or the port's current timeout + elapses, then return whatever did arrive -- possibly nothing -- instead of + raising. Timeouts are read through the inherited ``get_read_timeout``, so + ``temporary_timeout`` drives this fake exactly as it drives a real port. + + ``echo`` makes the port answer a write with the same bytes, the way the socket + tests' EchoServer does, which is what the shared contract suite needs of a + device. ``max_read_size`` caps how much one read returns, so a caller that must + tolerate short reads can be exercised deliberately. + """ + + _POLL_INTERVAL_S = 0.002 + + def __init__( + self, + timeout: float = 1.0, + max_read_size: Optional[int] = None, + echo: bool = False, + ): + super().__init__( + human_readable_device_name="fake bravo", + port="/dev/fake", + timeout=timeout, + ) + # A stand-in for the pyserial port, which is never opened here. + self._ser = _FakePort(timeout) # type: ignore[assignment] + self._max_read_size = max_read_size + self._echo = echo + self._buffered = bytearray() + self._scheduled: List[Tuple[float, bytes]] = [] + self.written: List[bytes] = [] + self.read_sizes: List[int] = [] + self.read_timeouts: List[float] = [] + # Port calls in the order they finished, which is what tells a write that + # waited its turn from one that cut in front of a read. + self.completed: List[str] = [] + self.write_error: Optional[BaseException] = None + + def arrive(self, data: bytes, after: float = 0.0) -> None: + """Make data readable ``after`` seconds from now.""" + self._scheduled.append((time.monotonic() + after, data)) + + def reset(self) -> None: + """Forget every call and every byte, so one fake can serve two sessions.""" + self._buffered.clear() + self._scheduled.clear() + self.written.clear() + self.read_sizes.clear() + self.read_timeouts.clear() + self.completed.clear() + + def _collect_arrived(self) -> None: + now = time.monotonic() + while self._scheduled and self._scheduled[0][0] <= now: + self._buffered.extend(self._scheduled.pop(0)[1]) + + async def setup(self): + pass + + async def stop(self): + pass + + async def write(self, data: bytes) -> None: + if self.write_error is not None: + raise self.write_error + self.written.append(data) + self.completed.append("write") + if self._echo: + self.arrive(data) + + async def read(self, num_bytes: int = 1) -> bytes: + self.read_sizes.append(num_bytes) + self.read_timeouts.append(self.get_read_timeout()) + wanted = num_bytes if self._max_read_size is None else min(num_bytes, self._max_read_size) + deadline = time.monotonic() + self.get_read_timeout() + out = bytearray() + while True: + self._collect_arrived() + take = min(wanted - len(out), len(self._buffered)) + if take > 0: + out.extend(self._buffered[:take]) + del self._buffered[:take] + remaining = deadline - time.monotonic() + if len(out) >= wanted or remaining <= 0: + self.completed.append("read") + return bytes(out) + await asyncio.sleep(min(self._POLL_INTERVAL_S, remaining)) + + +async def _wait_for_first_read(io: FakeSerial) -> None: + """Block until a read has actually reached the port. + + Waiting on the port itself, rather than sleeping a fixed interval and hoping, + is what keeps the overlap these tests depend on from quietly stopping under + load, which is the condition where the lock matters most. + """ + deadline = time.monotonic() + 2.0 + while not io.read_sizes: + if time.monotonic() > deadline: + raise AssertionError("the first read never reached the port") + await asyncio.sleep(0.005) + + +async def _overlapping_reads(transport: SerialTransport, io: FakeSerial) -> List[object]: + """Run a receive and a receive_exact that are certain to overlap on the port. + + Returns the two outcomes: ``b""`` from the receive, and the ``TimeoutError`` + the starved receive_exact raises. + """ + first = asyncio.ensure_future(asyncio.to_thread(transport.receive, 0.30)) + await _wait_for_first_read(io) + second = asyncio.ensure_future(asyncio.to_thread(transport.receive_exact, 2, 0.60)) + return list(await asyncio.gather(first, second, return_exceptions=True)) + + +class SerialTransportTests(TransportContractTests, unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.io = FakeSerial(echo=True) + self.transport = await self._connected(self.io) + + async def _connected(self, io: FakeSerial, baudrate: int = 9600) -> SerialTransport: + # Built through the real constructor, so the keyword arguments it passes to + # Serial stay honest, then pointed at the fake because there is no port here. + transport = SerialTransport( + human_readable_device_name="fake bravo", + port="/dev/fake", + baudrate=baudrate, + ) + transport._io = io + await transport.setup() + self.addAsyncCleanup(transport.stop) + return transport + + async def connected_transport(self) -> AsyncTransportBase: + return self.transport + + async def chunked_transport(self) -> AsyncTransportBase: + return await self._connected(FakeSerial(echo=True, max_read_size=3)) + + def unconnected_transport(self) -> AsyncTransportBase: + transport = SerialTransport(human_readable_device_name="unconnected", port="/dev/fake") + transport._io = FakeSerial() + return transport + + def _send_failure(self, data: bytes) -> BaseException: + """Send from a worker thread and hand back whatever it raised. + + Returning the exception rather than letting it travel out of the thread keeps + its ``__cause__``, for the reason ``AsyncTransportBase._run`` documents. + """ + try: + self.transport.send(data) + except BaseException as exc: # noqa: BLE001 - the exception is the assertion + return exc + raise AssertionError("send did not raise") + + async def test_receive_returns_before_the_timeout_elapses(self): + # The port has no way to say "that is the whole response", so a single + # blocking read of the full buffer size would wait out the timeout on every + # call. receive() waits out the timeout only for the first byte, then drains + # briefly, and so comes back as soon as there is something to hand over. + self.io.arrive(b"hi") + start = time.monotonic() + result = await asyncio.to_thread(self.transport.receive, 2.0) + elapsed = time.monotonic() - start + self.assertEqual(result, b"hi") + self.assertEqual(self.io.read_sizes[0], 1) + self.assertEqual(len(self.io.read_timeouts), 2) + self.assertAlmostEqual(self.io.read_timeouts[0], 2.0, places=1) + self.assertGreater(self.io.read_timeouts[1], 0.0) + self.assertLess(self.io.read_timeouts[1], 0.1) + self.assertLess(elapsed, 1.5) + + async def test_drain_timeout_is_derived_from_the_line_speed(self): + # Draining with the timeout at zero would return only what the port happened + # to hold at that instant, which is a property of the cabling: an adapter + # with a latency timer batches, a directly attached UART does not. A couple + # of character times makes the drain the protocol's business instead, so it + # is nonzero everywhere and halves when the line runs twice as fast. + slow = FakeSerial() + slow_transport = await self._connected(slow, baudrate=9600) + fast = FakeSerial() + fast_transport = await self._connected(fast, baudrate=19200) + + slow.arrive(b"hi") + fast.arrive(b"hi") + await asyncio.to_thread(slow_transport.receive, 2.0) + await asyncio.to_thread(fast_transport.receive, 2.0) + + self.assertGreater(slow.read_timeouts[1], 0.0) + self.assertAlmostEqual(slow.read_timeouts[1], 0.00208, places=5) + self.assertAlmostEqual(slow.read_timeouts[1], 2 * fast.read_timeouts[1], places=6) + + async def test_receive_exact_short_reads_ask_only_for_what_is_outstanding(self): + # A port that hands back less than was asked for on each read: receive_exact + # must keep reading until the count is met, and ask each time only for the + # bytes still missing. + io = FakeSerial(max_read_size=3) + transport = await self._connected(io) + io.arrive(b"12345678") + + result = await asyncio.to_thread(transport.receive_exact, 8, 1.0) + + self.assertEqual(result, b"12345678") + self.assertEqual(io.read_sizes, [8, 5, 2]) + + async def test_receive_exact_waits_for_bytes_that_arrive_over_time(self): + self.io.arrive(b"12") + self.io.arrive(b"34", after=0.05) + self.io.arrive(b"5678", after=0.1) + + start = time.monotonic() + result = await asyncio.to_thread(self.transport.receive_exact, 8, 1.0) + elapsed = time.monotonic() - start + + self.assertEqual(result, b"12345678") + self.assertGreater(elapsed, 0.1) + + async def test_receive_exact_deadline_is_cumulative_across_reads(self): + # A port that returns one byte at a time, with the third byte never coming. + # Each read must be given only the time still left before the deadline, so + # that `timeout` bounds the whole call rather than every read separately. + io = FakeSerial(max_read_size=1) + transport = await self._connected(io) + io.arrive(b"1") + io.arrive(b"2", after=0.15) + + start = time.monotonic() + with self.assertRaises(TimeoutError) as ctx: + await asyncio.to_thread(transport.receive_exact, 4, 0.3) + elapsed = time.monotonic() - start + + message = str(ctx.exception) + self.assertIn("0.3 seconds", message) + self.assertIn("2 of 4 bytes received", message) + self.assertNotIn("did not complete within", message) + # Granted timeouts shrink as the deadline approaches; they never start over. + self.assertLess(io.read_timeouts[-1], io.read_timeouts[0]) + self.assertGreater(elapsed, 0.25) + + async def test_overlapping_reads_do_not_corrupt_the_port_timeout(self): + # The port's read timeout is one piece of state that every reader installs + # over and restores. Two reads running at once, each wrapping its own + # temporary_timeout around an await, would capture each other's value as the + # one to restore: the port would be left holding a read's timeout instead of + # its own, and the second read would be handed a fresh 0.60 seconds rather + # than what was left of it. The lock is what stops both. + original = self.io.get_read_timeout() + + outcomes = await _overlapping_reads(self.transport, self.io) + + self.assertEqual(outcomes[0], b"") + self.assertIsInstance(outcomes[1], TimeoutError) + self.assertEqual(self.io.get_read_timeout(), original) + # The second read waited its turn, so it got what was left of its 0.60 + # seconds after the first read's 0.30, not the whole of it back again. + self.assertEqual(len(self.io.read_timeouts), 2) + self.assertLess(self.io.read_timeouts[1], 0.45) + + async def test_port_lock_belongs_to_the_loop_that_owns_the_connection(self): + # Built where no event loop is running, the way a script that creates its + # transports before starting one does, and set up afterwards on the loop that + # goes on to own the connection. On Python 3.9 an asyncio.Lock captures a loop + # the moment it is constructed, so a lock built beside the transport would + # belong to another loop or to none -- which nothing but contention, where the + # lock has to suspend a waiter, would ever surface. + io = FakeSerial() + + def build() -> SerialTransport: + transport = SerialTransport(human_readable_device_name="fake bravo", port="/dev/fake") + transport._io = io + return transport + + transport = await asyncio.to_thread(build) + await transport.setup() + self.addAsyncCleanup(transport.stop) + + outcomes = await _overlapping_reads(transport, io) + + self.assertEqual(outcomes[0], b"") + self.assertIsInstance(outcomes[1], TimeoutError) + self.assertEqual(io.get_read_timeout(), 1.0) + + async def test_send_waits_for_a_read_to_release_the_port(self): + # Serial runs every port call on one worker thread, so a write issued while a + # read holds that worker queues behind it whether or not this transport says + # so. Saying so is what lets the write be charged for the wait and answer + # within its own budget, instead of the outer bound firing against a port that + # is working perfectly. The write must take its turn, and be seen to. + reading = asyncio.ensure_future(asyncio.to_thread(self.transport.receive, 0.30)) + await _wait_for_first_read(self.io) + + await asyncio.to_thread(self.transport.send, b"ping") + + self.assertEqual(await reading, b"") + self.assertEqual(self.io.written, [b"ping"]) + # The order the port saw them finish in, which is what "took its turn" means + # and is decided by the lock rather than by the clock. Timing how long the + # send waited would instead measure how much of the read's 0.30 seconds had + # already elapsed before the wait could be timed at all. + self.assertEqual(self.io.completed, ["read", "write"]) + + @unittest.skipUnless(HAS_SERIAL, "pyserial is not installed") + async def test_send_reports_a_write_timeout_as_timeout_error(self): + # Transport.send promises TimeoutError, so pyserial's SerialTimeoutException + # -- which is an OSError, not a TimeoutError -- has to be reported as one, or + # a caller could not handle a send timeout the same way across transports. + import serial + + cause = serial.SerialTimeoutException("write timeout") + self.io.write_error = cause + + raised = await asyncio.to_thread(self._send_failure, b"ping") + + self.assertIsInstance(raised, TimeoutError) + self.assertIn("Timeout while writing to serial port", str(raised)) + self.assertIs(raised.__cause__, cause) + + @unittest.skipUnless(HAS_SERIAL, "pyserial is not installed") + async def test_send_leaves_a_non_timeout_serial_failure_alone(self): + # A port that has gone away is not a timeout. Reporting it as one would tell + # a caller to retry against a device that is gone. + import serial + + cause = serial.SerialException("device disconnected") + self.io.write_error = cause + + raised = await asyncio.to_thread(self._send_failure, b"ping") + + self.assertIs(raised, cause) + + +class SerialTransportAcrossLoopsTests(unittest.TestCase): + def test_port_lock_is_rebuilt_for_each_loop_that_owns_the_connection(self): + # asyncio.run() brings its own loop and disposes of it afterwards, so a + # transport driven by two of them is set up on a different loop each time. A + # lock that outlived the first loop would be usable but not contendable on + # the second, which is the state these overlapping reads walk into. + io = FakeSerial() + transport = SerialTransport(human_readable_device_name="fake bravo", port="/dev/fake") + transport._io = io + + async def session() -> List[object]: + await transport.setup() + try: + return await _overlapping_reads(transport, io) + finally: + await transport.stop() + + for _ in range(2): + io.reset() + outcomes = asyncio.run(session()) + self.assertEqual(outcomes[0], b"") + self.assertIsInstance(outcomes[1], TimeoutError) + self.assertEqual(io.get_read_timeout(), 1.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/transport/socket.py b/pylabrobot/agilent/bravo/transport/socket.py new file mode 100644 index 00000000000..59aeb6c1e9b --- /dev/null +++ b/pylabrobot/agilent/bravo/transport/socket.py @@ -0,0 +1,72 @@ +"""TCP transport for Bravo instruments, over PyLabRobot's socket I/O.""" + +from pylabrobot.io.socket import Socket + +from ._bridge import _RECEIVE_BUFFER_SIZE, AsyncTransportBase + + +class SocketTransport(AsyncTransportBase): + """A synchronous byte channel over :class:`pylabrobot.io.socket.Socket`. + + Because ``Socket.read_exact`` applies its timeout per chunk rather than + cumulatively, the outer, future-level bound each call is given (see + ``AsyncTransportBase._run_bounded``) is the real cumulative ceiling on that + call: a trickling + response can delay individual chunks indefinitely, but + ``receive_exact(n, timeout=2.0)`` can itself never take longer than 3.0 seconds + in total, no matter how many chunks the response arrives in. + """ + + def __init__( + self, + human_readable_device_name: str, + host: str, + port: int, + timeout: float = 2.0, + ): + """Create a socket transport for the given host and port. + + Args: + human_readable_device_name: Name used in PyLabRobot's I/O logs. + host: The instrument's IP address or hostname. + port: The instrument's TCP port. + timeout: Default time to wait for a send to complete, in seconds. + """ + super().__init__(transport_name="socket", endpoint=f"{host}:{port}") + self._io = Socket( + human_readable_device_name=human_readable_device_name, + host=host, + port=port, + ) + self._timeout = timeout + + async def _open_io(self) -> None: + """Connect the socket.""" + await self._io.setup() + + async def _close_io(self) -> None: + """Disconnect the socket.""" + await self._io.stop() + + def send(self, data: bytes) -> None: + """Send raw bytes to the device. See :meth:`Transport.send`.""" + self._run_bounded(self._io.write(data, timeout=self._timeout), self._timeout) + + def receive(self, timeout: float = 2.0) -> bytes: + """Receive whatever bytes a single underlying read yields, up to 4096 bytes. + + See :meth:`Transport.receive`. The 4096-byte cap only bounds an unusually + large response; it is not the hazard to plan around. A single read can, and + routinely does, return far fewer bytes than a complete frame regardless of + that frame's size, because it returns whatever fragment TCP has delivered so + far -- a 20-byte frame split across two TCP segments can come back as 8 bytes + on one call. This method never guarantees a complete frame; reassembling one + from possibly-partial reads is the caller's responsibility. + """ + return self._run_receive( + self._io.read(num_bytes=_RECEIVE_BUFFER_SIZE, timeout=timeout), timeout + ) + + def receive_exact(self, num_bytes: int, timeout: float = 2.0) -> bytes: + """Receive exactly ``num_bytes``. See :meth:`Transport.receive_exact`.""" + return self._run_bounded(self._io.read_exact(num_bytes, timeout=timeout), timeout) diff --git a/pylabrobot/agilent/bravo/transport/socket_tests.py b/pylabrobot/agilent/bravo/transport/socket_tests.py new file mode 100644 index 00000000000..e16f3c45f8c --- /dev/null +++ b/pylabrobot/agilent/bravo/transport/socket_tests.py @@ -0,0 +1,183 @@ +import asyncio +import time +import unittest +from typing import Optional +from unittest.mock import patch + +from pylabrobot.agilent.bravo.transport._bridge import AsyncTransportBase +from pylabrobot.agilent.bravo.transport.base_tests import TransportContractTests +from pylabrobot.agilent.bravo.transport.socket import _RECEIVE_BUFFER_SIZE, SocketTransport + + +class _LoopbackServer: + """A local TCP server on an ephemeral port, for exercising the transport. + + Subclasses supply only :meth:`handle`. + """ + + def __init__(self): + self._server: Optional[asyncio.AbstractServer] = None + self.port = 0 + + async def handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + raise NotImplementedError + + async def start(self) -> None: + self._server = await asyncio.start_server(self.handle, "127.0.0.1", 0) + self.port = self._server.sockets[0].getsockname()[1] + + async def stop(self) -> None: + assert self._server is not None + self._server.close() + await self._server.wait_closed() + + +class EchoServer(_LoopbackServer): + """Echoes every write straight back.""" + + async def handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + while True: + data = await reader.read(1024) + if not data: + break + writer.write(data) + await writer.drain() + writer.close() + + +class DribbleServer(_LoopbackServer): + """Echoes each write back in delayed, undersized chunks. + + Unlike ``EchoServer``, a single write from the client is guaranteed to arrive + as several separate reads: each chunk is followed by a delay before the next + one is written, so the client's first read returns before later chunks + exist. This exercises the multi-read assembly path in ``receive_exact``. + """ + + def __init__(self, chunk_size: int = 2, delay: float = 0.05): + super().__init__() + self._chunk_size = chunk_size + self._delay = delay + + async def handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + while True: + data = await reader.read(1024) + if not data: + break + for i in range(0, len(data), self._chunk_size): + writer.write(data[i : i + self._chunk_size]) + await writer.drain() + await asyncio.sleep(self._delay) + writer.close() + + +class SocketTransportTests(TransportContractTests, unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.server = await self._started(EchoServer()) + self.transport = await self._connected("test bravo", self.server.port) + + async def _started(self, server): + await server.start() + self.addAsyncCleanup(server.stop) + return server + + async def _connected(self, name: str, port: int) -> SocketTransport: + transport = SocketTransport( + human_readable_device_name=name, + host="127.0.0.1", + port=port, + ) + await transport.setup() + self.addAsyncCleanup(transport.stop) + return transport + + async def connected_transport(self) -> AsyncTransportBase: + return self.transport + + async def chunked_transport(self) -> AsyncTransportBase: + dribbler = await self._started(DribbleServer(chunk_size=2, delay=0.05)) + return await self._connected("dribble bravo", dribbler.port) + + def unconnected_transport(self) -> AsyncTransportBase: + return SocketTransport( + human_readable_device_name="unconnected", + host="127.0.0.1", + port=self.server.port, + ) + + async def test_receive_caps_at_buffer_size(self): + # A response larger than _RECEIVE_BUFFER_SIZE is truncated to that many + # bytes on a single receive() call; this pins that behavior so it can't + # regress into a silent, undocumented change. Rather than sleeping a fixed + # interval and hoping the whole payload has landed, poll the reader's + # buffered byte count -- without consuming it -- until it has, so this + # can't race the loopback echo under load. + payload = b"x" * (_RECEIVE_BUFFER_SIZE + 200) + await asyncio.to_thread(self.transport.send, payload) + + # _buffer is not part of StreamReader's typed public surface; this is + # test-only introspection to poll for arrival without consuming. + reader = self.transport._io._reader + assert reader is not None + deadline = time.monotonic() + 2.0 + while len(reader._buffer) < len(payload): # type: ignore[attr-defined] + if time.monotonic() > deadline: + buffered = len(reader._buffer) # type: ignore[attr-defined] + self.fail(f"only {buffered} of {len(payload)} bytes had arrived") + await asyncio.sleep(0.005) + + result = await asyncio.to_thread(self.transport.receive) + self.assertEqual(len(result), _RECEIVE_BUFFER_SIZE) + self.assertEqual(result, payload[:_RECEIVE_BUFFER_SIZE]) + + async def test_receive_exact_spans_several_underlying_reads(self): + # The contract suite pins that the eight bytes come back assembled. This + # pins that assembling them really did take more than one read of the + # socket, which is the part Socket.read_exact is being trusted with. + transport = await self.chunked_transport() + + def blocking_roundtrip() -> bytes: + transport.send(b"12345678") + return transport.receive_exact(8) + + start = time.monotonic() + result = await asyncio.to_thread(blocking_roundtrip) + elapsed = time.monotonic() - start + + self.assertEqual(result, b"12345678") + # 8 bytes arrive as four 2-byte chunks, 50ms apart: a single underlying + # read could not have produced this result in under ~150ms. + self.assertGreater(elapsed, 0.15) + + async def test_send_reports_a_write_timeout_as_timeout_error(self): + # Transport.send promises TimeoutError. Socket.write already raises one when + # a drain times out, so what needs pinning is that send hands it on rather + # than swallowing it or letting the outer future-level bound stand in for it. + # Provoking a real drain timeout would mean pushing megabytes at a peer that + # never reads, whose teardown then blocks on that unread data; the exception + # is injected instead, and still travels the whole way out through _run. + injected = TimeoutError("Timeout while writing to socket after 0.2 seconds") + + async def failing_write(data, timeout=None): + raise injected + + def blocking_call() -> BaseException: + with patch.object(self.transport._io, "write", failing_write): + try: + self.transport.send(b"ping") + except BaseException as exc: # noqa: BLE001 - the exception is the assertion + return exc + raise AssertionError("send did not raise") + + raised = await asyncio.to_thread(blocking_call) + + # Asserted on the message rather than on identity, for the reason + # AsyncTransportBase._run documents: from Python 3.11 on, an exception of + # exactly class TimeoutError does not survive the crossing intact. + self.assertIsInstance(raised, TimeoutError) + self.assertIn("Timeout while writing to socket", str(raised)) + self.assertNotIn("did not complete within", str(raised)) + + +if __name__ == "__main__": + unittest.main() From b29e6fc7cd5d27ef9d7342b1ca3deae441641264 Mon Sep 17 00:00:00 2001 From: kelsorj Date: Fri, 21 Aug 2026 10:59:35 -0700 Subject: [PATCH 3/9] Add Bravo wire protocols Two families. Gemini serves Darwin-generation firmware: an 8-byte little-endian header carrying a sync word, protocol version, payload type, and payload size, followed by packets or instructions. V11/Agile serves the Agile, Agile 7612, and SRT generations, with CRC-8/SMBUS on the legacy packet format and CRC-8/MAXIM on the 7612 format. Neither protocol is vendor documentation; both were recovered by observing traffic between Agilent VWorks and an instrument. Neither carries authentication or encryption, so anyone with network access to the instrument can command it. --- pylabrobot/agilent/bravo/protocol/__init__.py | 20 + .../bravo/protocol/agile_7612_commands.py | 82 ++ .../protocol/agile_7612_commands_tests.py | 77 ++ .../agilent/bravo/protocol/agile_7612_crc.py | 291 +++++++ .../bravo/protocol/agile_7612_crc_tests.py | 38 + .../bravo/protocol/agile_7612_packet.py | 279 +++++++ .../bravo/protocol/agile_7612_packet_tests.py | 142 ++++ .../agilent/bravo/protocol/agile_packet.py | 606 ++++++++++++++ .../bravo/protocol/agile_packet_tests.py | 113 +++ pylabrobot/agilent/bravo/protocol/commands.py | 427 ++++++++++ .../agilent/bravo/protocol/commands_tests.py | 141 ++++ .../agilent/bravo/protocol/gemini/__init__.py | 16 + .../agilent/bravo/protocol/gemini/engine.py | 739 ++++++++++++++++++ .../bravo/protocol/gemini/engine_tests.py | 243 ++++++ .../agilent/bravo/protocol/gemini/enums.py | 503 ++++++++++++ .../agilent/bravo/protocol/gemini/errors.py | 184 +++++ .../bravo/protocol/gemini/errors_tests.py | 70 ++ .../agilent/bravo/protocol/gemini/framing.py | 282 +++++++ .../bravo/protocol/gemini/framing_tests.py | 140 ++++ .../bravo/protocol/gemini/instruction.py | 420 ++++++++++ .../protocol/gemini/instruction_tests.py | 121 +++ .../agilent/bravo/protocol/gemini/packet.py | 233 ++++++ .../bravo/protocol/gemini/packet_tests.py | 130 +++ .../bravo/protocol/v11_agile_7612_comm.py | 118 +++ .../protocol/v11_agile_7612_comm_tests.py | 76 ++ pylabrobot/agilent/bravo/protocol/v11_comm.py | 182 +++++ .../agilent/bravo/protocol/v11_comm_tests.py | 142 ++++ 27 files changed, 5815 insertions(+) create mode 100644 pylabrobot/agilent/bravo/protocol/__init__.py create mode 100644 pylabrobot/agilent/bravo/protocol/agile_7612_commands.py create mode 100644 pylabrobot/agilent/bravo/protocol/agile_7612_commands_tests.py create mode 100644 pylabrobot/agilent/bravo/protocol/agile_7612_crc.py create mode 100644 pylabrobot/agilent/bravo/protocol/agile_7612_crc_tests.py create mode 100644 pylabrobot/agilent/bravo/protocol/agile_7612_packet.py create mode 100644 pylabrobot/agilent/bravo/protocol/agile_7612_packet_tests.py create mode 100644 pylabrobot/agilent/bravo/protocol/agile_packet.py create mode 100644 pylabrobot/agilent/bravo/protocol/agile_packet_tests.py create mode 100644 pylabrobot/agilent/bravo/protocol/commands.py create mode 100644 pylabrobot/agilent/bravo/protocol/commands_tests.py create mode 100644 pylabrobot/agilent/bravo/protocol/gemini/__init__.py create mode 100644 pylabrobot/agilent/bravo/protocol/gemini/engine.py create mode 100644 pylabrobot/agilent/bravo/protocol/gemini/engine_tests.py create mode 100644 pylabrobot/agilent/bravo/protocol/gemini/enums.py create mode 100644 pylabrobot/agilent/bravo/protocol/gemini/errors.py create mode 100644 pylabrobot/agilent/bravo/protocol/gemini/errors_tests.py create mode 100644 pylabrobot/agilent/bravo/protocol/gemini/framing.py create mode 100644 pylabrobot/agilent/bravo/protocol/gemini/framing_tests.py create mode 100644 pylabrobot/agilent/bravo/protocol/gemini/instruction.py create mode 100644 pylabrobot/agilent/bravo/protocol/gemini/instruction_tests.py create mode 100644 pylabrobot/agilent/bravo/protocol/gemini/packet.py create mode 100644 pylabrobot/agilent/bravo/protocol/gemini/packet_tests.py create mode 100644 pylabrobot/agilent/bravo/protocol/v11_agile_7612_comm.py create mode 100644 pylabrobot/agilent/bravo/protocol/v11_agile_7612_comm_tests.py create mode 100644 pylabrobot/agilent/bravo/protocol/v11_comm.py create mode 100644 pylabrobot/agilent/bravo/protocol/v11_comm_tests.py diff --git a/pylabrobot/agilent/bravo/protocol/__init__.py b/pylabrobot/agilent/bravo/protocol/__init__.py new file mode 100644 index 00000000000..c9a79932f84 --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/__init__.py @@ -0,0 +1,20 @@ +"""Wire-protocol implementations for Agilent Bravo controllers. + +A Bravo instrument speaks one of two unrelated binary protocols depending on +its controller generation: + +- ``gemini`` -- the framed TCP protocol used by Darwin-generation firmware. +- The V11/Agile protocol (``agile_packet``, ``agile_7612_packet``, + ``agile_7612_commands``, ``agile_7612_crc``, ``commands``, ``v11_comm``, + ``v11_agile_7612_comm``) -- the length-prefixed protocol used to reach the + Rabbit microcontroller on Agile and Agile 7612 controllers. + +Both protocols encode and decode bytes only; neither module in this package +opens a connection. Callers construct a +:class:`~pylabrobot.agilent.bravo.transport.Transport` themselves and hand it +to a comm class (:class:`~pylabrobot.agilent.bravo.protocol.gemini.engine.GeminiEngine`, +:class:`~pylabrobot.agilent.bravo.protocol.v11_comm.V11DeviceComm`, or +:class:`~pylabrobot.agilent.bravo.protocol.v11_agile_7612_comm.V11Agile7612DeviceComm`). +""" + +from __future__ import annotations diff --git a/pylabrobot/agilent/bravo/protocol/agile_7612_commands.py b/pylabrobot/agilent/bravo/protocol/agile_7612_commands.py new file mode 100644 index 00000000000..d270620ebda --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/agile_7612_commands.py @@ -0,0 +1,82 @@ +"""Move-command payload for the Agile 7612 Bravo generation. + +Identical to :class:`~.commands.AgileMoveInfo` except its +``home_complete_register`` field is packed as a uint16 (17-byte payload total) +rather than the uint32 (19-byte payload) the legacy Agile generation uses. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass + +from ..types import _AXIS_BY_CODE, Axis, axis_code + + +@dataclass +class Agile7612MoveInfo: + """Move command payload for the Agile 7612 ``CMD_PREPARE_MOVE``. + + All position/velocity/acceleration values are in encoder ticks and ticks/ms. + + Attributes: + axis: The axis this move targets. + position: Target position (absolute) or delta (relative), in ticks. + velocity: Move velocity, in ticks/ms. + acceleration: Move acceleration, in ticks/ms^2. + absolute_move: Whether ``position`` is absolute rather than relative. + check_for_homed: Whether the controller should refuse the move if the + axis has not been homed. + home_complete_register: The Agile register whose value confirms this + axis's home flag, packed as a uint16. + """ + + axis: Axis + position: float + velocity: float + acceleration: float + absolute_move: bool = True + check_for_homed: bool = True + home_complete_register: int = 0 + + _PACK_FORMAT = " bytes: + """Pack this move command into its 17-byte wire encoding. + + Returns: + The packed payload. + """ + return struct.pack( + self._PACK_FORMAT, + axis_code(self.axis), + self.position, + self.velocity, + self.acceleration, + 1 if self.absolute_move else 0, + 1 if self.check_for_homed else 0, + self.home_complete_register & 0xFFFF, + ) + + @classmethod + def unpack(cls, data: bytes) -> Agile7612MoveInfo: + """Unpack a move command from its 17-byte wire encoding. + + Args: + data: At least 17 bytes, payload first. + + Returns: + The decoded move command. + """ + axis_val, pos, vel, accel, abs_move, check_homed, home_reg = struct.unpack( + cls._PACK_FORMAT, data[: struct.calcsize(cls._PACK_FORMAT)] + ) + return cls( + axis=_AXIS_BY_CODE[axis_val], + position=pos, + velocity=vel, + acceleration=accel, + absolute_move=bool(abs_move), + check_for_homed=bool(check_homed), + home_complete_register=home_reg, + ) diff --git a/pylabrobot/agilent/bravo/protocol/agile_7612_commands_tests.py b/pylabrobot/agilent/bravo/protocol/agile_7612_commands_tests.py new file mode 100644 index 00000000000..a2c9b682a60 --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/agile_7612_commands_tests.py @@ -0,0 +1,77 @@ +import struct +import unittest + +from pylabrobot.agilent.bravo.protocol.agile_7612_commands import Agile7612MoveInfo +from pylabrobot.agilent.bravo.protocol.commands import AgileMoveInfo + + +class Agile7612MoveInfoTests(unittest.TestCase): + def test_pack_length_is_seventeen_bytes(self): + info = Agile7612MoveInfo(axis="x", position=100.0, velocity=1.0, acceleration=0.5) + self.assertEqual(len(info.pack()), 17) + + def test_standard_move_info_is_nineteen_bytes(self): + # The Agile 7612 struct differs from the legacy Agile struct only in + # home_complete_register's width (u16 vs u32) -- 17 bytes vs 19. + info = AgileMoveInfo(axis="x", position=100.0, velocity=1.0, acceleration=0.5) + self.assertEqual(len(info.pack()), 19) + + def test_z_home_matches_capture(self): + # Captured Agile 7612 Z-axis homing PREPARE_MOVE payload; every byte is + # pinned against the real wire capture. + info = Agile7612MoveInfo( + axis="z", + position=0.0, + velocity=200.0, + acceleration=0.6, + absolute_move=True, + check_for_homed=True, + home_complete_register=0x0160, + ) + expected = bytes.fromhex("0200000000000048439a99193f01016001") + self.assertEqual(info.pack(), expected) + + def test_x_relative_jog_field_layout(self): + info = Agile7612MoveInfo( + axis="x", + position=1574.8, + velocity=74.96, + acceleration=0.4, + absolute_move=False, + check_for_homed=False, + home_complete_register=0x015E, + ) + packed = info.pack() + self.assertEqual(len(packed), 17) + self.assertEqual(packed[0], 0) # axis x = 0 + self.assertEqual(packed[13], 0) # absolute_move = False + self.assertEqual(packed[14], 0) # check_for_homed = False + self.assertEqual(struct.unpack_from(" int: + """Compute the CRC-8/MAXIM checksum over a byte sequence. + + Args: + data: The bytes to checksum. + length: How many leading bytes of ``data`` to include. Defaults to all of + ``data``; the Agile 7612 packet codec passes 9 explicitly, to checksum + a 10-byte packet's first nine bytes while excluding its own CRC byte. + + Returns: + The single checksum byte. + """ + if length is None: + length = len(data) + crc = 0 + for i in range(length): + crc = _CRC8_MAXIM_TABLE[crc ^ (data[i] & 0xFF)] + return crc diff --git a/pylabrobot/agilent/bravo/protocol/agile_7612_crc_tests.py b/pylabrobot/agilent/bravo/protocol/agile_7612_crc_tests.py new file mode 100644 index 00000000000..617e7adc74a --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/agile_7612_crc_tests.py @@ -0,0 +1,38 @@ +import unittest + +from pylabrobot.agilent.bravo.protocol.agile_7612_crc import crc8_maxim +from pylabrobot.agilent.bravo.protocol.agile_packet import crc8 as crc8_smbus + + +class Crc8MaximKnownAnswerTests(unittest.TestCase): + def test_controller_identification_packet(self): + # Captured Agile 7612 controller-identification packet (register 0x90); + # the expected byte is the packet's own trailing CRC byte, computed + # independently of this module's crc8_maxim(). + pkt = bytes.fromhex("0990000000000000003f") + self.assertEqual(crc8_maxim(pkt[:9], 9), 0x3F) + self.assertEqual(pkt[9], 0x3F) + + def test_jog_trigger_packet(self): + # Captured Agile 7612 jog-trigger packet (header 0x80, byte[7]=0x36); + # the expected byte is the packet's own trailing CRC byte. + pkt = bytes.fromhex("800040000000053600d8") + self.assertEqual(crc8_maxim(pkt[:9], 9), 0xD8) + self.assertEqual(pkt[9], 0xD8) + + def test_matches_published_check_value(self): + # CRC-8/MAXIM's published catalogue check value: CRC(b"123456789") == 0xA1. + self.assertEqual(crc8_maxim(b"123456789"), 0xA1) + + def test_empty_input(self): + self.assertEqual(crc8_maxim(b"", 0), 0x00) + + +class Crc8MaximDiffersFromSmbusTests(unittest.TestCase): + def test_differs_on_shared_data(self): + data = b"\x01\x00\x00\x01\x00\x00\x00\x00\x00" + self.assertNotEqual(crc8_maxim(data), crc8_smbus(data)) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/protocol/agile_7612_packet.py b/pylabrobot/agilent/bravo/protocol/agile_7612_packet.py new file mode 100644 index 00000000000..d3d81b2a98b --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/agile_7612_packet.py @@ -0,0 +1,279 @@ +"""Agile packet codec for the Agile 7612 Bravo generation (CRC-8/MAXIM variant). + +This wire format is not vendor protocol documentation. It was recovered by +observing traffic between Agilent VWorks and a Bravo Agile 7612 controller, +not from a published specification. The protocol has no authentication or +encryption: anyone with network access to the instrument's TCP port can send +it commands. + +A drop-in replacement for :mod:`.agile_packet` with an identical API: same +10-byte packet layout, same command headers and register addresses, only the +checksum differs. Every packet builder here, and :class:`AgileReply`, use +:func:`~.agile_7612_crc.crc8_maxim` instead of the SMBUS CRC-8 that +:mod:`.agile_packet` uses. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass + +from .agile_7612_crc import crc8_maxim +from .agile_packet import AGILE_PACKET_SIZE, UNIQUE_VALUE_EXPECTED, AgileCommand, AgileRegister + +__all__ = [ + "AGILE_PACKET_SIZE", + "AgileCommand", + "AgileRegister", + "AgileReply", + "UNIQUE_VALUE_EXPECTED", + "crc8", + "verify_packet", + "register_get", + "register_set_value", + "move_absolute_value", + "move_relative_value", + "move_jog_value", + "move_go", + "servo_enable", + "servo_disable", + "reset_faults", + "get_group_a_status", +] + +crc8 = crc8_maxim + + +def _make_packet(header: int, controller_id: int, payload: bytes) -> bytes: + """Build a 10-byte Agile packet with a CRC-8/MAXIM checksum. + + Args: + header: The command-type byte. + controller_id: The target controller ID, or 0 for broadcast. + payload: Up to 7 payload bytes; shorter payloads leave the remainder zero. + + Returns: + The packed 10-byte packet. + """ + pkt = bytearray(AGILE_PACKET_SIZE) + pkt[0] = header & 0xFF + pkt[1] = controller_id & 0xFF + for i, b in enumerate(payload[:7]): + pkt[2 + i] = b + pkt[9] = crc8_maxim(pkt, 9) + return bytes(pkt) + + +def verify_packet(packet: bytes) -> bool: + """Verify a received 10-byte Agile 7612 packet's CRC-8/MAXIM checksum. + + Args: + packet: The packet to verify. + + Returns: + True if ``packet`` is 10 bytes and its checksum byte matches. + """ + if len(packet) != AGILE_PACKET_SIZE: + return False + return crc8_maxim(packet, 9) == packet[9] + + +def register_get(controller_id: int, register: int) -> bytes: + """Build a RegisterGet packet to read a motor controller register. + + Args: + controller_id: The target controller ID. + register: The register address to read. + + Returns: + The packed packet. + """ + payload = struct.pack(" bytes: + """Build a RegisterEqualValue packet to write a motor controller register. + + Args: + controller_id: The target controller ID. + register: The register address to write. + value: The 32-bit value to write. + + Returns: + The packed packet. + """ + payload = struct.pack(" bytes: + """Build a MoveAbsoluteValue packet to set an absolute destination. + + Args: + controller_id: The target controller ID. + axis: The local axis index on that controller. + position_ticks: The destination, in encoder ticks. + + Returns: + The packed packet. + """ + payload = struct.pack(" bytes: + """Build a MoveRelativeValue packet. + + Args: + controller_id: The target controller ID. + axis: The local axis index on that controller. + delta_ticks: The move distance, in encoder ticks. + + Returns: + The packed packet. + """ + payload = struct.pack(" bytes: + """Build a MoveJogValue packet to start a continuous jog. + + Args: + controller_id: The target controller ID. + axis: The local axis index on that controller. + velocity: The jog velocity, in ticks/ms. + + Returns: + The packed packet. + """ + payload = struct.pack(" bytes: + """Build a MoveGo packet to execute pending moves on the given axes. + + Args: + controller_id: The target controller ID. + axis_mask: Bitmask of local axis indices to start. + + Returns: + The packed packet. + """ + payload = struct.pack(" bytes: + """Build a ServoEnable packet. + + Args: + controller_id: The target controller ID. + axis: The local axis index on that controller. + + Returns: + The packed packet. + """ + payload = struct.pack(" bytes: + """Build a ServoDisable packet. + + Args: + controller_id: The target controller ID. + axis: The local axis index on that controller. + + Returns: + The packed packet. + """ + payload = struct.pack(" bytes: + """Build a ResetFaults packet. + + Args: + controller_id: The target controller ID. + axis_mask: Bitmask of local axis indices to reset. + + Returns: + The packed packet. + """ + payload = struct.pack(" bytes: + """Build a GetGroupAStatus packet to read all axis statuses. + + Args: + controller_id: The target controller ID. + + Returns: + The packed packet. + """ + payload = b"\x00" * 7 + return _make_packet(AgileCommand.GET_GROUP_A_STATUS, controller_id, payload) + + +@dataclass +class AgileReply: + """A parsed Agile 7612 response packet. + + Attributes: + header: The response's header/command-type byte. + controller_id: The responding controller's ID. + payload: The 7-byte payload (packet bytes 2-8). + crc_valid: Whether the packet's checksum byte matched. + """ + + header: int + controller_id: int + payload: bytes + crc_valid: bool + + @classmethod + def from_packet(cls, packet: bytes) -> AgileReply: + """Parse a 10-byte Agile 7612 response packet. + + Args: + packet: The raw packet bytes. + + Returns: + The parsed reply. Checking :attr:`crc_valid` is the caller's + responsibility; this does not raise on a checksum mismatch. + + Raises: + ValueError: If ``packet`` is not exactly :data:`AGILE_PACKET_SIZE` bytes. + """ + if len(packet) != AGILE_PACKET_SIZE: + raise ValueError(f"Expected {AGILE_PACKET_SIZE} bytes, got {len(packet)}") + return cls( + header=packet[0], + controller_id=packet[1], + payload=packet[2:9], + crc_valid=verify_packet(packet), + ) + + def get_register_value(self) -> int: + """Extract a 32-bit register value from the response payload. + + Returns: + The decoded value. + """ + (value,) = struct.unpack_from(" float: + """Extract a float value from the response payload. + + Returns: + The decoded value. + """ + (value,) = struct.unpack_from(" int: + """Compute the CRC-8/SMBUS checksum over a byte sequence. + + Args: + data: The bytes to checksum. + length: How many leading bytes of ``data`` to include. Defaults to all of + ``data``; packet building passes 9, to checksum a 10-byte packet's + first nine bytes while excluding its own CRC byte. + + Returns: + The single checksum byte. + """ + if length is None: + length = len(data) + crc = 0 + for i in range(length): + crc = _CRC8_TABLE[crc ^ (data[i] & 0xFF)] + return crc + + +def _make_packet(header: int, controller_id: int, payload: bytes) -> bytes: + """Build a 10-byte Agile packet with a CRC-8/SMBUS checksum. + + Args: + header: The command-type byte. + controller_id: The target controller ID, or 0 for broadcast. + payload: Up to 7 payload bytes; shorter payloads leave the remainder zero. + + Returns: + The packed 10-byte packet. + """ + pkt = bytearray(AGILE_PACKET_SIZE) + pkt[0] = header & 0xFF + pkt[1] = controller_id & 0xFF + for i, b in enumerate(payload[:7]): + pkt[2 + i] = b + pkt[9] = crc8(pkt, 9) + return bytes(pkt) + + +def verify_packet(packet: bytes) -> bool: + """Verify a received 10-byte Agile packet's CRC-8/SMBUS checksum. + + Args: + packet: The packet to verify. + + Returns: + True if ``packet`` is 10 bytes and its checksum byte matches. + """ + if len(packet) != AGILE_PACKET_SIZE: + return False + return crc8(packet, 9) == packet[9] + + +# --------------------------------------------------------------------------- +# Agile command types (header byte values) +# --------------------------------------------------------------------------- + + +class AgileCommand(IntEnum): + """Header byte values selecting an Agile packet's command type.""" + + REGISTER_GET = 0x01 + REGISTER_SET = 0x02 + MOVE_ABSOLUTE = 0x10 + MOVE_RELATIVE = 0x11 + MOVE_JOG = 0x12 + MOVE_GO = 0x13 + SERVO_ENABLE = 0x20 + SERVO_DISABLE = 0x21 + RESET_FAULTS = 0x30 + GET_GROUP_A_STATUS = 0x40 + + +# --------------------------------------------------------------------------- +# Common Agile registers +# --------------------------------------------------------------------------- + + +class AgileRegister(IntEnum): + """Register addresses read and written on the Agile controller.""" + + UNIQUE_VALUE = 0x0100 # A_Control_Unique_Value, expects 0xAA55 + POSITION = 0x0200 # Current position (ticks) + VELOCITY = 0x0201 # Current velocity + STATUS_A = 0x0300 # Group A status bits + HOME_FLAG = 0x0400 # Home flag register + POSITION_ERROR = 0x0500 # Position error + MAX_POSITION_ERROR = 0x0501 # Maximum allowable position error + SERVO_ENABLED = 0x0600 # Servo enable state + # ADC registers for head detection calibration. + CORE_ADC0 = 0x0846 # 2118 decimal + OFFSET_ADC0 = 0x0848 # 2120 decimal + OFFSET_ADC3 = 0x0869 # 2153 decimal + # CRC error tracking. + CRC_ERROR_COUNT = 0x095B + + +UNIQUE_VALUE_EXPECTED = 0xAA55 + + +# --------------------------------------------------------------------------- +# Packet builder functions +# --------------------------------------------------------------------------- + + +def register_get(controller_id: int, register: int) -> bytes: + """Build a RegisterGet packet to read a motor controller register. + + Args: + controller_id: The target controller ID. + register: The register address to read. + + Returns: + The packed packet. + """ + payload = struct.pack(" bytes: + """Build a RegisterEqualValue packet to write a motor controller register. + + Args: + controller_id: The target controller ID. + register: The register address to write. + value: The 32-bit value to write. + + Returns: + The packed packet. + """ + payload = struct.pack(" bytes: + """Build a MoveAbsoluteValue packet to set an absolute destination. + + Args: + controller_id: The target controller ID. + axis: The local axis index on that controller. + position_ticks: The destination, in encoder ticks. + + Returns: + The packed packet. + """ + payload = struct.pack(" bytes: + """Build a MoveRelativeValue packet. + + Args: + controller_id: The target controller ID. + axis: The local axis index on that controller. + delta_ticks: The move distance, in encoder ticks. + + Returns: + The packed packet. + """ + payload = struct.pack(" bytes: + """Build a MoveJogValue packet to start a continuous jog. + + Args: + controller_id: The target controller ID. + axis: The local axis index on that controller. + velocity: The jog velocity, in ticks/ms. + + Returns: + The packed packet. + """ + payload = struct.pack(" bytes: + """Build a MoveGo packet to execute pending moves on the given axes. + + Args: + controller_id: The target controller ID. + axis_mask: Bitmask of local axis indices to start. + + Returns: + The packed packet. + """ + payload = struct.pack(" bytes: + """Build a ServoEnable packet. + + Args: + controller_id: The target controller ID. + axis: The local axis index on that controller. + + Returns: + The packed packet. + """ + payload = struct.pack(" bytes: + """Build a ServoDisable packet. + + Args: + controller_id: The target controller ID. + axis: The local axis index on that controller. + + Returns: + The packed packet. + """ + payload = struct.pack(" bytes: + """Build a ResetFaults packet. + + Args: + controller_id: The target controller ID. + axis_mask: Bitmask of local axis indices to reset. + + Returns: + The packed packet. + """ + payload = struct.pack(" bytes: + """Build a GetGroupAStatus packet to read all axis statuses. + + Args: + controller_id: The target controller ID. + + Returns: + The packed packet. + """ + payload = b"\x00" * 7 + return _make_packet(AgileCommand.GET_GROUP_A_STATUS, controller_id, payload) + + +# --------------------------------------------------------------------------- +# Response parsing +# --------------------------------------------------------------------------- + + +@dataclass +class AgileReply: + """A parsed Agile response packet. + + Attributes: + header: The response's header/command-type byte. + controller_id: The responding controller's ID. + payload: The 7-byte payload (packet bytes 2-8). + crc_valid: Whether the packet's checksum byte matched. + """ + + header: int + controller_id: int + payload: bytes + crc_valid: bool + + @classmethod + def from_packet(cls, packet: bytes) -> AgileReply: + """Parse a 10-byte Agile response packet. + + Args: + packet: The raw packet bytes. + + Returns: + The parsed reply. Checking :attr:`crc_valid` is the caller's + responsibility; this does not raise on a checksum mismatch. + + Raises: + ValueError: If ``packet`` is not exactly :data:`AGILE_PACKET_SIZE` bytes. + """ + if len(packet) != AGILE_PACKET_SIZE: + raise ValueError(f"Expected {AGILE_PACKET_SIZE} bytes, got {len(packet)}") + return cls( + header=packet[0], + controller_id=packet[1], + payload=packet[2:9], + crc_valid=verify_packet(packet), + ) + + def get_register_value(self) -> int: + """Extract a 32-bit register value from the response payload. + + Returns: + The decoded value. + """ + (value,) = struct.unpack_from(" float: + """Extract a float value from the response payload. + + Returns: + The decoded value. + """ + (value,) = struct.unpack_from(" Rabbit) +# --------------------------------------------------------------------------- + + +class CommandID(IntEnum): + """Command IDs sent to the Bravo via the V11DeviceComm protocol. + + 0x01-0x02, 0x04-0x07, and 0x0E-0x0F are reserved by the Rabbit firmware + (deprecated firmware response, meta-framework, abort/pause/unpause/ignore, + and protocol-version query respectively) and are not assigned here. + """ + + QUERY_VERSION = 0x00 + PING_DEVICE = 0xA0 + DIRECT_AGILE_COMMAND = 0xA1 + PREPARE_MOVE = 0xA2 + QUERY_ROBOT_DISABLE = 0xA3 + QUERY_MOTOR_POWER = 0xA4 + CLEAR_MOTOR_POWER_FAULT = 0xA5 + GET_POSITION = 0xA6 + QUERY_STATE = 0xA7 + CLEAR_GO_BUTTON = 0xA8 + GO_BUTTON_PRESSED = 0xA9 + PREPARE_JOG = 0xAA + STOP = 0xAB + QUERY_JOG_STATUS = 0xAE + SET_LIGHT = 0xB0 + CLEAR_LIGHTS = 0xB1 + DETECT_GRIPPER = 0xB2 + READ_AD_WEIGH_PAD = 0xB3 + GRIP = 0xB4 + DETECT_SMART_HEAD = 0xB5 + GET_EEPROM_DATA = 0xB6 + WRITE_EEPROM_DATA = 0xB7 + WRITE_SERIAL_NUMBER = 0xB8 + GET_SERIAL_NUMBER = 0xB9 + + +# --------------------------------------------------------------------------- +# Binary command payload structures (1-byte aligned, packed, no padding) +# --------------------------------------------------------------------------- + + +@dataclass +class AgileMoveInfo: + """Move command payload for ``CMD_PREPARE_MOVE`` on the legacy Agile generation. + + All position/velocity/acceleration values are in encoder ticks and ticks/ms. + + Attributes: + axis: The axis this move targets. + position: Target position (absolute) or delta (relative), in ticks. + velocity: Move velocity, in ticks/ms. + acceleration: Move acceleration, in ticks/ms^2. + absolute_move: Whether ``position`` is absolute rather than relative. + check_for_homed: Whether the controller should refuse the move if the + axis has not been homed. + home_complete_register: The Agile register whose value confirms this + axis's home flag, packed as a uint32. The Agile 7612 generation packs + this same field as a uint16; see + :class:`~.agile_7612_commands.Agile7612MoveInfo`. + """ + + axis: Axis + position: float + velocity: float + acceleration: float + absolute_move: bool = True + check_for_homed: bool = True + home_complete_register: int = 0 + + # u8 + 3*float + 2*u8 + u32 = 1 + 12 + 2 + 4 = 19 bytes + _PACK_FORMAT = " bytes: + """Pack this move command into its 19-byte wire encoding. + + Returns: + The packed payload. + """ + return struct.pack( + self._PACK_FORMAT, + axis_code(self.axis), + self.position, + self.velocity, + self.acceleration, + 1 if self.absolute_move else 0, + 1 if self.check_for_homed else 0, + self.home_complete_register, + ) + + @classmethod + def unpack(cls, data: bytes) -> AgileMoveInfo: + """Unpack a move command from its 19-byte wire encoding. + + Args: + data: At least 19 bytes, payload first. + + Returns: + The decoded move command. + """ + axis_val, pos, vel, accel, abs_move, check_homed, home_reg = struct.unpack( + cls._PACK_FORMAT, data[: struct.calcsize(cls._PACK_FORMAT)] + ) + return cls( + axis=_AXIS_BY_CODE[axis_val], + position=pos, + velocity=vel, + acceleration=accel, + absolute_move=bool(abs_move), + check_for_homed=bool(check_homed), + home_complete_register=home_reg, + ) + + +@dataclass +class AgileJogInfo: + """Jog command payload for ``CMD_PREPARE_JOG``. + + Attributes: + axis: The axis to jog. + velocity: Jog velocity, in ticks/ms. + acceleration: Jog acceleration, in ticks/ms^2. + max_position: Position limit the jog must not cross, in ticks. + tolerance: Position tolerance, in ticks. + peak_current: Peak motor current, as a fraction of the axis's maximum. + """ + + axis: Axis + velocity: float + acceleration: float + max_position: float + tolerance: float + peak_current: float + + _PACK_FORMAT = " bytes: + """Pack this jog command into its wire encoding. + + Returns: + The packed payload. + """ + return struct.pack( + self._PACK_FORMAT, + axis_code(self.axis), + self.velocity, + self.acceleration, + self.max_position, + self.tolerance, + self.peak_current, + ) + + @classmethod + def unpack(cls, data: bytes) -> AgileJogInfo: + """Unpack a jog command from its wire encoding. + + Args: + data: At least ``struct.calcsize(AgileJogInfo._PACK_FORMAT)`` bytes, + payload first. + + Returns: + The decoded jog command. + """ + vals = struct.unpack(cls._PACK_FORMAT, data[: struct.calcsize(cls._PACK_FORMAT)]) + return cls( + axis=_AXIS_BY_CODE[vals[0]], + velocity=vals[1], + acceleration=vals[2], + max_position=vals[3], + tolerance=vals[4], + peak_current=vals[5], + ) + + +@dataclass +class LightCommandData: + """Wire payload for ``CMD_SET_LIGHT``: a color, blink period, and duty cycle. + + This mirrors the wire format exactly, including its millisecond period + field: unlike :class:`~pylabrobot.agilent.bravo.types.LightCommand`, which + stores the period in seconds for the rest of the driver to use, + ``period_ms`` here is the literal 32-bit millisecond count the firmware + reads. Build one from a :class:`~pylabrobot.agilent.bravo.types.LightCommand` + with :meth:`from_light_command` rather than passing + ``int(command.period)`` as ``period_ms`` -- truncating a sub-second period + to an int silently produces 0, which the firmware reads as solid instead of + blinking. + + Attributes: + light: The color channel(s) to light. + period_ms: Blink period, in whole milliseconds. 0 means solid. + duty_cycle: Fraction of each period the light is on, 0.0 to 1.0. + """ + + light: LightColor + period_ms: int = 0 + duty_cycle: float = 1.0 + + _PACK_FORMAT = " bytes: + """Pack this light command into its 9-byte wire encoding. + + Returns: + The packed payload. + """ + return struct.pack(self._PACK_FORMAT, int(self.light), self.period_ms, self.duty_cycle) + + @classmethod + def unpack(cls, data: bytes) -> LightCommandData: + """Unpack a light command from its 9-byte wire encoding. + + Args: + data: At least 9 bytes, payload first. + + Returns: + The decoded light command. + """ + light, period, duty = struct.unpack(cls._PACK_FORMAT, data[: struct.calcsize(cls._PACK_FORMAT)]) + return cls(light=LightColor(light), period_ms=period, duty_cycle=duty) + + @classmethod + def from_light_command(cls, command: LightCommand) -> LightCommandData: + """Build a wire payload from a driver-level light command. + + Converts ``command.period`` from seconds to the wire's millisecond count + via :func:`~pylabrobot.agilent.bravo.types.light_command_period_ms`, + rather than truncating it directly. + + Args: + command: The light command to convert. + + Returns: + The equivalent wire payload. + """ + return cls( + light=command.color, + period_ms=light_command_period_ms(command), + duty_cycle=command.duty_cycle, + ) + + def to_light_command(self) -> LightCommand: + """Convert this wire payload back to a driver-level light command. + + Returns: + The equivalent :class:`~pylabrobot.agilent.bravo.types.LightCommand`, + with :attr:`period_ms` converted back to seconds. + """ + return LightCommand( + color=LightColor(self.light), + period=self.period_ms / 1000.0, + duty_cycle=self.duty_cycle, + ) + + +@dataclass +class GripperParams: + """Gripper command payload for ``CMD_GRIP``. + + Attributes: + grip_current: Current limit during the grip move, in amps. + grip_velocity: Grip move velocity. + grip_acceleration: Grip move acceleration. + target_position: Target jaw position. + position_tolerance: Position tolerance for detecting a successful grip. + max_gripper_current: Absolute current ceiling for the gripper axis. + original_max_pos_error: The G-axis's normal max-position-error limit, to + restore after the grip move's own tolerance is done with it. + original_velocity: The G-axis's normal velocity, to restore afterward. + original_acceleration: The G-axis's normal acceleration, to restore + afterward. + ticks_per_eng_unit: Encoder ticks per engineering unit for the G-axis. + """ + + grip_current: float + grip_velocity: float + grip_acceleration: float + target_position: float + position_tolerance: float + max_gripper_current: float + original_max_pos_error: float + original_velocity: float + original_acceleration: float + ticks_per_eng_unit: float + + _PACK_FORMAT = " bytes: + """Pack this gripper command into its 40-byte wire encoding. + + Returns: + The packed payload. + """ + return struct.pack( + self._PACK_FORMAT, + self.grip_current, + self.grip_velocity, + self.grip_acceleration, + self.target_position, + self.position_tolerance, + self.max_gripper_current, + self.original_max_pos_error, + self.original_velocity, + self.original_acceleration, + self.ticks_per_eng_unit, + ) + + @classmethod + def unpack(cls, data: bytes) -> GripperParams: + """Unpack a gripper command from its 40-byte wire encoding. + + Args: + data: At least 40 bytes, payload first. + + Returns: + The decoded gripper command. + """ + vals = struct.unpack(cls._PACK_FORMAT, data[: struct.calcsize(cls._PACK_FORMAT)]) + return cls(*vals) + + +@dataclass +class SmartHeadEEPROMData: + """EEPROM read/write payload for ``CMD_GET_EEPROM_DATA``/``CMD_WRITE_EEPROM_DATA``. + + Attributes: + address: The EEPROM address to read or write. + length: Number of valid bytes in ``data``, 1-5. + data: Up to 5 bytes of EEPROM content. + """ + + address: int + length: int + data: bytes = b"" + + _PACK_FORMAT = " bytes: + """Pack this EEPROM command into its 7-byte wire encoding. + + Returns: + The packed payload, with ``data`` padded or truncated to 5 bytes. + """ + padded = (self.data + b"\x00" * 5)[:5] + return struct.pack(self._PACK_FORMAT, self.address, self.length, padded) + + @classmethod + def unpack(cls, raw: bytes) -> SmartHeadEEPROMData: + """Unpack an EEPROM command from its 7-byte wire encoding. + + Args: + raw: At least 7 bytes, payload first. + + Returns: + The decoded EEPROM command, with ``data`` trimmed to ``length`` bytes. + """ + addr, length, data_bytes = struct.unpack( + cls._PACK_FORMAT, raw[: struct.calcsize(cls._PACK_FORMAT)] + ) + return cls(address=addr, length=length, data=data_bytes[:length]) + + +# --------------------------------------------------------------------------- +# Smart Head EEPROM address map +# --------------------------------------------------------------------------- + + +class EEPROMAddress(IntEnum): + """EEPROM field addresses on a smart pipetting head.""" + + FIRMWARE_VERSION = 0x00 + HEAD_TYPE = 0x01 + HOMING_OFFSET = 0x02 # 2 bytes + W_AXIS_TRAVEL = 0x04 # 5 bytes (cumulative mm) + W_AXIS_DIR_CHANGES = 0x09 # 4 bytes + PM_DATE = 0x0D # 2 bytes + W_TRAVEL_PRIOR_PM = 0x0F # 5 bytes + W_DIR_CHANGES_PRIOR_PM = 0x14 # 4 bytes + TOTAL_EEPROM_WRITES = 0x18 # 3 bytes + SERIAL_NUMBER_LENGTH = 0x1B # 1 byte + SERIAL_NUMBER = 0x1C # 1-20 bytes + + +# --------------------------------------------------------------------------- +# Default timeouts +# --------------------------------------------------------------------------- + +DEFAULT_COMMAND_TIMEOUT = 2.0 +MAX_COMMAND_RETRIES = 5 diff --git a/pylabrobot/agilent/bravo/protocol/commands_tests.py b/pylabrobot/agilent/bravo/protocol/commands_tests.py new file mode 100644 index 00000000000..ab8d34af646 --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/commands_tests.py @@ -0,0 +1,141 @@ +import unittest +from typing import List, Tuple + +from pylabrobot.agilent.bravo.protocol.commands import ( + DEFAULT_COMMAND_TIMEOUT, + AgileJogInfo, + AgileMoveInfo, + GripperParams, + LightCommandData, + SmartHeadEEPROMData, +) +from pylabrobot.agilent.bravo.types import Axis, LightColor, LightCommand + + +class AgileMoveInfoTests(unittest.TestCase): + def test_pack_length(self): + info = AgileMoveInfo(axis="x", position=100.0, velocity=1.0, acceleration=0.5) + self.assertEqual(len(info.pack()), 19) + + def test_roundtrip(self): + info = AgileMoveInfo( + axis="z", + position=1000.0, + velocity=50.0, + acceleration=100.0, + absolute_move=True, + check_for_homed=True, + home_complete_register=0x0160, + ) + restored = AgileMoveInfo.unpack(info.pack()) + self.assertEqual(restored.axis, "z") + self.assertAlmostEqual(restored.position, 1000.0, places=2) + self.assertTrue(restored.absolute_move) + self.assertEqual(restored.home_complete_register, 0x0160) + + def test_axis_byte_matches_wire_code_table(self): + # x=0, y=1, z=2, w=3, g=4, zg=5 -- see types._AXIS_CODES. + cases: List[Tuple[Axis, int]] = [ + ("x", 0), + ("y", 1), + ("z", 2), + ("w", 3), + ("g", 4), + ("zg", 5), + ] + for axis, code in cases: + info = AgileMoveInfo(axis=axis, position=0.0, velocity=0.0, acceleration=0.0) + self.assertEqual(info.pack()[0], code) + + +class AgileJogInfoTests(unittest.TestCase): + def test_roundtrip(self): + info = AgileJogInfo( + axis="g", + velocity=10.0, + acceleration=5.0, + max_position=200.0, + tolerance=1.5, + peak_current=0.2, + ) + restored = AgileJogInfo.unpack(info.pack()) + self.assertEqual(restored.axis, "g") + self.assertAlmostEqual(restored.velocity, 10.0, places=3) + self.assertAlmostEqual(restored.peak_current, 0.2, places=3) + + +class LightCommandDataTests(unittest.TestCase): + def test_pack_unpack_roundtrip(self): + cmd = LightCommandData( + light=LightColor.RED | LightColor.GREEN, + period_ms=500, + duty_cycle=0.5, + ) + restored = LightCommandData.unpack(cmd.pack()) + self.assertTrue(restored.light & LightColor.RED) + self.assertTrue(restored.light & LightColor.GREEN) + self.assertEqual(restored.period_ms, 500) + self.assertAlmostEqual(restored.duty_cycle, 0.5, places=3) + + def test_from_light_command_converts_seconds_to_milliseconds(self): + # A 0.5s period must survive as period_ms=500, not int(0.5)=0, which the + # firmware would read as "solid" instead of blinking. + command = LightCommand(color=LightColor.BLUE, period=0.5, duty_cycle=1.0) + data = LightCommandData.from_light_command(command) + self.assertEqual(data.period_ms, 500) + + def test_period_half_second_survives_full_roundtrip(self): + command = LightCommand(color=LightColor.BLUE, period=0.5, duty_cycle=0.75) + packed = LightCommandData.from_light_command(command).pack() + unpacked = LightCommandData.unpack(packed) + recovered = unpacked.to_light_command() + self.assertEqual(recovered.period, 0.5) + self.assertNotEqual(recovered.period, 0.0) + self.assertAlmostEqual(recovered.duty_cycle, 0.75, places=3) + + def test_zero_period_means_solid(self): + command = LightCommand(color=LightColor.RED, period=0.0) + data = LightCommandData.from_light_command(command) + self.assertEqual(data.period_ms, 0) + + +class GripperParamsTests(unittest.TestCase): + def test_roundtrip(self): + params = GripperParams( + grip_current=0.3, + grip_velocity=10.0, + grip_acceleration=5.0, + target_position=2.0, + position_tolerance=0.1, + max_gripper_current=0.5, + original_max_pos_error=0.2, + original_velocity=20.0, + original_acceleration=15.0, + ticks_per_eng_unit=944.88, + ) + restored = GripperParams.unpack(params.pack()) + self.assertAlmostEqual(restored.grip_current, 0.3, places=3) + self.assertAlmostEqual(restored.ticks_per_eng_unit, 944.88, places=1) + + +class SmartHeadEEPROMDataTests(unittest.TestCase): + def test_roundtrip(self): + eeprom = SmartHeadEEPROMData(address=0x01, length=1, data=b"\x03") + restored = SmartHeadEEPROMData.unpack(eeprom.pack()) + self.assertEqual(restored.address, 0x01) + self.assertEqual(restored.length, 1) + self.assertEqual(restored.data, b"\x03") + + def test_data_padded_to_five_bytes_on_pack(self): + eeprom = SmartHeadEEPROMData(address=0x1C, length=3, data=b"abc") + self.assertEqual(len(eeprom.pack()), 7) + + +class DefaultCommandTimeoutTests(unittest.TestCase): + def test_is_in_seconds(self): + # DEFAULT_COMMAND_TIMEOUT is expressed in seconds, not milliseconds. + self.assertEqual(DEFAULT_COMMAND_TIMEOUT, 2.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/protocol/gemini/__init__.py b/pylabrobot/agilent/bravo/protocol/gemini/__init__.py new file mode 100644 index 00000000000..73aa09b8783 --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/gemini/__init__.py @@ -0,0 +1,16 @@ +"""Gemini wire protocol -- the framed TCP protocol spoken by Darwin-generation +Bravo firmware. + +Submodules: + +- ``enums`` -- command types, subcommand tables, NAK codes, motor states, and + other wire-level constants. +- ``framing`` -- the 8-byte outer TCP frame header and its payload wrappers. +- ``packet`` -- the 8-byte Gemini packet codec and controller-tree addressing. +- ``instruction`` -- the 4-word motion/delay/tips instruction codec. +- ``errors`` -- protocol-level exceptions and NAK-to-error-type mapping. +- ``engine`` -- the synchronous request/response dispatcher that drives a + connected transport. +""" + +from __future__ import annotations diff --git a/pylabrobot/agilent/bravo/protocol/gemini/engine.py b/pylabrobot/agilent/bravo/protocol/gemini/engine.py new file mode 100644 index 00000000000..67fff6188ea --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/gemini/engine.py @@ -0,0 +1,739 @@ +"""GeminiEngine -- synchronous GET/SET/multipacket dispatcher for a Darwin controller. + +Every command is issued under a single lock with one shared "response +complete" event, so at most one request is ever outstanding at a time. A +background thread continuously reads frames off the transport and dispatches +them: a GET/SET response wakes the waiting caller, while an unsolicited +trigger, stream, or reserved-event frame is fanned out to registered +callbacks. + +Usage:: + + transport = SocketTransport("bravo", "192.168.0.8", TCP_PORT) + # transport.setup() is awaited by the caller before engine use begins. + engine = GeminiEngine(transport) + engine.start_receiving() + try: + fw = engine.get_value(InstructionAddress(4), CommonSubCommands.FW_VERSION) + finally: + engine.stop_receiving() +""" + +from __future__ import annotations + +import logging +import threading +import time +from collections import deque +from typing import Callable, Optional + +from pylabrobot.io import LOG_LEVEL_IO + +from ...transport import Transport +from .enums import ( + BROADCAST_WAIT_MS, + FRAME_HEADER_SIZE, + MAX_PACKETS_PER_MULTIPACKET, + NODE_BROADCAST, + CommandTypes, + CommonSubCommands, + GeminiSubCommands, + ReservedEvent, + TCPMessageType, + is_reserved_event, +) +from .errors import GeminiTimeoutError, MultipacketError, NAKError +from .framing import ( + FrameHeader, + MultipacketResponse, + pack_multipacket_frame, + pack_packet_frame, + pack_serial_frame, +) +from .instruction import pack_float32, unpack_float32 +from .packet import MASTER_ADDRESS, InstructionAddress, Packet + +logger = logging.getLogger(__name__) + + +# Short blocking read for the rx thread, so it can poll the stop flag +# frequently without busy-waiting. +_RX_POLL_S = 0.1 +# How long close() gives the rx thread to notice the stop flag and exit. +_RX_STOP_JOIN_S = 2.0 + + +PacketCallback = Callable[[Packet], None] +ReservedEventCallback = Callable[[ReservedEvent, Packet], None] + + +class GeminiEngine: + """Synchronous get/set/multipacket dispatcher with a background rx thread. + + Threading model: + + - ``_command_lock`` serializes every command-issuing method so only one + request is in flight at a time. + - ``_command_complete`` is a single event, reset before each send and set + by the rx thread when a response of matching shape arrives. + - Shared response state (``_value_buffer``, ``_nak_response``, etc.) is + written by the rx thread and read by the caller under the command lock. + - ``msg_id`` in the packet is not used for correlation: the command lock + already guarantees there is only ever one outstanding request. + """ + + def __init__(self, transport: Transport): + """Bind this engine to a transport. + + Args: + transport: The byte channel to the Darwin controller. Its connection + lifecycle belongs to the caller: this engine's :meth:`connect` and + :meth:`close` only start and stop the background receive thread, not + the transport's own connection. + """ + self._transport = transport + self._command_lock = threading.Lock() + self._command_complete = threading.Event() + + # Response state (written by rx thread, read under the command lock). + self._value_buffer: int = 0 + self._nak_response: int = 0 + self._multipacket_success: bool = False + self._multipacket_error_device: int = 0 + self._serial_response: Optional[bytes] = None + + self._rx_stop = threading.Event() + self._rx_thread: Optional[threading.Thread] = None + + # Self-routed broadcast trigger queue, so a broadcast this engine itself + # sends also reaches its own trigger callbacks. + self._local_queue: deque = deque() + self._local_queue_lock = threading.Lock() + + # Callbacks for unsolicited / stream packets. + self._on_trigger_callbacks: list[PacketCallback] = [] + self._on_stream_callbacks: list[PacketCallback] = [] + self._on_reserved_event_callbacks: list[ReservedEventCallback] = [] + + # --- Lifecycle ---------------------------------------------------------- + + @property + def is_connected(self) -> bool: + """Whether the transport is connected and the receive thread is running.""" + return ( + self._transport.is_connected and self._rx_thread is not None and self._rx_thread.is_alive() + ) + + def start_receiving(self) -> None: + """Start the background thread that receives and dispatches frames. + + Does not open the transport connection itself: the caller must already + have awaited ``transport.setup()`` before calling this. Named + ``start_receiving`` rather than ``connect`` because it starts nothing but + this engine's own receive thread -- the transport's connection is a + separate lifecycle the caller owns. + + Raises: + RuntimeError: If the transport is not yet connected. Starting the + receive thread against an unconnected transport would otherwise let + the thread's first ``receive_exact`` raise a ``RuntimeError`` that + neither ``except TimeoutError`` nor ``except OSError`` in + :meth:`_rx_loop` catches, so it would fall through to the thread's + outer handler and die silently instead of surfacing here. + """ + if self.is_connected: + return + if not self._transport.is_connected: + raise RuntimeError( + "Transport is not set up; await transport.setup() before engine.start_receiving()." + ) + self._rx_stop.clear() + self._rx_thread = threading.Thread(target=self._rx_loop, name="gemini-rx", daemon=True) + self._rx_thread.start() + + def stop_receiving(self) -> None: + """Stop the background receive thread. + + Does not close the transport connection; the caller owns that lifecycle + and may reuse the transport afterward. + """ + self._rx_stop.set() + if self._rx_thread is not None: + self._rx_thread.join(timeout=_RX_STOP_JOIN_S) + self._rx_thread = None + + def __enter__(self) -> GeminiEngine: + """Start the receive thread and return this engine.""" + self.start_receiving() + return self + + def __exit__(self, exc_type, exc, tb) -> None: + """Stop the receive thread.""" + self.stop_receiving() + + # --- Event subscription ------------------------------------------------- + + def on_trigger(self, cb: PacketCallback) -> None: + """Register a callback for incoming ``TRIGGER`` (subcmd=0) packets. + + These are how axes signal event numbers -- move start, move complete, or + a reserved event such as STOP or E-stop -- to the host. + + Args: + cb: Called with the trigger packet. + """ + self._on_trigger_callbacks.append(cb) + + def remove_trigger(self, cb: PacketCallback) -> None: + """Deregister a previously-registered trigger callback. + + Args: + cb: The callback to remove; ignored if not currently registered. + """ + try: + self._on_trigger_callbacks.remove(cb) + except ValueError: + pass + + def wait_for_trigger_event(self, event_value: int, timeout: float) -> bool: + """Block until a broadcast TRIGGER with the given event value arrives. + + Used by motion code to wait for the controller's move-complete echo of a + composite ``SEND_EVT``. + + Args: + event_value: The exact ``cmd_val`` to wait for. + timeout: Maximum time to wait, in seconds. + + Returns: + True if the event arrived before the timeout, False otherwise. + """ + event = threading.Event() + + def _on_evt(pkt: Packet) -> None: + """Set the wait event when a trigger packet carries the awaited value. + + Args: + pkt: The received trigger packet. + """ + if pkt.cmd_val == event_value: + event.set() + + self.on_trigger(_on_evt) + try: + return event.wait(timeout) + finally: + self.remove_trigger(_on_evt) + + def on_stream(self, cb: PacketCallback) -> None: + """Register a callback for STREAM-type packets (unsolicited datalog). + + Args: + cb: Called with the stream packet. + """ + self._on_stream_callbacks.append(cb) + + def on_reserved_event(self, cb: ReservedEventCallback) -> None: + """Register a callback for RESERVED InstructionEvents. + + Fires whenever a TRIGGER broadcast arrives whose value decodes as a + composite event with event number 127 (E-stop, light-curtain trip, + fault, and similar safety events). + + Args: + cb: Called with the decoded reserved event and the packet it arrived in. + """ + self._on_reserved_event_callbacks.append(cb) + + # --- Core GET / SET ----------------------------------------------------- + + def get_value( + self, + address: InstructionAddress, + sub_command: int, + timeout: float = 5.0, + ) -> int: + """Read a subcommand's raw uint32 value. + + Args: + address: The controller-tree node to query. + sub_command: The subcommand to read. + timeout: Maximum time to wait for the response, in seconds. + + Returns: + The value returned by the controller. + + Raises: + GeminiTimeoutError: If no response arrives within ``timeout``. + NAKError: If the controller rejected the request. + """ + with self._command_lock: + self._command_complete.clear() + self._value_buffer = 0 + self._nak_response = 0 + packet = Packet.get_request(dest=address, sub_command=sub_command) + self._transport.send(pack_packet_frame(packet)) + if not self._command_complete.wait(timeout): + raise GeminiTimeoutError( + f"Gemini GET timeout: {address} subcmd={sub_command}", + timeout=timeout, + ) + if self._nak_response != 0: + raise NAKError( + self._nak_response, + sub_command=sub_command, + dest_node=address.node_id, + dest_dev=address.dev_id, + ) + return self._value_buffer + + def get_float( + self, + address: InstructionAddress, + sub_command: int, + timeout: float = 5.0, + ) -> float: + """Read a subcommand's value, interpreted as an IEEE 754 float. + + Args: + address: The controller-tree node to query. + sub_command: The subcommand to read. + timeout: Maximum time to wait for the response, in seconds. + + Returns: + The decoded float value. + """ + raw = self.get_value(address, sub_command, timeout) + return unpack_float32(raw) + + def set_uint( + self, + address: InstructionAddress, + sub_command: int, + value: int, + timeout: float = 5.0, + ) -> None: + """Write a subcommand's raw uint32 value. + + A broadcast send (``address.node_id == NODE_BROADCAST``) does not wait + for a response: it sleeps :data:`~.enums.BROADCAST_WAIT_MS` milliseconds + instead, and if the subcommand is ``TRIGGER`` the packet is also + self-routed into the local receive queue so this engine's own trigger + callbacks still fire. + + Args: + address: The controller-tree node to write to. + sub_command: The subcommand to set. + value: The 32-bit value to write. + timeout: Maximum time to wait for the response, in seconds. Ignored + for a broadcast send. + + Raises: + GeminiTimeoutError: If no response arrives within ``timeout``. + NAKError: If the controller rejected the request. + """ + with self._command_lock: + packet = Packet.set_request(dest=address, sub_command=sub_command, value=value) + logger.debug( + "tx SET: dest=%d.%d sub=%d val=0x%08x", + address.node_id, + address.dev_id, + sub_command, + value, + ) + if address.node_id == NODE_BROADCAST: + self._transport.send(pack_packet_frame(packet)) + if sub_command == CommonSubCommands.TRIGGER: + with self._local_queue_lock: + self._local_queue.append(packet) + time.sleep(BROADCAST_WAIT_MS / 1000.0) + return + + self._command_complete.clear() + self._nak_response = 0 + self._transport.send(pack_packet_frame(packet)) + if not self._command_complete.wait(timeout): + raise GeminiTimeoutError( + f"Gemini SET timeout: {address} subcmd={sub_command}", + timeout=timeout, + ) + if self._nak_response != 0: + raise NAKError( + self._nak_response, + sub_command=sub_command, + dest_node=address.node_id, + dest_dev=address.dev_id, + ) + + def set_float( + self, + address: InstructionAddress, + sub_command: int, + value: float, + timeout: float = 5.0, + ) -> None: + """Write a subcommand's value, packed as an IEEE 754 float on the wire. + + Args: + address: The controller-tree node to write to. + sub_command: The subcommand to set. + value: The float value to write. + timeout: Maximum time to wait for the response, in seconds. + """ + self.set_uint(address, sub_command, pack_float32(value), timeout) + + # --- Multipacket -------------------------------------------------------- + + def send_multipacket( + self, + packets: list[Packet], + timeout: float = 10.0, + ) -> None: + """Send a batch of packets, chunked into multipackets of at most 64 each. + + Each chunk blocks until the controller returns a + :class:`~.framing.MultipacketResponse`. + + Args: + packets: The packets to send, in send order. + timeout: Maximum time to wait for each chunk's response, in seconds. + + Raises: + GeminiTimeoutError: If a chunk's response does not arrive within ``timeout``. + MultipacketError: If a chunk fails: one of its packets was NAK'd. + """ + if not packets: + return + with self._command_lock: + i = 0 + while i < len(packets): + chunk = packets[i : i + MAX_PACKETS_PER_MULTIPACKET] + if logger.isEnabledFor(LOG_LEVEL_IO): + for p in chunk: + logger.log( + LOG_LEVEL_IO, + "tx MP-pkt: dest=%d.%d sub=%d val=0x%08x", + p.dest.node_id, + p.dest.dev_id, + p.sub_command, + p.cmd_val, + ) + self._command_complete.clear() + self._multipacket_success = False + self._nak_response = 0 + self._multipacket_error_device = 0 + frame = pack_multipacket_frame(chunk) + if logger.isEnabledFor(LOG_LEVEL_IO): + logger.log(LOG_LEVEL_IO, "Gemini TX MP frame %d bytes: %s", len(frame), frame.hex()) + self._transport.send(frame) + if not self._command_complete.wait(timeout): + raise GeminiTimeoutError( + f"Gemini multipacket timeout after {len(chunk)} packets", + timeout=timeout, + ) + if not self._multipacket_success: + raise MultipacketError( + nak_code=self._nak_response, + error_device_addr=self._multipacket_error_device, + num_exchanges=len(chunk), + ) + i += len(chunk) + + # --- Serial device (plate sensor) --------------------------------------- + + def send_serial(self, payload: bytes, timeout: float = 1.0) -> bytes: + """Send a 9-byte serial-device payload and return the response bytes. + + Used for peripherals the controller forwards serial bytes to, such as + the plate-presence sensor. Retries within the timeout window until a + response whose first byte matches the request's first byte arrives. + + Args: + payload: Exactly 9 bytes to forward to the serial peripheral. + timeout: Total time to wait for a matching response, in seconds. + + Returns: + The response bytes. + + Raises: + ValueError: If ``payload`` is not exactly 9 bytes. + GeminiTimeoutError: If no matching response arrives within ``timeout``. + """ + if len(payload) != 9: + raise ValueError(f"serial payload must be 9 bytes, got {len(payload)}") + with self._command_lock: + self._command_complete.clear() + self._serial_response = None + self._transport.send(pack_serial_frame(payload)) + deadline = time.monotonic() + timeout + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise GeminiTimeoutError("Gemini serial-packet timeout", timeout=timeout) + if not self._command_complete.wait(remaining): + raise GeminiTimeoutError("Gemini serial-packet timeout", timeout=timeout) + resp = self._serial_response + # Require at least 8 bytes and a first-byte match. + if resp is not None and len(resp) >= 8 and resp[0] == payload[0]: + return resp + # Spurious response; reset and keep waiting for the real one. + self._command_complete.clear() + + # --- Receive loop ------------------------------------------------------- + + def _rx_loop(self) -> None: + """Continuously read frames off the transport and dispatch them. + + Runs on the background receive thread until :meth:`close` sets the stop + flag. Polls with a short read timeout so the stop flag is noticed + promptly rather than only between frames. + """ + logger.debug("gemini rx thread starting") + try: + while not self._rx_stop.is_set(): + # Drain any self-routed packets before reading from the transport. + self._drain_local_queue() + + try: + header_bytes = self._transport.receive_exact(FRAME_HEADER_SIZE, timeout=_RX_POLL_S) + except TimeoutError: + continue + except OSError: + if self._rx_stop.is_set(): + return + logger.warning("gemini rx: transport error, stopping") + return + + try: + header = FrameHeader.from_bytes(header_bytes) + except ValueError as exc: + logger.warning("gemini rx: malformed frame header: %s", exc) + continue + + if not header.is_valid_sync: + logger.warning( + "gemini rx: invalid msg_sync=0x%04x -- discarding", + header.msg_sync, + ) + continue + + payload = b"" + if header.payload_size > 0: + try: + payload = self._transport.receive_exact(header.payload_size, timeout=1.0) + except TimeoutError: + logger.warning( + "gemini rx: payload (%d bytes) timed out", + header.payload_size, + ) + continue + + if logger.isEnabledFor(LOG_LEVEL_IO): + logger.log( + LOG_LEVEL_IO, + "Gemini RX frame type=%d %d bytes: %s", + header.payload_type, + len(header_bytes) + len(payload), + (header_bytes + payload).hex(), + ) + self._dispatch_frame(header, payload) + except Exception: # pragma: no cover -- diagnostic + logger.exception("gemini rx thread crashed") + finally: + logger.debug("gemini rx thread exiting") + + def _drain_local_queue(self) -> None: + """Process every packet self-routed by a broadcast SET, if any.""" + while True: + with self._local_queue_lock: + if not self._local_queue: + return + pkt = self._local_queue.popleft() + try: + self._process_packet(pkt) + except Exception: # pragma: no cover -- diagnostic + logger.exception("error processing self-routed packet") + + def _dispatch_frame(self, header: FrameHeader, payload: bytes) -> None: + """Decode one received frame's payload and route it by type. + + Args: + header: The frame's decoded header. + payload: The frame's raw payload bytes. + """ + ptype = header.payload_type + if ptype == TCPMessageType.PACKET: + try: + pkt = Packet.from_bytes(payload) + except ValueError as exc: + logger.warning("gemini rx: malformed packet: %s", exc) + return + self._process_packet(pkt) + elif ptype == TCPMessageType.MULTIPACKET: + try: + resp = MultipacketResponse.from_bytes(payload) + except ValueError as exc: + logger.warning("gemini rx: malformed multipacket response: %s", exc) + return + self._process_multipacket_response(resp) + elif ptype == TCPMessageType.SERIAL_DATA: + self._process_serial_response(payload) + else: + logger.debug("gemini rx: unknown payload_type=%d", ptype) + + def _process_packet(self, packet: Packet) -> None: + """Update shared response state or fan a packet out to callbacks. + + Args: + packet: The received packet. + """ + logger.debug( + "rx pkt: src=%d.%d dest=%d.%d cmd=%d sub=%d val=0x%08x msgid=%d", + packet.src.node_id, + packet.src.dev_id, + packet.dest.node_id, + packet.dest.dev_id, + packet.cmd_type, + packet.sub_command, + packet.cmd_val, + packet.msg_id, + ) + cmd = packet.cmd_type + if cmd == CommandTypes.SETCMD_RESP: + self._nak_response = 0 + self._command_complete.set() + elif cmd == CommandTypes.GETCMD_RESP: + self._nak_response = 0 + self._value_buffer = packet.cmd_val + self._command_complete.set() + elif cmd == CommandTypes.SETCMD_ERR_RESP or cmd == CommandTypes.GETCMD_ERR_RESP: + self._nak_response = packet.cmd_val & 0xFF + self._command_complete.set() + elif cmd == CommandTypes.SETCMD and packet.sub_command == CommonSubCommands.TRIGGER: + # Incoming, or self-routed, trigger event. First check whether it is a + # RESERVED safety/fault event. + reserved = is_reserved_event(packet.cmd_val) + if reserved is not None: + logger.warning( + "Gemini RESERVED event from %d.%d: %s (val=0x%x)", + packet.src.node_id, + packet.src.dev_id, + reserved.name, + packet.cmd_val, + ) + # On ERROR/FAULT, also read SUBCMD_ERRCODE from the event's source so + # the log captures what actually failed. This must run on a separate + # thread: the rx loop cannot call get_value on itself, since that + # would deadlock waiting for a response it is itself responsible for + # dispatching. + if reserved.name in ("ERROR", "FAULT"): + src = packet.src + + def _fetch_errcode() -> None: + """Read and log SUBCMD_ERRCODE from the reserved event's source node. + + Runs on its own thread; see the comment above this closure for why. + """ + try: + code = self.get_value(src, GeminiSubCommands.ERRCODE, timeout=1.0) + category = (code >> 16) & 0xFFFF + specific = code & 0xFFFF + logger.warning( + " SUBCMD_ERRCODE from %d.%d = 0x%08x (category=%d specific=%d)", + src.node_id, + src.dev_id, + code, + category, + specific, + ) + except Exception as exc: + logger.debug(" (could not read SUBCMD_ERRCODE: %s)", exc) + + threading.Thread(target=_fetch_errcode, daemon=True).start() + for reserved_cb in self._on_reserved_event_callbacks: + try: + reserved_cb(reserved, packet) + except Exception: # pragma: no cover + logger.exception("reserved-event callback raised") + # Always also fire the generic trigger callbacks (move-complete echoes, etc.). + for trigger_cb in self._on_trigger_callbacks: + try: + trigger_cb(packet) + except Exception: # pragma: no cover -- a callback must not kill the rx thread + logger.exception("trigger callback raised") + elif cmd == CommandTypes.STREAM: + for stream_cb in self._on_stream_callbacks: + try: + stream_cb(packet) + except Exception: # pragma: no cover + logger.exception("stream callback raised") + # Other cmd_types (e.g. an inbound GETCMD) are ignored. + + def _process_multipacket_response(self, resp: MultipacketResponse) -> None: + """Update shared response state from a multipacket response. + + Args: + resp: The received multipacket response. + """ + self._multipacket_success = resp.is_success + if not resp.is_success: + self._nak_response = resp.device_error_nak + self._multipacket_error_device = resp.error_device_addr + else: + self._nak_response = 0 + self._command_complete.set() + + def _process_serial_response(self, payload: bytes) -> None: + """Update shared response state from a serial-peripheral response. + + Args: + payload: The received serial-peripheral payload. + """ + self._serial_response = payload + self._command_complete.set() + + # --- Master-node convenience helpers ------------------------------------ + + def master_get_uint(self, sub_command: int, timeout: float = 5.0) -> int: + """Read a subcommand's raw uint32 value from the master node. + + Args: + sub_command: The subcommand to read. + timeout: Maximum time to wait for the response, in seconds. + + Returns: + The value returned by the controller. + """ + return self.get_value(MASTER_ADDRESS, sub_command, timeout) + + def master_set_uint(self, sub_command: int, value: int, timeout: float = 5.0) -> None: + """Write a subcommand's raw uint32 value on the master node. + + Args: + sub_command: The subcommand to set. + value: The 32-bit value to write. + timeout: Maximum time to wait for the response, in seconds. + """ + self.set_uint(MASTER_ADDRESS, sub_command, value, timeout) + + def master_get_float(self, sub_command: int, timeout: float = 5.0) -> float: + """Read a subcommand's value from the master node, as an IEEE 754 float. + + Args: + sub_command: The subcommand to read. + timeout: Maximum time to wait for the response, in seconds. + + Returns: + The decoded float value. + """ + return unpack_float32(self.master_get_uint(sub_command, timeout)) + + def master_set_float(self, sub_command: int, value: float, timeout: float = 5.0) -> None: + """Write a subcommand's value on the master node, packed as a float. + + Args: + sub_command: The subcommand to set. + value: The float value to write. + timeout: Maximum time to wait for the response, in seconds. + """ + self.master_set_uint(sub_command, pack_float32(value), timeout) diff --git a/pylabrobot/agilent/bravo/protocol/gemini/engine_tests.py b/pylabrobot/agilent/bravo/protocol/gemini/engine_tests.py new file mode 100644 index 00000000000..1e38f43fb65 --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/gemini/engine_tests.py @@ -0,0 +1,243 @@ +import threading +import time +import unittest +from typing import Callable, Optional + +from pylabrobot.agilent.bravo.protocol.gemini.engine import _RX_POLL_S, GeminiEngine +from pylabrobot.agilent.bravo.protocol.gemini.enums import CommandTypes, CommonSubCommands +from pylabrobot.agilent.bravo.protocol.gemini.errors import GeminiTimeoutError, NAKError +from pylabrobot.agilent.bravo.protocol.gemini.framing import FrameHeader, pack_packet_frame +from pylabrobot.agilent.bravo.protocol.gemini.packet import InstructionAddress, Packet +from pylabrobot.agilent.bravo.transport import Transport + + +class LoopbackTransport(Transport): + """An in-memory stand-in for a Darwin controller's TCP connection. + + Every ``send()`` is handed to an optional ``responder`` callback, whose + return value (if any) is queued for the next ``receive``/``receive_exact`` + to read back -- the minimum needed to drive :class:`GeminiEngine`'s + request/response and background receive-thread logic without real sockets. + """ + + def __init__(self, responder: Optional[Callable[[bytes], Optional[bytes]]] = None): + self._cond = threading.Condition() + self._buffer = bytearray() + self.sent_frames: list = [] + self._connected = True + self.responder = responder + + def send(self, data: bytes) -> None: + self.sent_frames.append(data) + if self.responder is not None: + reply = self.responder(data) + if reply: + with self._cond: + self._buffer.extend(reply) + self._cond.notify_all() + + def receive(self, timeout: float = 2.0) -> bytes: + with self._cond: + if not self._buffer: + self._cond.wait(timeout) + data = bytes(self._buffer) + self._buffer.clear() + return data + + def receive_exact(self, num_bytes: int, timeout: float = 2.0) -> bytes: + deadline = time.monotonic() + timeout + with self._cond: + while len(self._buffer) < num_bytes: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"LoopbackTransport timed out waiting for {num_bytes} bytes") + self._cond.wait(remaining) + chunk = bytes(self._buffer[:num_bytes]) + del self._buffer[:num_bytes] + return chunk + + @property + def is_connected(self) -> bool: + return self._connected + + +def _get_resp_responder(value: int): + def _respond(frame: bytes) -> Optional[bytes]: + header = FrameHeader.from_bytes(frame[:8]) + packet = Packet.from_bytes(frame[8 : 8 + header.payload_size]) + if packet.cmd_type != CommandTypes.GETCMD: + return None + resp = Packet( + src=packet.dest, + dest=packet.src, + cmd_type=CommandTypes.GETCMD_RESP, + sub_command=packet.sub_command, + cmd_val=value, + ) + return pack_packet_frame(resp) + + return _respond + + +def _nak_responder(nak_code: int): + def _respond(frame: bytes) -> Optional[bytes]: + header = FrameHeader.from_bytes(frame[:8]) + packet = Packet.from_bytes(frame[8 : 8 + header.payload_size]) + resp = Packet( + src=packet.dest, + dest=packet.src, + cmd_type=CommandTypes.GETCMD_ERR_RESP, + sub_command=packet.sub_command, + cmd_val=nak_code, + ) + return pack_packet_frame(resp) + + return _respond + + +class GeminiEngineLifecycleTests(unittest.TestCase): + def test_start_receiving_starts_rx_thread_and_stop_receiving_stops_it(self): + engine = GeminiEngine(LoopbackTransport()) + self.assertFalse(engine.is_connected) + engine.start_receiving() + self.assertTrue(engine.is_connected) + engine.stop_receiving() + self.assertFalse(engine.is_connected) + + def test_start_receiving_requires_a_connected_transport(self): + transport = LoopbackTransport() + transport._connected = False + engine = GeminiEngine(transport) + with self.assertRaises(RuntimeError): + engine.start_receiving() + + def test_context_manager(self): + with GeminiEngine(LoopbackTransport()) as engine: + self.assertTrue(engine.is_connected) + self.assertFalse(engine.is_connected) + + def test_rx_poll_interval_is_in_seconds(self): + # The receive-thread poll interval is expressed in seconds, not milliseconds. + self.assertEqual(_RX_POLL_S, 0.1) + + +class GeminiEngineGetSetTests(unittest.TestCase): + def setUp(self): + self.address = InstructionAddress(4) + + def test_get_value_returns_response(self): + transport = LoopbackTransport(responder=_get_resp_responder(0x2A)) + with GeminiEngine(transport) as engine: + value = engine.get_value(self.address, CommonSubCommands.FW_VERSION, timeout=1.0) + self.assertEqual(value, 0x2A) + + def test_get_float_decodes_ieee754(self): + from pylabrobot.agilent.bravo.protocol.gemini.instruction import pack_float32 + + raw = pack_float32(3.5) + transport = LoopbackTransport(responder=_get_resp_responder(raw)) + with GeminiEngine(transport) as engine: + value = engine.get_float(self.address, CommonSubCommands.FW_VERSION, timeout=1.0) + self.assertAlmostEqual(value, 3.5, places=3) + + def test_get_value_raises_nak_error(self): + transport = LoopbackTransport(responder=_nak_responder(3)) # OUT_OF_RANGE + with GeminiEngine(transport) as engine: + with self.assertRaises(NAKError): + engine.get_value(self.address, CommonSubCommands.FW_VERSION, timeout=1.0) + + def test_get_value_timeout_carries_seconds_not_milliseconds(self): + transport = LoopbackTransport(responder=None) # never answers + with GeminiEngine(transport) as engine: + start = time.monotonic() + with self.assertRaises(GeminiTimeoutError) as ctx: + engine.get_value(self.address, CommonSubCommands.FW_VERSION, timeout=0.05) + elapsed = time.monotonic() - start + # The wait genuinely spans close to the 0.05s given: an upper bound alone + # does not rule out the wait finishing early (e.g. a timeout silently + # divided by 1000), so both bounds are asserted. + self.assertGreaterEqual(elapsed, 0.04) + self.assertLess(elapsed, 2.0) + self.assertEqual(ctx.exception.timeout, 0.05) + + def test_broadcast_set_does_not_wait_for_response(self): + transport = LoopbackTransport(responder=None) + broadcast = InstructionAddress(63) + with GeminiEngine(transport) as engine: + start = time.monotonic() + engine.set_uint(broadcast, CommonSubCommands.TRIGGER, value=1, timeout=5.0) + elapsed = time.monotonic() - start + # Broadcasts sleep BROADCAST_WAIT_MS (6ms), not the 5s request timeout. + self.assertLess(elapsed, 1.0) + + def test_set_uint_timeout_is_in_seconds(self): + transport = LoopbackTransport(responder=None) # never answers + with GeminiEngine(transport) as engine: + start = time.monotonic() + with self.assertRaises(GeminiTimeoutError) as ctx: + engine.set_uint(self.address, CommonSubCommands.TRIGGER, value=1, timeout=0.05) + elapsed = time.monotonic() - start + self.assertGreaterEqual(elapsed, 0.04) + self.assertLess(elapsed, 2.0) + self.assertEqual(ctx.exception.timeout, 0.05) + + def test_send_multipacket_timeout_is_in_seconds(self): + transport = LoopbackTransport(responder=None) # never answers + packets = [Packet.set_request(self.address, CommonSubCommands.TRIGGER, 1)] + with GeminiEngine(transport) as engine: + start = time.monotonic() + with self.assertRaises(GeminiTimeoutError) as ctx: + engine.send_multipacket(packets, timeout=0.05) + elapsed = time.monotonic() - start + self.assertGreaterEqual(elapsed, 0.04) + self.assertLess(elapsed, 2.0) + self.assertEqual(ctx.exception.timeout, 0.05) + + def test_send_serial_timeout_is_in_seconds(self): + transport = LoopbackTransport(responder=None) # never answers + with GeminiEngine(transport) as engine: + start = time.monotonic() + with self.assertRaises(GeminiTimeoutError) as ctx: + engine.send_serial(bytes(range(9)), timeout=0.05) + elapsed = time.monotonic() - start + self.assertGreaterEqual(elapsed, 0.04) + self.assertLess(elapsed, 2.0) + self.assertEqual(ctx.exception.timeout, 0.05) + + +class GeminiEngineTriggerCallbackTests(unittest.TestCase): + def test_broadcast_trigger_is_self_routed_to_callbacks(self): + transport = LoopbackTransport(responder=None) + received = [] + with GeminiEngine(transport) as engine: + engine.on_trigger(lambda pkt: received.append(pkt.cmd_val)) + engine.set_uint(InstructionAddress(63), CommonSubCommands.TRIGGER, value=0x99, timeout=1.0) + deadline = time.monotonic() + 2.0 + while not received and time.monotonic() < deadline: + time.sleep(0.01) + self.assertEqual(received, [0x99]) + + def test_wait_for_trigger_event_matches_value(self): + transport = LoopbackTransport(responder=None) + with GeminiEngine(transport) as engine: + + def _fire(): + time.sleep(0.05) + engine.set_uint(InstructionAddress(63), CommonSubCommands.TRIGGER, value=42, timeout=1.0) + + threading.Thread(target=_fire, daemon=True).start() + matched = engine.wait_for_trigger_event(42, timeout=2.0) + self.assertTrue(matched) + + def test_wait_for_trigger_event_times_out_in_seconds(self): + transport = LoopbackTransport(responder=None) + with GeminiEngine(transport) as engine: + start = time.monotonic() + matched = engine.wait_for_trigger_event(0xDEAD, timeout=0.1) + elapsed = time.monotonic() - start + self.assertFalse(matched) + self.assertLess(elapsed, 1.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/protocol/gemini/enums.py b/pylabrobot/agilent/bravo/protocol/gemini/enums.py new file mode 100644 index 00000000000..1a508b92f70 --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/gemini/enums.py @@ -0,0 +1,503 @@ +"""Wire-level constants and enums for the Gemini protocol. + +These values are the vocabulary of the Darwin-generation controller-tree +protocol: which subcommand ID reads which parameter, which bit pattern in a +frame header means what, and how a controller-tree node address is composed. +They come from the firmware's own command table and are not something this +module derives; every value here must match what a Darwin controller board +actually implements on the wire. +""" + +from __future__ import annotations + +from enum import IntEnum +from typing import Optional + +# --- Communication core --------------------------------------------- + + +class TCPMessageType(IntEnum): + """Outer TCP frame payload type (frame header bytes 4-5).""" + + PACKET = 1 + MULTIPACKET = 4 + SERIAL_DATA = 5 + + +class CommandTypes(IntEnum): + """``Packet.cmd_type`` -- the low 4 bits of packet byte 2.""" + + SETCMD = 1 + SETCMD_RESP = 2 + GETCMD = 3 + GETCMD_RESP = 4 + SETCMD_ERR_RESP = 5 + GETCMD_ERR_RESP = 6 + STREAM = 7 + + +class CommandNAKTypes(IntEnum): + """Error codes returned in ``*_ERR_RESP`` packets and multipacket responses.""" + + INVALID_SUBCMD = 1 + INVALID_DEVICE = 2 + OUT_OF_RANGE = 3 + READ_ONLY = 4 + WRITE_ONLY = 5 + INSTR_TBL_FULL = 6 + PLATE_DETECT_NOT_AVAILABLE = 7 + BRAKE_NOT_AVAILABLE = 8 + FLASH_PROTECTED = 9 + UNSUCCESSFUL_OPERATION = 10 + MOVE_IN_PROGRESS = 11 + + +class SubCommandDataType(IntEnum): + """How a subcommand's ``cmd_val`` word is interpreted.""" + + UINT32 = 0 + FLOAT32 = 1 + + +class CommonSubCommands(IntEnum): + """Subcommands valid on every controller-tree node (master and axis). 0-18.""" + + TRIGGER = 0 + DBG_VALUE = 1 + DBGLOG_SIZE = 2 + UPDATE_FW = 3 + FW_VERSION = 4 + BKUP_VERSION = 5 + PARAM_DB_RD_PTR = 6 + PARAM_DB_WR_PTR = 7 + PARAM_DB_VALUE = 8 + PARAM_DB_COUNT = 9 + PARAM_DB_APPLY = 10 + PARAM_DB_RESET = 11 + PARAM_DB_SAVE = 12 + PARAM_DB_LOAD = 13 + FILE_CRC = 14 + FILE_LENGTH = 15 + FILE_READY = 16 + FILE_CMD = 17 + REBOOT = 18 + + +class GeminiSubCommands(IntEnum): + """Subcommands valid on non-master nodes (axis controllers). 19-88.""" + + INSTR_CLEAR = 19 + INSTR_NEW_INSTR = 20 + INSTR_TBL_VAL = 21 + START_EVT = 22 + SEND_EVT = 23 + STATUS_START_STR = 24 + STATUS_STOP_STR = 25 + DLOG_START_STR = 26 + DLOG_STOP_STR = 27 + STREAM_STATUS = 28 + STREAM_DLOG = 29 + POSITION = 30 + CLOT_MARGIN = 31 + CLOT_DURATION = 32 + LLD_MARGIN = 33 + LLD_POS = 34 + ERRCODE = 35 + LEDSTATE = 36 + FLASH_WRITE_SEC = 37 + FLASH_WRITE_PTR = 38 + FLASH_READ_SEC = 39 + FLASH_READ_PTR = 40 + FLASH_VAL = 41 + HOMING_FLAG = 42 + FORCE_MOVE_MAX_POS_ERR = 43 + DLOG_LOG_VALUE = 44 + DLOG_ITEM = 45 + DLOG_BUF_SIZE = 46 + DLOG_TRIGGER = 47 + DLOG_PARAM_RESET = 48 + DLOG_PTS = 49 + DLOG_INTERVAL = 50 + DLOG_START_DELAY = 51 + DLOG_START_EVENT = 52 + DLOG_DURATION = 53 + HIDX_REC_DIST = 54 + BRAKE_CTRL = 55 + MOTOR_STATE = 56 + HOMING_FLAG_STATE = 57 + STEP_MODE = 58 + STEP_MIN = 59 + STEP_MAX = 60 + STEP_PRESCALER = 61 + STEP_CYCLE_COUNT = 62 + TRACE_CONFIG = 63 + TRACE_POINTS = 64 + TRACE_READ = 65 + CMOVE_TBL_REC = 66 + CMOVE_TBL_WORD = 67 + CMOVE_TBL_VAL = 68 + CURRENT_DRAW = 69 + HOLDING_CURRENT = 70 + FLASH_PROTECT = 71 + EXEVT_TRIG_TYPE = 72 + EXEVT_DISTANCE = 73 + EXEVT_DESTINATION = 74 + EXEVT_SENDEVT = 75 + PLATE_PRESENT = 76 + MIN_FORCE = 77 + CLOT_MOVEDONE_MARGIN = 78 + CLOT_MOVEDONE_WAIT = 79 + CLOT_MOVEDONE_DWELL = 80 + DUMP_HISTOGRAM_DATA = 81 + ZERO_HISTOGRAM_DATA = 82 + PHASE_ERR_COUNT = 83 + PWM_OUTPUT_MODE = 84 + + +class DarwinMasterNodeSubCommands(IntEnum): + """Master-node subcommands specific to the Darwin controller. 19-33.""" + + STATUS_LIGHTS = 19 + CHASSIS_LIGHTS = 20 + SAFETY_STATUS = 21 + MUTE_MODE = 22 + STUPID_HEAD_COUNTS = 23 + SMART_INIT = 24 + SMART_BAUD = 25 + SMART_DEV_ADDR = 26 + SMART_SOFT_RESET = 27 + SMART_RD_EEPROM = 28 + SMART_RD_EEPROM_VAL = 29 + SMART_SET_ADDR_BYTES = 30 + SMART_WR_EEPROM_VAL = 31 + SMART_WR_EEPROM = 32 + CLEAR_GO_BTN_LATCH = 33 + + +class InstructionTypes(IntEnum): + """Motion/logical instruction type -- encoded into instruction word0's low byte.""" + + MOVE_TO = 0 + MOVE_BY = 1 + CMOVE_TO = 2 + DELAY = 3 + TIPS_OFF = 4 + SOLENOID_ON = 5 + SOLENOID_OFF = 6 + + +class ExtraEventTriggerType(IntEnum): + """How an extra-event trigger fires relative to a move.""" + + TRIG_NONE = 0 + TRIG_ON_FLAG = 1 + + +class FirmwareUpdateType(IntEnum): + """Payload shape of a firmware-update file transfer.""" + + IMG = 1 + IMG_AND_VAR = 4 + + +class FileCmd(IntEnum): + """File-transfer operation selector for ``CommonSubCommands.FILE_CMD``.""" + + CAL_WR = 0 + CAL_RD = 1 + FW_WR = 2 + FW_RD = 3 + DATA_WR = 4 + DATA_RD = 5 + CAL_CLEAR = 6 + DATA_CLEAR = 7 + TIPS_WR = 8 + TIPS_RD = 9 + TIPS_CLEAR = 10 + + +# --- Axis ----------------------------------------------------------- + + +class MotorState(IntEnum): + """BLDC axis lifecycle states, as written to and read from ``MOTOR_STATE``.""" + + CALIBRATE = 0 + INITIAL = 1 + COMMUTATE = 2 + COMMUTATING = 3 + COMMUTATED = 4 + HOME = 5 + HOME_INTERNAL = 6 + FINDING_FLAG = 7 + STOP_ON_FLAG = 8 + MOVE_TO_FLAG = 9 + FLAG_FOUND = 10 + FINDING_INDEX = 11 + STOP_ON_INDEX = 12 + MOVE_TO_INDEX = 13 + INDEX_FOUND = 14 + HOME_SPARE_1 = 15 + HOME_SPARE_2 = 16 + HOMED = 17 + READY = 18 + BUSY = 19 + DISABLE = 20 + DISABLED = 21 + ENABLE = 22 + ETCH_ENABLE = 23 + ETCH_ENABLED = 24 + ETCH_DISABLE = 25 + FOLLOW = 26 + PUSH_WAIT = 27 + FOLLOWING = 28 + + +class AxisDirection(IntEnum): + """Direction bit of a Gemini instruction (word1 bit 16).""" + + NEGATIVE = 0 + POSITIVE = 1 + + +# --- Parameter database ------------------------------------------- + + +class ParamDBs(IntEnum): + """Parameter-database indices, 0-151. + + Addressed with ``CommonSubCommands.PARAM_DB_RD_PTR`` / + ``PARAM_DB_WR_PTR`` to read or write one tuning/calibration parameter + stored on a controller-tree node. + """ + + HW_TYPE = 0 + REVISION_NUM = 1 + MOTOR_TYPE = 2 + MOTOR_PRESENT = 3 + POLE_PAIRS = 4 + COUNTS_PER_ROTATION = 5 + POS_SCALE = 6 + MOTOR_TO_ENC_SIGN = 7 + MOTOR_DIR_SIGN = 8 + COUNTS_PER_INDEX = 9 + INFINITE_ROTATIONS = 10 + ENCODER_TYPE = 11 + BRAKE_PRESENT = 12 + BRAKE_DELAY = 13 + PLATE_DETECT_PRESENT = 14 + IA_ADC_CHANNEL = 15 + IB_ADC_CHANNEL = 16 + IA_PWM_CHANNEL = 17 + IB_PWM_CHANNEL = 18 + IC_PWM_CHANNEL = 19 + ISR_FREQ = 20 + PWM_FREQ = 21 + SPEED_LOOP_PS = 22 + POS_LOOP_PS = 23 + IQ_PTERM = 24 + IQ_ITERM = 25 + IQ_DTERM = 26 + IQ_CURR_OUT_SATURATION = 27 + ID_PTERM = 28 + ID_ITERM = 29 + ID_DTERM = 30 + ID_CURR_OUT_SATURATION = 31 + ID_SETPOINT = 32 + VEL_PTERM = 33 + VEL_ITERM = 34 + VEL_DTERM = 35 + VEL_CURR_OUT_SATURATION = 36 + VEL_FB_CUTOFF = 37 + SPEED_LOOP_BYPASS = 38 + SM_VEL_PTERM = 39 + SM_VEL_ITERM = 40 + SM_VEL_DTERM = 41 + STATIONARY_VEL_PTERM = 42 + STATIONARY_VEL_ITERM = 43 + STATIONARY_VEL_DTERM = 44 + SM_STATIONARY_VEL_PTERM = 45 + SM_STATIONARY_VEL_ITERM = 46 + SM_STATIONARY_VEL_DTERM = 47 + SPEED_SCALE = 48 + POS_PTERM = 49 + POS_ITERM = 50 + POS_DTERM = 51 + POS_CURR_OUT_SATURATION = 52 + POS_PID_MIN_ERR_THOLD = 53 + SM_POS_PTERM = 54 + SM_POS_ITERM = 55 + SM_POS_DTERM = 56 + STATIONARY_POS_PTERM = 57 + STATIONARY_POS_ITERM = 58 + STATIONARY_POS_DTERM = 59 + SM_STATIONARY_POS_PTERM = 60 + SM_STATIONARY_POS_ITERM = 61 + SM_STATIONARY_POS_DTERM = 62 + POS_D_ERR_CUTOFF = 63 + SPD_D_ERR_CUTOFF = 64 + SM_POS_D_ERR_CUTOFF = 65 + SM_SPD_D_ERR_CUTOFF = 66 + SPEED_FEED_FWD_GAIN = 67 + CURRENT_FEED_FWD_GAIN1 = 68 + CURRENT_FEED_FWD_GAIN2 = 69 + CURRENT_FEED_FWD_GAIN3 = 70 + SM_SPEED_FEED_FWD_GAIN = 71 + SM_CURRENT_FEED_FWD_GAIN1 = 72 + SM_CURRENT_FEED_FWD_GAIN2 = 73 + SM_CURRENT_FEED_FWD_GAIN3 = 74 + SPEED_FILTER_CENTER_FREQ = 75 + SPEED_FILTER_BANDWIDTH = 76 + DEAD_BAND_TYPE = 77 + STATIONARY_MAX_ERROR = 78 + SM_STATIONARY_MAX_ERROR = 79 + ALIGN_HS_CHECK_THRESH = 80 + ALIGN_PTERM = 81 + ALIGN_ITERM = 82 + ALIGN_DTERM = 83 + ALIGN_MAX_DISC_TIME = 84 + ALIGN_DISC_THRESHOLD = 85 + ALIGN_PID_ERR_THRESHOLD = 86 + ALIGN_REF_TIME = 87 + ALIGN_SETTLE_TIME = 88 + ALIGN_RAMP_CYCLES = 89 + ALIGN_PID_AVG_TOL = 90 + ALIGN_PID_AVG_COUNTS = 91 + ALIGN_RAMP_CURRENT_TARGET = 92 + ALIGN_FSPARE1 = 93 + ALIGN_FSPARE2 = 94 + ALIGN_FSPARE3 = 95 + ALIGN_USPARE4 = 96 + ALIGN_USPARE5 = 97 + MAX_HOLDING_CURRENT = 98 + HOMING_OVERSHOOT = 99 + HOMING_TYPE = 100 + HOMING_DIR = 101 + HOMING_INDEX_DIR = 102 + HOMING_INDEX_DIST = 103 + HOMING_INDEX_DIST_ERR_LIMIT = 104 + HOMING_SPEED = 105 + HOMING_ACCEL = 106 + HOMING_HS_CURRENT_LIMIT = 107 + HOMING_POS = 108 + HOMING_HARDSTOP_POS_ERR = 109 + HOMING_TIMEOUT = 110 + HOMING_INVERT_FLAG = 111 + ACCELERATION = 112 + JERK = 113 + SPEED = 114 + SM_ACCELERATION = 115 + SM_JERK = 116 + SM_SPEED = 117 + MOVE_DONE_MARGIN_TIME = 118 + POS_MARGIN = 119 + SM_POS_MARGIN = 120 + FORCE_MOVE_POS_SETTLE_TIME = 121 + FORCE_MOVE_POS_MARGIN = 122 + MAX_FORCE_CURRENT = 123 + POS_ERR_LIMIT = 124 + SM_THRESHOLD = 125 + I2T_TIME = 126 + I2T_CONT_CURRENT = 127 + I2T_PEAK_CURRENT = 128 + CURRENT_LOOP_OFFSET = 129 + PISTON_SELECT = 130 + LIN_ENCODER_AVG_PTS = 131 + SM_LIN_ENCODER_AVG_PTS = 132 + AUX_POS_SCALE = 133 + LIN_ENCODER_CUTOFF_FREQ = 134 + TIPS_OFF_COUNTS_MAX = 135 + TIPS_OFF_LOW_OUTPUT_COUNT = 136 + TIPS_OFF_DOWN_SPD_SLOW = 137 + FSPARE1 = 138 + FSPARE2 = 139 + FSPARE3 = 140 + FSPARE4 = 141 + FSPARE5 = 142 + USPARE6 = 143 + USPARE7 = 144 + USPARE8 = 145 + USPARE9 = 146 + USPARE10 = 147 + MIN_FW_FOR_PDB = 148 + + +# --- Protocol constants ------------------------------------------------------- + +MSG_SYNC = 0xAAAA +PROTOCOL_VERSION = 1 +FRAME_HEADER_SIZE = 8 +PACKET_SIZE = 8 +SERIAL_PACKET_SIZE = 9 +MAX_MULTIPACKET_SIZE = 512 +MAX_PACKETS_PER_MULTIPACKET = 64 +TCP_PORT = 7613 +TFTP_PORT = 69 + +# Well-known controller-tree addresses (InstructionAddress node_id) +NODE_BROADCAST = 63 +NODE_MASTER = 1 + +# Reserved event number used for instruction-triggering broadcasts. +EVENT_RESERVED = 127 + + +class ReservedEvent(IntEnum): + """Subcodes of ``EVENT_RESERVED`` (127), broadcast by the master node. + + Decoded from the high bits of a composite InstructionEvent value; see + :func:`decode_instruction_event`. + """ + + STOP = 1 + CONTINUE = 2 + ERROR = 3 + FAULT = 4 + ETEACH_PRESSED = 5 + ETEACH_RELEASED = 6 + SAFETY_NOTICE = 7 + STOP_DISABLE = 8 # light-curtain trip / E-stop press -> motors disabled + + +# How long a broadcast SET blocks before returning, in milliseconds, so the +# controller-tree bus has time to carry the broadcast to every node before the +# host issues its next command. +BROADCAST_WAIT_MS = 6 + + +def decode_instruction_event(evt: int) -> tuple[bool, int, int]: + """Decode an InstructionEvent word into its composite fields. + + Args: + evt: The raw event value from a packet's ``cmd_val``. + + Returns: + A ``(is_composite, event_no, mask)`` tuple: whether bit 7 (the composite + flag) is set, the low-7-bit event number, and the mask carried in the + high bits. + """ + composite = bool(evt & 0x80) + event_no = evt & 0x7F + mask = (evt >> 8) & 0xFFFFFF + return composite, event_no, mask + + +def is_reserved_event(evt: int) -> Optional[ReservedEvent]: + """Return the reserved-event subcode if ``evt`` is a composite RESERVED event. + + Args: + evt: The raw event value from a packet's ``cmd_val``. + + Returns: + The decoded :class:`ReservedEvent`, or ``None`` if ``evt`` is not a + composite event with event number :data:`EVENT_RESERVED`, or its mask + does not correspond to a known reserved subcode. + """ + composite, event_no, mask = decode_instruction_event(evt) + if not composite or event_no != EVENT_RESERVED: + return None + reserved = mask & 0xFFFF + try: + return ReservedEvent(reserved) + except ValueError: + return None diff --git a/pylabrobot/agilent/bravo/protocol/gemini/errors.py b/pylabrobot/agilent/bravo/protocol/gemini/errors.py new file mode 100644 index 00000000000..898bc7eab7e --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/gemini/errors.py @@ -0,0 +1,184 @@ +"""Gemini protocol-level errors. + +Three errors can arise purely from the wire exchange, independent of what a +command was trying to do: + +- :class:`GeminiTimeoutError` -- no response arrived within the per-request timeout. +- :class:`NAKError` -- the controller returned a ``*_ERR_RESP`` packet. +- :class:`MultipacketError` -- one packet in a multipacket batch was NAK'd. + +A NAK code is also mapped onto +:class:`~pylabrobot.agilent.bravo.errors.BravoError` +(:attr:`~pylabrobot.agilent.bravo.errors.ErrorType.DARWIN_GENERIC`) via +:func:`nak_to_bravo_error`, so callers that only handle the shared error type +still see these failures. +""" + +from __future__ import annotations + +from typing import Optional + +from ...errors import BravoError, ErrorType +from .enums import CommandNAKTypes + + +class GeminiProtocolError(Exception): + """Base class for protocol-level errors raised by the Gemini engine.""" + + +class GeminiTimeoutError(GeminiProtocolError): + """A request did not receive a matching response within its timeout. + + Attributes: + timeout: The timeout that elapsed, in seconds, if known. + """ + + def __init__(self, message: str, *, timeout: Optional[float] = None): + """Create a Gemini timeout error. + + Args: + message: A description of which request timed out. + timeout: The timeout that elapsed, in seconds. + """ + super().__init__(message) + self.timeout = timeout + + +class NAKError(GeminiProtocolError): + """The controller returned an error-response packet (``*_ERR_RESP``). + + The ``cmd_val`` of the error packet holds the :class:`~.enums.CommandNAKTypes` + code. + + Attributes: + nak_code: The raw NAK code byte. + nak: The decoded :class:`~.enums.CommandNAKTypes`, or ``None`` if + ``nak_code`` does not match a known member. + sub_command: The subcommand that was NAK'd, if known. + dest_node: The controller-tree node address that NAK'd, if known. + dest_dev: The device index within that node, if known. + """ + + def __init__( + self, + nak_code: int, + *, + sub_command: Optional[int] = None, + dest_node: Optional[int] = None, + dest_dev: Optional[int] = None, + ): + """Create a NAK error. + + Args: + nak_code: The raw NAK code byte from the error-response packet. + sub_command: The subcommand that was NAK'd, if known. + dest_node: The controller-tree node address that NAK'd, if known. + dest_dev: The device index within that node, if known. + """ + self.nak_code = nak_code + try: + self.nak: Optional[CommandNAKTypes] = CommandNAKTypes(nak_code) + nak_name = self.nak.name + except ValueError: + self.nak = None + nak_name = f"UNKNOWN_NAK_{nak_code}" + self.sub_command = sub_command + self.dest_node = dest_node + self.dest_dev = dest_dev + location = "" + if dest_node is not None: + location = f" at node {dest_node}" + if dest_dev: + location += f".{dest_dev}" + sub = f" subcmd={sub_command}" if sub_command is not None else "" + super().__init__(f"Gemini NAK {nak_name}{location}{sub}") + + +class MultipacketError(GeminiProtocolError): + """A multipacket batch was rejected: one of its packets was NAK'd. + + Attributes: + nak_code: The raw NAK code byte. + nak: The decoded :class:`~.enums.CommandNAKTypes`, or ``None`` if + ``nak_code`` does not match a known member. + error_device_addr: Address byte of the device that NAK'd. + num_exchanges: How many packets in the batch the controller accepted + before the NAK. + """ + + def __init__( + self, + nak_code: int, + error_device_addr: int, + num_exchanges: int, + ): + """Create a multipacket error. + + Args: + nak_code: The raw NAK code byte from the multipacket response. + error_device_addr: Address byte of the device that NAK'd. + num_exchanges: How many packets in the batch the controller accepted + before the NAK. + """ + self.nak_code = nak_code + try: + self.nak: Optional[CommandNAKTypes] = CommandNAKTypes(nak_code) + nak_name = self.nak.name + except ValueError: + self.nak = None + nak_name = f"UNKNOWN_NAK_{nak_code}" + self.error_device_addr = error_device_addr + self.num_exchanges = num_exchanges + super().__init__( + f"Gemini multipacket NAK {nak_name} at device 0x{error_device_addr:02X} " + f"(after {num_exchanges} exchanges)" + ) + + +# --- NAK -> BravoError bridge -------------------------------------------------- + +_NAK_TO_BRAVO: dict[int, ErrorType] = { + CommandNAKTypes.INVALID_SUBCMD: ErrorType.COULD_NOT_SEND_COMMAND, + CommandNAKTypes.INVALID_DEVICE: ErrorType.CONTROLLER_UNIDENTIFIED, + CommandNAKTypes.OUT_OF_RANGE: ErrorType.INVALID_DEST, + CommandNAKTypes.READ_ONLY: ErrorType.COULD_NOT_SEND_COMMAND, + CommandNAKTypes.WRITE_ONLY: ErrorType.COULD_NOT_SEND_COMMAND, + CommandNAKTypes.INSTR_TBL_FULL: ErrorType.CONTROLLER_QUEUE, + CommandNAKTypes.PLATE_DETECT_NOT_AVAILABLE: ErrorType.DARWIN_GENERIC, + CommandNAKTypes.BRAKE_NOT_AVAILABLE: ErrorType.CONTROLLER_BRAKE, + CommandNAKTypes.FLASH_PROTECTED: ErrorType.DARWIN_GENERIC, + CommandNAKTypes.UNSUCCESSFUL_OPERATION: ErrorType.DARWIN_GENERIC, + CommandNAKTypes.MOVE_IN_PROGRESS: ErrorType.MOVE_POSITION, +} + + +def nak_to_bravo_error( + nak_code: int, + *, + sub_command: Optional[int] = None, + extra: Optional[str] = None, +) -> BravoError: + """Translate a NAK code into a :class:`~pylabrobot.agilent.bravo.errors.BravoError`. + + The specific NAK name is preserved as the error's custom text, so a caller + that only handles the shared error hierarchy still sees which NAK fired. + + Args: + nak_code: The raw NAK code byte. + sub_command: The subcommand that was NAK'd, if known. + extra: Additional context to append to the error text. + + Returns: + The translated error. + """ + error_type = _NAK_TO_BRAVO.get(nak_code, ErrorType.DARWIN_GENERIC) + try: + name = CommandNAKTypes(nak_code).name + except ValueError: + name = f"UNKNOWN_NAK_{nak_code}" + bits = [f"Gemini NAK {name}"] + if sub_command is not None: + bits.append(f"subcmd={sub_command}") + if extra: + bits.append(extra) + return BravoError(error_type, custom_text=" ".join(bits)) diff --git a/pylabrobot/agilent/bravo/protocol/gemini/errors_tests.py b/pylabrobot/agilent/bravo/protocol/gemini/errors_tests.py new file mode 100644 index 00000000000..c0872a6997d --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/gemini/errors_tests.py @@ -0,0 +1,70 @@ +import unittest + +from pylabrobot.agilent.bravo.errors import BravoError, ErrorType +from pylabrobot.agilent.bravo.protocol.gemini.enums import CommandNAKTypes +from pylabrobot.agilent.bravo.protocol.gemini.errors import ( + GeminiTimeoutError, + MultipacketError, + NAKError, + nak_to_bravo_error, +) + + +class GeminiTimeoutErrorTests(unittest.TestCase): + def test_stores_timeout_in_seconds(self): + # This is the unit the caller passed in -- seconds, not milliseconds. + err = GeminiTimeoutError("Gemini GET timeout", timeout=5.0) + self.assertEqual(err.timeout, 5.0) + + def test_timeout_defaults_to_none(self): + err = GeminiTimeoutError("no timeout given") + self.assertIsNone(err.timeout) + + def test_is_a_gemini_protocol_error(self): + with self.assertRaises(GeminiTimeoutError): + raise GeminiTimeoutError("boom", timeout=1.0) + + +class NAKErrorTests(unittest.TestCase): + def test_known_nak_included_in_message(self): + err = NAKError(CommandNAKTypes.OUT_OF_RANGE, sub_command=30, dest_node=4, dest_dev=1) + self.assertIn("OUT_OF_RANGE", str(err)) + self.assertIn("4.1", str(err)) + self.assertIn("subcmd=30", str(err)) + self.assertEqual(err.nak, CommandNAKTypes.OUT_OF_RANGE) + + def test_unknown_nak_code(self): + err = NAKError(0x7F) + self.assertIsNone(err.nak) + self.assertIn("UNKNOWN_NAK_127", str(err)) + + +class MultipacketErrorTests(unittest.TestCase): + def test_message_includes_device_and_count(self): + err = MultipacketError( + nak_code=CommandNAKTypes.INSTR_TBL_FULL, error_device_addr=0x44, num_exchanges=12 + ) + self.assertIn("INSTR_TBL_FULL", str(err)) + self.assertIn("0x44", str(err)) + self.assertIn("12", str(err)) + + +class NakToBravoErrorTests(unittest.TestCase): + def test_known_code_maps_to_expected_error_type(self): + err = nak_to_bravo_error(CommandNAKTypes.OUT_OF_RANGE) + self.assertIsInstance(err, BravoError) + self.assertEqual(err.error_type, ErrorType.INVALID_DEST) + + def test_unknown_code_falls_back_to_darwin_generic(self): + err = nak_to_bravo_error(0x7F) + self.assertEqual(err.error_type, ErrorType.DARWIN_GENERIC) + + def test_custom_text_preserves_nak_name(self): + err = nak_to_bravo_error(CommandNAKTypes.MOVE_IN_PROGRESS, sub_command=30, extra="node 4.0") + self.assertIn("MOVE_IN_PROGRESS", str(err)) + self.assertIn("subcmd=30", str(err)) + self.assertIn("node 4.0", str(err)) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/protocol/gemini/framing.py b/pylabrobot/agilent/bravo/protocol/gemini/framing.py new file mode 100644 index 00000000000..af9a637421b --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/gemini/framing.py @@ -0,0 +1,282 @@ +"""Gemini outer TCP frame header and multipacket/serial payload wrappers. + +This wire format is not vendor protocol documentation. It was recovered by +observing traffic between Agilent VWorks and a Darwin-generation Bravo +controller, not from a published specification. The protocol has no +authentication or encryption: anyone with network access to the instrument's +TCP port can send it commands. + +Frame layout:: + + bytes 0-1 msg_sync 0xAAAA (little-endian) + bytes 2-3 protocol_version 0x0001 (little-endian) + bytes 4-5 payload_type little-endian uint16; see TCPMessageType + bytes 6-7 payload_size little-endian uint16; bytes of payload following + + bytes 8.. payload payload_size bytes, interpretation per type + +Payload types: + 1 PACKET exactly 8 bytes -- one :class:`~.packet.Packet` + 4 MULTIPACKET up to 512 bytes; outgoing: N x 8 concatenated packets, + incoming: :class:`MultipacketResponse` (8 bytes) + 5 SERIAL_DATA exactly 9 bytes -- a serial-peripheral payload +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass + +from .enums import ( + FRAME_HEADER_SIZE, + MAX_MULTIPACKET_SIZE, + MAX_PACKETS_PER_MULTIPACKET, + MSG_SYNC, + PACKET_SIZE, + PROTOCOL_VERSION, + TCPMessageType, +) +from .packet import Packet + +_HEADER_FMT = " bytes: + """Pack this header into its 8-byte little-endian wire encoding. + + Returns: + The packed header bytes. + """ + return struct.pack( + _HEADER_FMT, + self.msg_sync, + self.protocol_version, + self.payload_type, + self.payload_size, + ) + + @classmethod + def from_bytes(cls, data: bytes) -> FrameHeader: + """Parse a header from its 8-byte wire encoding. + + Args: + data: At least :data:`~.enums.FRAME_HEADER_SIZE` bytes, header first. + + Returns: + The decoded header. + + Raises: + ValueError: If fewer than :data:`~.enums.FRAME_HEADER_SIZE` bytes are given. + """ + if len(data) < FRAME_HEADER_SIZE: + raise ValueError(f"Frame header requires {FRAME_HEADER_SIZE} bytes, got {len(data)}") + sync, ver, ptype, psize = struct.unpack_from(_HEADER_FMT, data, 0) + return cls(msg_sync=sync, protocol_version=ver, payload_type=ptype, payload_size=psize) + + @property + def is_valid_sync(self) -> bool: + """Whether :attr:`msg_sync` matches the expected sync word.""" + return self.msg_sync == MSG_SYNC + + +# --- Multipacket batch (outgoing) -------------------------------------------- + + +def pack_multipacket_batch(packets: list[Packet]) -> bytes: + """Serialize a list of packets as a single outgoing multipacket payload. + + Args: + packets: The packets to concatenate, in send order. + + Returns: + The concatenated 8-byte packet encodings. + + Raises: + ValueError: If the batch exceeds the packet-count or byte-size wire limit. + """ + if len(packets) > MAX_PACKETS_PER_MULTIPACKET: + raise ValueError( + f"multipacket exceeds {MAX_PACKETS_PER_MULTIPACKET}-packet limit (got {len(packets)})" + ) + buf = bytearray() + for p in packets: + buf.extend(p.to_bytes()) + if len(buf) > MAX_MULTIPACKET_SIZE: + raise ValueError( + f"multipacket payload exceeds {MAX_MULTIPACKET_SIZE}-byte limit (got {len(buf)})" + ) + return bytes(buf) + + +def unpack_multipacket_batch(payload: bytes) -> list[Packet]: + """Parse an outgoing multipacket payload back into its packets. + + Args: + payload: A payload built by :func:`pack_multipacket_batch` (or received + unchanged from one). + + Returns: + The packets, in their original order. + + Raises: + ValueError: If ``payload`` is not a whole number of 8-byte packets. + """ + if len(payload) % PACKET_SIZE != 0: + raise ValueError( + f"multipacket payload length {len(payload)} is not a multiple of {PACKET_SIZE}" + ) + return [ + Packet.from_bytes(payload[i : i + PACKET_SIZE]) for i in range(0, len(payload), PACKET_SIZE) + ] + + +# --- Multipacket response (incoming) ----------------------------------------- + +_MP_RESPONSE_FMT = " bool: + """Whether every packet in the batch was accepted.""" + return self.error_code == 0 + + def to_bytes(self) -> bytes: + """Pack this response into its 8-byte wire encoding. + + Returns: + The packed response bytes. + """ + return struct.pack( + _MP_RESPONSE_FMT, + self.num_exchanges, + self.error_code, + self.error_device_addr, + self.device_error_nak, + self.padding, + ) + + @classmethod + def from_bytes(cls, data: bytes) -> MultipacketResponse: + """Parse a response from its 8-byte wire encoding. + + Args: + data: At least 8 bytes, response first. + + Returns: + The decoded response. + + Raises: + ValueError: If fewer than 8 bytes are given. + """ + if len(data) < _MP_RESPONSE_SIZE: + raise ValueError(f"MultipacketResponse requires {_MP_RESPONSE_SIZE} bytes, got {len(data)}") + num, err, addr, nak, pad = struct.unpack_from(_MP_RESPONSE_FMT, data, 0) + return cls( + num_exchanges=num, + error_code=err, + error_device_addr=addr, + device_error_nak=nak, + padding=pad, + ) + + +# --- Frame pack helpers ------------------------------------------------------ + + +def pack_packet_frame(packet: Packet) -> bytes: + """Wrap a single packet in a ``TCPMessageType.PACKET`` frame. + + Args: + packet: The packet to send. + + Returns: + The full frame: header followed by the packet's 8 bytes. + """ + payload = packet.to_bytes() + header = FrameHeader( + msg_sync=MSG_SYNC, + protocol_version=PROTOCOL_VERSION, + payload_type=TCPMessageType.PACKET, + payload_size=len(payload), + ) + return header.to_bytes() + payload + + +def pack_multipacket_frame(packets: list[Packet]) -> bytes: + """Wrap a packet batch in a ``TCPMessageType.MULTIPACKET`` frame. + + Args: + packets: The packets to send, in send order. + + Returns: + The full frame: header followed by the concatenated packets. + """ + payload = pack_multipacket_batch(packets) + header = FrameHeader( + msg_sync=MSG_SYNC, + protocol_version=PROTOCOL_VERSION, + payload_type=TCPMessageType.MULTIPACKET, + payload_size=len(payload), + ) + return header.to_bytes() + payload + + +def pack_serial_frame(payload: bytes) -> bytes: + """Wrap a 9-byte serial-peripheral payload in a ``TCPMessageType.SERIAL_DATA`` frame. + + Args: + payload: Exactly 9 bytes of serial-device data. + + Returns: + The full frame: header followed by the 9-byte payload. + + Raises: + ValueError: If ``payload`` is not exactly 9 bytes. + """ + if len(payload) != 9: + raise ValueError(f"serial payload must be 9 bytes, got {len(payload)}") + header = FrameHeader( + msg_sync=MSG_SYNC, + protocol_version=PROTOCOL_VERSION, + payload_type=TCPMessageType.SERIAL_DATA, + payload_size=len(payload), + ) + return header.to_bytes() + payload diff --git a/pylabrobot/agilent/bravo/protocol/gemini/framing_tests.py b/pylabrobot/agilent/bravo/protocol/gemini/framing_tests.py new file mode 100644 index 00000000000..c0525c5a4bb --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/gemini/framing_tests.py @@ -0,0 +1,140 @@ +import struct +import unittest + +from pylabrobot.agilent.bravo.protocol.gemini.enums import ( + MAX_MULTIPACKET_SIZE, + MAX_PACKETS_PER_MULTIPACKET, + MSG_SYNC, + PROTOCOL_VERSION, + CommandTypes, + CommonSubCommands, + TCPMessageType, +) +from pylabrobot.agilent.bravo.protocol.gemini.framing import ( + FrameHeader, + MultipacketResponse, + pack_multipacket_batch, + pack_multipacket_frame, + pack_packet_frame, + pack_serial_frame, + unpack_multipacket_batch, +) +from pylabrobot.agilent.bravo.protocol.gemini.packet import HOST_ADDRESS, InstructionAddress, Packet + + +class FrameHeaderTests(unittest.TestCase): + def test_packs_to_eight_little_endian_bytes(self): + h = FrameHeader( + msg_sync=MSG_SYNC, + protocol_version=PROTOCOL_VERSION, + payload_type=TCPMessageType.PACKET, + payload_size=8, + ) + self.assertEqual(h.to_bytes(), bytes.fromhex("aaaa010001000800")) + + def test_roundtrip(self): + h = FrameHeader(MSG_SYNC, PROTOCOL_VERSION, TCPMessageType.MULTIPACKET, 56) + recovered = FrameHeader.from_bytes(h.to_bytes()) + self.assertEqual(recovered, h) + self.assertTrue(recovered.is_valid_sync) + + def test_rejects_truncated_header(self): + with self.assertRaises(ValueError): + FrameHeader.from_bytes(b"\x00" * 7) + + def test_rejects_bad_sync_word(self): + bad = struct.pack("/ field-width swap between them + # produces identical bytes and the test would not catch it. + r = MultipacketResponse( + num_exchanges=3, error_code=1, error_device_addr=4, device_error_nak=11, padding=9 + ) + expected = bytes.fromhex("03000100040b0900") + self.assertEqual(r.to_bytes(), expected) + self.assertFalse(r.is_success) + + def test_from_bytes_rejects_truncated_payload(self): + with self.assertRaises(ValueError): + MultipacketResponse.from_bytes(b"\x00" * 7) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/protocol/gemini/instruction.py b/pylabrobot/agilent/bravo/protocol/gemini/instruction.py new file mode 100644 index 00000000000..b20052c4c72 --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/gemini/instruction.py @@ -0,0 +1,420 @@ +"""Gemini 4-word instruction codec. + +An instruction is the encoding a Darwin axis controller executes for motion, +a timed delay, or a tip-related action. It is loaded onto a controller-tree +node via a multipacket batch: one ``INSTR_NEW_INSTR`` write followed by four +``INSTR_TBL_VAL`` writes (one per word), plus ``START_EVT``/``SEND_EVT`` +writes to bind the instruction to the trigger events that start and +report it. + +Word layout:: + + Word 0: + bits 0-7 instr_type (InstructionTypes) + bits 8-23 velocity_scaled uint16, velocity_pct/100.0 * 65535 + (if is_low_velocity, value = velocity_pct*1000/100 * 65535) + bits 24-31 acceleration_scaled uint8, accel_pct/100.0 * 255 (min 1 if accel>0) + + Word 1: + bits 0-7 jerk_scaled uint8, jerk_pct/100.0 * 255 + bits 8-15 force_scaled uint8, force_pct/100.0 * 255 + bit 16 direction 1=Positive, 0=Negative + bit 17 reset_pos_on_start + bit 18 reset_pos_after_stop + bit 19 error_on_dest_reach + bit 20 lld + bit 21 stop_on_touch + bit 22 check_for_clots + bit 24 is_low_velocity (velocity_pct < 0.1 encoding flag) + + Word 2 (to_value): raw uint32 -- interpretation depends on instr_type. + MOVE_TO/MOVE_BY: IEEE 754 float (normalized target position or volume) + CMOVE_TO: low u16 = pt_data_id, high u16 = pt_data_count + DELAY: delay in milliseconds + + Word 3 (trig_at_value): raw uint32 -- typically a trigger-point float, or + for plunger instructions: low u16 = plunger_speed, bits 16-23 = + plunger_accel, bits 24-31 = plunger_jerk. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass, field +from typing import Union + +from .enums import AxisDirection, InstructionTypes + +_FLOAT32 = struct.Struct(" int: + """Pack an IEEE 754 single-precision float into a uint32 for wire encoding. + + Args: + value: The float to encode. + + Returns: + The float's bit pattern as an unsigned 32-bit integer. + """ + (word,) = _UINT32.unpack(_FLOAT32.pack(value)) + return int(word) + + +def unpack_float32(word: int) -> float: + """Unpack a wire uint32 into an IEEE 754 single-precision float. + + Args: + word: The 32-bit value as sent or received on the wire. + + Returns: + The decoded float. + """ + (value,) = _FLOAT32.unpack(_UINT32.pack(word & 0xFFFFFFFF)) + return float(value) + + +@dataclass +class Instruction: + """A four-word motion/logic instruction, independent of wire framing. + + Motion percentages are 0-100 (percent of the axis's configured maximum). + Velocities below 0.1% engage the "low velocity" encoding: the percentage is + stored pre-multiplied by 1000, with a flag bit set so the controller knows + to divide it back out. + + Attributes: + instr_type: What kind of instruction this is. Decoding preserves a wire + value the firmware sent that does not match a known + :class:`~.enums.InstructionTypes` member as a plain ``int`` rather than + raising, so an unrecognized instruction can still round-trip. + velocity_percent: Move velocity, 0-100% of axis max. + acceleration_percent: Move acceleration, 0-100% of axis max. + jerk_percent: Move jerk, 0-100% of axis max. The firmware rejects an + instruction whose encoded jerk byte is 0, so values <=0 or >100 are + clamped to 100 rather than encoded as 0. + force_percent: Force limit, 0-100%; 0 is a valid value meaning no force + control, unlike jerk. + direction: Move direction. + reset_pos_on_start: Whether to zero the position counter when the move starts. + reset_pos_after_stop: Whether to zero the position counter when the move stops. + error_on_dest_reach: Whether reaching the destination should raise a fault. + lld: Whether liquid-level detection is active during this move. + stop_on_touch: Whether to stop the move on a touch/force event. + check_for_clots: Whether to monitor for a clot during this move. + to_value: Word 2, raw; see the module docstring for its per-``instr_type`` + interpretation, or use :attr:`volume`/:attr:`delay_ms`. + trig_at_value: Word 3, raw; see :attr:`trig_at_float`/:attr:`plunger_speed`. + """ + + instr_type: Union[InstructionTypes, int] = InstructionTypes.MOVE_TO + velocity_percent: float = 100.0 + acceleration_percent: float = 100.0 + jerk_percent: float = 100.0 + force_percent: float = 0.0 + direction: AxisDirection = AxisDirection.POSITIVE + reset_pos_on_start: bool = False + reset_pos_after_stop: bool = False + error_on_dest_reach: bool = False + lld: bool = False + stop_on_touch: bool = False + check_for_clots: bool = False + to_value: int = 0 + trig_at_value: int = 0 + + # Preserves the exact scaled byte values a decoded instruction was built + # from, so encode(decode(x)) reproduces x's bytes exactly even where a + # percentage would otherwise round to a slightly different scaled byte. + # Empty on an instruction built directly rather than decoded. + _scaled: dict = field(default_factory=dict, repr=False, compare=False) + + # --- Word 2 / word 3 typed accessors ----------------------------------- + + @property + def volume(self) -> float: + """Word 2 as a float32 volume or position, for MOVE_TO/MOVE_BY.""" + return unpack_float32(self.to_value) + + @volume.setter + def volume(self, v: float) -> None: + """Set word 2 from a float32 volume or position. + + Args: + v: The value to encode. + """ + self.to_value = pack_float32(v) + + @property + def delay_ms(self) -> int: + """Word 2 as a delay in milliseconds, for DELAY.""" + return self.to_value & 0xFFFFFFFF + + @delay_ms.setter + def delay_ms(self, ms: int) -> None: + """Set word 2 from a delay in milliseconds. + + Args: + ms: The delay to encode. + """ + self.to_value = ms & 0xFFFFFFFF + + @property + def cmove_pt_data_id(self) -> int: + """Word 2 low 16 bits: the CMOVE point-table data ID.""" + return self.to_value & 0xFFFF + + @property + def cmove_pt_data_count(self) -> int: + """Word 2 high 16 bits: the CMOVE point-table point count.""" + return (self.to_value >> 16) & 0xFFFF + + def set_cmove_pt_data(self, data_id: int, data_count: int) -> None: + """Set word 2 from a CMOVE point-table ID and point count. + + Args: + data_id: The point-table data ID. + data_count: The number of points in the table. + """ + self.to_value = ((data_count & 0xFFFF) << 16) | (data_id & 0xFFFF) + + @property + def trig_at_float(self) -> float: + """Word 3 as a float32 trigger position.""" + return unpack_float32(self.trig_at_value) + + @trig_at_float.setter + def trig_at_float(self, v: float) -> None: + """Set word 3 from a float32 trigger position. + + Args: + v: The value to encode. + """ + self.trig_at_value = pack_float32(v) + + @property + def plunger_speed(self) -> int: + """Word 3 low 16 bits: plunger speed, for plunger instructions.""" + return self.trig_at_value & 0xFFFF + + @property + def plunger_acceleration(self) -> int: + """Word 3 bits 16-23: plunger acceleration, for plunger instructions.""" + return (self.trig_at_value >> 16) & 0xFF + + @property + def plunger_jerk(self) -> int: + """Word 3 bits 24-31: plunger jerk, for plunger instructions.""" + return (self.trig_at_value >> 24) & 0xFF + + def set_plunger(self, speed: int, accel: int, jerk: int) -> None: + """Set word 3 from plunger speed, acceleration, and jerk. + + Args: + speed: Plunger speed, packed into the low 16 bits. + accel: Plunger acceleration, packed into bits 16-23. + jerk: Plunger jerk, packed into bits 24-31. + """ + self.trig_at_value = ((jerk & 0xFF) << 24) | ((accel & 0xFF) << 16) | (speed & 0xFFFF) + + # --- 4-word codec ------------------------------------------------------- + + def to_words(self) -> tuple[int, int, int, int]: + """Encode this instruction into its four wire words. + + Prefers the scaled byte values preserved by :meth:`from_words`, when + present, over recomputing them from the percentage fields, so that + decoding and re-encoding an instruction reproduces its original bytes + exactly even where a percentage rounds imperfectly. + + Returns: + The ``(word0, word1, word2, word3)`` tuple to load via + ``INSTR_TBL_VAL``. + """ + if self._scaled: + vel_scaled = self._scaled["velocity_scaled"] + accel_scaled = self._scaled["accel_scaled"] + jerk_scaled = self._scaled["jerk_scaled"] + force_scaled = self._scaled["force_scaled"] + low_vel = self._scaled["low_velocity"] + else: + vel_scaled, low_vel = _scale_velocity(self.velocity_percent) + accel_scaled = _scale_accel(self.acceleration_percent) + jerk_scaled = _scale_jerk_percent(self.jerk_percent) + force_scaled = _scale_force_percent(self.force_percent) + + word0 = ( + (int(self.instr_type) & 0xFF) | ((vel_scaled & 0xFFFF) << 8) | ((accel_scaled & 0xFF) << 24) + ) + word1 = (jerk_scaled & 0xFF) | ((force_scaled & 0xFF) << 8) + if self.direction == AxisDirection.POSITIVE: + word1 |= _BIT_DIRECTION + if self.reset_pos_on_start: + word1 |= _BIT_RESET_POS_ON_START + if self.reset_pos_after_stop: + word1 |= _BIT_RESET_POS_AFTER_STOP + if self.error_on_dest_reach: + word1 |= _BIT_ERROR_ON_DEST_REACH + if self.lld: + word1 |= _BIT_LLD + if self.stop_on_touch: + word1 |= _BIT_STOP_ON_TOUCH + if self.check_for_clots: + word1 |= _BIT_CHECK_FOR_CLOTS + if low_vel: + word1 |= _BIT_LOW_VELOCITY + return ( + word0 & 0xFFFFFFFF, + word1 & 0xFFFFFFFF, + self.to_value & 0xFFFFFFFF, + self.trig_at_value & 0xFFFFFFFF, + ) + + @classmethod + def from_words(cls, w0: int, w1: int, w2: int, w3: int) -> Instruction: + """Decode an instruction from its four wire words. + + Args: + w0: Word 0, as read via ``INSTR_TBL_VAL``. + w1: Word 1. + w2: Word 2. + w3: Word 3. + + Returns: + The decoded instruction. Its exact scaled byte values are preserved + internally so that :meth:`to_words` reproduces ``w0``/``w1`` exactly. + """ + instr_type_value = w0 & 0xFF + vel_scaled = (w0 >> 8) & 0xFFFF + accel_scaled = (w0 >> 24) & 0xFF + jerk_scaled = w1 & 0xFF + force_scaled = (w1 >> 8) & 0xFF + is_low_vel = bool(w1 & _BIT_LOW_VELOCITY) + + vel_pct = vel_scaled * 100.0 / 65535.0 + if is_low_vel: + vel_pct *= 0.001 + accel_pct = accel_scaled * 100.0 / 255.0 + jerk_pct = jerk_scaled * 100.0 / 255.0 + force_pct = force_scaled * 100.0 / 255.0 + + instr_type: Union[InstructionTypes, int] + if instr_type_value in InstructionTypes._value2member_map_: + instr_type = InstructionTypes(instr_type_value) + else: + instr_type = instr_type_value + + inst = cls( + instr_type=instr_type, + velocity_percent=vel_pct, + acceleration_percent=accel_pct, + jerk_percent=jerk_pct, + force_percent=force_pct, + direction=AxisDirection.POSITIVE if w1 & _BIT_DIRECTION else AxisDirection.NEGATIVE, + reset_pos_on_start=bool(w1 & _BIT_RESET_POS_ON_START), + reset_pos_after_stop=bool(w1 & _BIT_RESET_POS_AFTER_STOP), + error_on_dest_reach=bool(w1 & _BIT_ERROR_ON_DEST_REACH), + lld=bool(w1 & _BIT_LLD), + stop_on_touch=bool(w1 & _BIT_STOP_ON_TOUCH), + check_for_clots=bool(w1 & _BIT_CHECK_FOR_CLOTS), + to_value=w2 & 0xFFFFFFFF, + trig_at_value=w3 & 0xFFFFFFFF, + ) + inst._scaled = { + "velocity_scaled": vel_scaled, + "accel_scaled": accel_scaled, + "jerk_scaled": jerk_scaled, + "force_scaled": force_scaled, + "low_velocity": is_low_vel, + } + return inst + + +def _scale_velocity(velocity_percent: float) -> tuple[int, bool]: + """Scale a velocity percentage into word0's uint16 field. + + Args: + velocity_percent: Velocity, 0-100% of axis max. Values outside + ``(0, 100]`` are treated as 100%. + + Returns: + The scaled uint16 value and whether the low-velocity flag must be set. + """ + v = velocity_percent + if v <= 0.0 or v > 100.0: + v = 100.0 + if v < 0.1: + scaled_base = v * 1000.0 + low_vel = True + else: + scaled_base = v + low_vel = False + scaled = int(scaled_base / 100.0 * 65535.0) & 0xFFFF + return scaled, low_vel + + +def _scale_accel(accel_percent: float) -> int: + """Scale an acceleration percentage into word0's uint8 field. + + Args: + accel_percent: Acceleration, 0-100% of axis max. Values outside + ``(0, 100]`` are treated as 100%. + + Returns: + The scaled uint8 value, floored at 1 whenever the input is positive. + """ + a = accel_percent + if a <= 0.0 or a > 100.0: + a = 100.0 + scaled = int(a / 100.0 * 255.0) + if scaled == 0 and a > 0.0: + scaled = 1 + return scaled & 0xFF + + +def _scale_jerk_percent(percent: float) -> int: + """Scale a jerk percentage into word1's low uint8 field. + + Values <=0 or >100 are treated as 100%, unlike :func:`_scale_force_percent` + where 0 is a valid, meaningful value. The firmware rejects an instruction + whose encoded jerk byte is 0 as out of range, so this clamp is load-bearing. + + Args: + percent: Jerk, 0-100% of axis max. + + Returns: + The scaled uint8 value. + """ + p = percent + if p <= 0.0 or p > 100.0: + p = 100.0 + return int(p / 100.0 * 255.0) & 0xFF + + +def _scale_force_percent(percent: float) -> int: + """Scale a force percentage into word1's second uint8 field. + + Unlike jerk, 0 is valid here and means no force control. + + Args: + percent: Force limit, clamped to 0-100%. + + Returns: + The scaled uint8 value. + """ + p = percent + if p < 0.0: + p = 0.0 + elif p > 100.0: + p = 100.0 + return int(p / 100.0 * 255.0) & 0xFF diff --git a/pylabrobot/agilent/bravo/protocol/gemini/instruction_tests.py b/pylabrobot/agilent/bravo/protocol/gemini/instruction_tests.py new file mode 100644 index 00000000000..ac684ed20e3 --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/gemini/instruction_tests.py @@ -0,0 +1,121 @@ +import unittest + +from pylabrobot.agilent.bravo.protocol.gemini.enums import AxisDirection, InstructionTypes +from pylabrobot.agilent.bravo.protocol.gemini.instruction import ( + Instruction, + pack_float32, + unpack_float32, +) + + +class Float32CodecTests(unittest.TestCase): + def test_roundtrip(self): + for value in (0.0, 1.0, -1.0, 3.14159, -273.15, 1e10, -1e-10): + self.assertAlmostEqual(unpack_float32(pack_float32(value)), value, places=4) + + def test_known_value(self): + # 1.0 as IEEE-754 single precision, little-endian. + self.assertEqual(pack_float32(1.0), 0x3F800000) + self.assertAlmostEqual(unpack_float32(0x3F800000), 1.0) + + +class InstructionWordRoundtripTests(unittest.TestCase): + def test_basic_move_roundtrips(self): + inst = Instruction( + instr_type=InstructionTypes.MOVE_TO, + velocity_percent=75.0, + acceleration_percent=50.0, + jerk_percent=100.0, + force_percent=0.0, + direction=AxisDirection.POSITIVE, + ) + inst.volume = 42.5 + words = inst.to_words() + self.assertEqual(len(words), 4) + decoded = Instruction.from_words(*words) + self.assertEqual(decoded.instr_type, InstructionTypes.MOVE_TO) + self.assertAlmostEqual(decoded.velocity_percent, 75.0, places=2) + self.assertAlmostEqual(decoded.acceleration_percent, 50.0, delta=1.0) + self.assertEqual(decoded.direction, AxisDirection.POSITIVE) + self.assertAlmostEqual(decoded.volume, 42.5, places=3) + + def test_decode_then_encode_is_byte_exact(self): + # Round-tripping through from_words/to_words must reproduce the exact + # input words, including where a percentage would otherwise re-quantize + # to a different scaled byte. + words = (0x0155AA03, 0x000180A1, 0x42280000, 0x00000000) + decoded = Instruction.from_words(*words) + self.assertEqual(decoded.to_words(), words) + + def test_low_velocity_flag_set_below_point_one_percent(self): + inst = Instruction(velocity_percent=0.05) + word0, word1, _, _ = inst.to_words() + self.assertTrue(word1 & (1 << 24)) + decoded = Instruction.from_words(word0, word1, 0, 0) + self.assertAlmostEqual(decoded.velocity_percent, 0.05, delta=0.01) + + def test_jerk_zero_clamps_to_full_scale(self): + # The firmware rejects a jerk byte of 0 as out of range, so jerk<=0 must + # clamp to 100%, not encode as 0. + inst = Instruction(jerk_percent=0.0) + _, word1, _, _ = inst.to_words() + self.assertEqual(word1 & 0xFF, 255) + + def test_force_zero_stays_zero(self): + # Unlike jerk, force=0 is meaningful (no force control) and must not clamp. + inst = Instruction(force_percent=0.0) + _, word1, _, _ = inst.to_words() + self.assertEqual((word1 >> 8) & 0xFF, 0) + + def test_flag_bits_roundtrip(self): + inst = Instruction( + reset_pos_on_start=True, + reset_pos_after_stop=True, + error_on_dest_reach=True, + lld=True, + stop_on_touch=True, + check_for_clots=True, + ) + decoded = Instruction.from_words(*inst.to_words()) + self.assertTrue(decoded.reset_pos_on_start) + self.assertTrue(decoded.reset_pos_after_stop) + self.assertTrue(decoded.error_on_dest_reach) + self.assertTrue(decoded.lld) + self.assertTrue(decoded.stop_on_touch) + self.assertTrue(decoded.check_for_clots) + + def test_unrecognized_instr_type_roundtrips_as_int(self): + words = (0xFF, 0, 0, 0) + decoded = Instruction.from_words(*words) + self.assertEqual(decoded.instr_type, 0xFF) + self.assertEqual(decoded.to_words(), words) + + +class InstructionWord2Word3AccessorTests(unittest.TestCase): + def test_delay_ms(self): + inst = Instruction() + inst.delay_ms = 1500 + self.assertEqual(inst.delay_ms, 1500) + self.assertEqual(inst.to_value, 1500) + + def test_cmove_pt_data(self): + inst = Instruction() + inst.set_cmove_pt_data(data_id=7, data_count=200) + self.assertEqual(inst.cmove_pt_data_id, 7) + self.assertEqual(inst.cmove_pt_data_count, 200) + + def test_plunger_fields(self): + inst = Instruction() + inst.set_plunger(speed=1000, accel=200, jerk=50) + self.assertEqual(inst.plunger_speed, 1000) + self.assertEqual(inst.plunger_acceleration, 200) + self.assertEqual(inst.plunger_jerk, 50) + + def test_trig_at_float(self): + inst = Instruction() + inst.trig_at_float = -12.5 + self.assertAlmostEqual(inst.trig_at_float, -12.5, places=3) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/protocol/gemini/packet.py b/pylabrobot/agilent/bravo/protocol/gemini/packet.py new file mode 100644 index 00000000000..f8c00325780 --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/gemini/packet.py @@ -0,0 +1,233 @@ +"""Gemini 8-byte packet codec and controller-tree addressing. + +A packet is the unit of work the Gemini protocol exchanges with one node of +the Darwin controller tree: a GET or SET of a single subcommand value, or the +response to one. Packet layout:: + + byte 0 src_addr (dev_id << 6) | node_id + byte 1 dest_addr (dev_id << 6) | node_id + byte 2 (msg_id << 4) | cmd_type -- msg_id is 2 bits, cmd_type is 4 bits + byte 3 sub_command + bytes 4-7 cmd_val (big-endian uint32) +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass + +from .enums import PACKET_SIZE, CommandTypes + +# --- InstructionAddress ------------------------------------------------------- + + +@dataclass(frozen=True) +class InstructionAddress: + """A controller-tree address: 6-bit node ID plus 2-bit device ID, in one byte. + + Encoding: ``byte = (dev_id << 6) | (node_id & 0x3F)``. + + Attributes: + node_id: The node's address on the controller tree, 0-63. + dev_id: The device index within that node, 0-3. + """ + + node_id: int + dev_id: int = 0 + + def __post_init__(self) -> None: + """Validate that both fields fit their wire-encoded bit widths. + + Raises: + ValueError: If ``node_id`` or ``dev_id`` is out of its valid range. + """ + if not 0 <= self.node_id <= 0x3F: + raise ValueError(f"node_id {self.node_id} out of range 0..63") + if not 0 <= self.dev_id <= 0x03: + raise ValueError(f"dev_id {self.dev_id} out of range 0..3") + + @property + def byte(self) -> int: + """The single-byte wire encoding of this address.""" + return ((self.dev_id & 0x03) << 6) | (self.node_id & 0x3F) + + @classmethod + def from_byte(cls, b: int) -> InstructionAddress: + """Decode an address from its single-byte wire encoding. + + Args: + b: The encoded address byte. + + Returns: + The decoded address. + """ + return cls(node_id=b & 0x3F, dev_id=(b >> 6) & 0x03) + + def __str__(self) -> str: + """Return ``"node.device"``, e.g. ``"4.1"``.""" + return f"{self.node_id}.{self.dev_id}" + + +HOST_ADDRESS = InstructionAddress(0, 0) +MASTER_ADDRESS = InstructionAddress(1, 0) +BROADCAST_ADDRESS = InstructionAddress(63, 0) + + +# --- Packet ------------------------------------------------------------------- + + +@dataclass +class Packet: + """One 8-byte Gemini packet. + + Attributes: + src: Sending controller-tree address. + dest: Destination controller-tree address. + cmd_type: One of :class:`~.enums.CommandTypes`. + sub_command: The subcommand this packet addresses. + cmd_val: The 32-bit command value: a value to write for SET, the read + result for a GET response, or a NAK code for an error response. + msg_id: A 2-bit rotating counter (0-3) that correlates a SETCMD/GETCMD + with its response; encoded into the high nibble of byte 2. + """ + + src: InstructionAddress + dest: InstructionAddress + cmd_type: int + sub_command: int + cmd_val: int = 0 + msg_id: int = 0 + + def __post_init__(self) -> None: + """Validate that every field fits its wire-encoded bit width. + + Raises: + ValueError: If any field is out of its valid range. + """ + if not 0 <= self.cmd_type <= 0x0F: + raise ValueError(f"cmd_type {self.cmd_type} out of range 0..15") + if not 0 <= self.msg_id <= 0x03: + raise ValueError(f"msg_id {self.msg_id} out of range 0..3") + if not 0 <= self.sub_command <= 0xFF: + raise ValueError(f"sub_command {self.sub_command} out of range 0..255") + if not 0 <= self.cmd_val <= 0xFFFFFFFF: + raise ValueError(f"cmd_val {self.cmd_val} out of range 0..2^32-1") + + def to_bytes(self) -> bytes: + """Pack this packet into its 8-byte wire encoding. + + Returns: + The packed packet bytes. + """ + b2 = ((self.msg_id & 0x03) << 4) | (self.cmd_type & 0x0F) + return struct.pack( + ">BBBBI", + self.src.byte, + self.dest.byte, + b2, + self.sub_command & 0xFF, + self.cmd_val & 0xFFFFFFFF, + ) + + @classmethod + def from_bytes(cls, data: bytes) -> Packet: + """Parse a packet from its 8-byte wire encoding. + + Reserved bits 6-7 of byte 2 are dropped: they are not part of + ``cmd_type`` or ``msg_id`` and are not preserved on re-encoding. + + Args: + data: Exactly :data:`~.enums.PACKET_SIZE` bytes. + + Returns: + The decoded packet. + + Raises: + ValueError: If ``data`` is not exactly :data:`~.enums.PACKET_SIZE` bytes. + """ + if len(data) != PACKET_SIZE: + raise ValueError(f"Packet requires exactly {PACKET_SIZE} bytes, got {len(data)}") + src, dest, b2, sub, val = struct.unpack(">BBBBI", data) + return cls( + src=InstructionAddress.from_byte(src), + dest=InstructionAddress.from_byte(dest), + cmd_type=b2 & 0x0F, + sub_command=sub, + cmd_val=val, + msg_id=(b2 >> 4) & 0x03, + ) + + # Convenience constructors ------------------------------------------------- + + @classmethod + def get_request( + cls, + dest: InstructionAddress, + sub_command: int, + msg_id: int = 0, + src: InstructionAddress = HOST_ADDRESS, + ) -> Packet: + """Build a GETCMD packet requesting a subcommand's current value. + + Args: + dest: The controller-tree node to query. + sub_command: The subcommand to read. + msg_id: The rotating correlation counter. + src: The sending address; defaults to the host. + + Returns: + The GET packet. + """ + return cls( + src=src, + dest=dest, + cmd_type=CommandTypes.GETCMD, + sub_command=sub_command, + msg_id=msg_id, + ) + + @classmethod + def set_request( + cls, + dest: InstructionAddress, + sub_command: int, + value: int, + msg_id: int = 0, + src: InstructionAddress = HOST_ADDRESS, + ) -> Packet: + """Build a SETCMD packet writing a subcommand's value. + + Args: + dest: The controller-tree node to write to. + sub_command: The subcommand to set. + value: The 32-bit value to write. + msg_id: The rotating correlation counter. + src: The sending address; defaults to the host. + + Returns: + The SET packet. + """ + return cls( + src=src, + dest=dest, + cmd_type=CommandTypes.SETCMD, + sub_command=sub_command, + cmd_val=value & 0xFFFFFFFF, + msg_id=msg_id, + ) + + def is_response(self) -> bool: + """Return whether this packet is any kind of SET/GET response.""" + return self.cmd_type in ( + CommandTypes.SETCMD_RESP, + CommandTypes.GETCMD_RESP, + CommandTypes.SETCMD_ERR_RESP, + CommandTypes.GETCMD_ERR_RESP, + ) + + def is_error(self) -> bool: + """Return whether this packet is a ``*_ERR_RESP`` (NAK) response.""" + return self.cmd_type in ( + CommandTypes.SETCMD_ERR_RESP, + CommandTypes.GETCMD_ERR_RESP, + ) diff --git a/pylabrobot/agilent/bravo/protocol/gemini/packet_tests.py b/pylabrobot/agilent/bravo/protocol/gemini/packet_tests.py new file mode 100644 index 00000000000..3bf63af8ed0 --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/gemini/packet_tests.py @@ -0,0 +1,130 @@ +import unittest + +from pylabrobot.agilent.bravo.protocol.gemini.enums import CommandTypes, CommonSubCommands +from pylabrobot.agilent.bravo.protocol.gemini.packet import ( + BROADCAST_ADDRESS, + HOST_ADDRESS, + MASTER_ADDRESS, + InstructionAddress, + Packet, +) + + +class InstructionAddressTests(unittest.TestCase): + def test_byte_encoding(self): + cases = [ + (0, 0, 0x00), # host + (1, 0, 0x01), # master + (63, 0, 0x3F), # broadcast + (4, 0, 0x04), + (4, 1, 0x44), + (5, 1, 0x45), + (6, 0, 0x06), + (6, 1, 0x46), + ] + for node_id, dev_id, expected_byte in cases: + addr = InstructionAddress(node_id=node_id, dev_id=dev_id) + self.assertEqual(addr.byte, expected_byte) + roundtrip = InstructionAddress.from_byte(expected_byte) + self.assertEqual(roundtrip.node_id, node_id) + self.assertEqual(roundtrip.dev_id, dev_id) + + def test_well_known_addresses(self): + self.assertEqual(HOST_ADDRESS.byte, 0x00) + self.assertEqual(MASTER_ADDRESS.byte, 0x01) + self.assertEqual(BROADCAST_ADDRESS.byte, 0x3F) + + def test_node_id_out_of_range_raises(self): + for node_id in (-1, 64, 100): + with self.assertRaises(ValueError): + InstructionAddress(node_id=node_id, dev_id=0) + + def test_dev_id_out_of_range_raises(self): + for dev_id in (-1, 4, 10): + with self.assertRaises(ValueError): + InstructionAddress(node_id=0, dev_id=dev_id) + + +class PacketConstructionTests(unittest.TestCase): + def test_get_request_encoding(self): + p = Packet.get_request( + dest=InstructionAddress(node_id=4, dev_id=0), + sub_command=CommonSubCommands.FW_VERSION, + msg_id=0, + ) + self.assertEqual(p.to_bytes(), bytes.fromhex("0004030400000000")) + + def test_msg_id_encoded_in_high_nibble(self): + p = Packet( + src=HOST_ADDRESS, + dest=InstructionAddress(4), + cmd_type=CommandTypes.GETCMD, + sub_command=4, + msg_id=2, + ) + # byte 2 = (msg_id=2 << 4) | (cmd_type=3) = 0x23 + self.assertEqual(p.to_bytes()[2], 0x23) + + def test_roundtrip_simple(self): + original = Packet( + src=InstructionAddress(4, 1), + dest=HOST_ADDRESS, + cmd_type=CommandTypes.GETCMD_RESP, + sub_command=30, + cmd_val=0x12345678, + msg_id=1, + ) + recovered = Packet.from_bytes(original.to_bytes()) + self.assertEqual(recovered, original) + + def test_cmd_val_is_big_endian(self): + p = Packet( + src=HOST_ADDRESS, + dest=InstructionAddress(1), + cmd_type=CommandTypes.SETCMD, + sub_command=0, + cmd_val=0x01020304, + ) + b = p.to_bytes() + self.assertEqual(b[4:8], b"\x01\x02\x03\x04") + + def test_response_predicates(self): + resp = Packet(HOST_ADDRESS, HOST_ADDRESS, CommandTypes.GETCMD_RESP, 4, 57) + self.assertTrue(resp.is_response()) + self.assertFalse(resp.is_error()) + + err = Packet(HOST_ADDRESS, HOST_ADDRESS, CommandTypes.SETCMD_ERR_RESP, 4, 3) + self.assertTrue(err.is_response()) + self.assertTrue(err.is_error()) + + req = Packet(HOST_ADDRESS, HOST_ADDRESS, CommandTypes.SETCMD, 4, 0) + self.assertFalse(req.is_response()) + + def test_rejects_wrong_length(self): + with self.assertRaises(ValueError): + Packet.from_bytes(b"\x00" * 7) + with self.assertRaises(ValueError): + Packet.from_bytes(b"\x00" * 9) + + def test_reserved_bits_are_not_preserved(self): + # Bits 6-7 of byte 2 are reserved and are dropped on decode: a packet + # carrying them cannot re-serialize to its input bytes. + with_reserved = bytes([0x01, 0x02, 0b1100_0011, 0x04, 0, 0, 0, 5]) + decoded = Packet.from_bytes(with_reserved) + self.assertEqual(decoded.to_bytes(), bytes([0x01, 0x02, 0b0000_0011, 0x04, 0, 0, 0, 5])) + self.assertEqual(decoded.cmd_type, 0x03) + self.assertEqual(decoded.msg_id, 0) + + def test_out_of_range_fields_raise(self): + with self.assertRaises(ValueError): + Packet(HOST_ADDRESS, HOST_ADDRESS, cmd_type=0x10, sub_command=0) + with self.assertRaises(ValueError): + Packet(HOST_ADDRESS, HOST_ADDRESS, cmd_type=0, sub_command=0, msg_id=4) + with self.assertRaises(ValueError): + Packet(HOST_ADDRESS, HOST_ADDRESS, cmd_type=0, sub_command=0x100) + with self.assertRaises(ValueError): + Packet(HOST_ADDRESS, HOST_ADDRESS, cmd_type=0, sub_command=0, cmd_val=1 << 32) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/protocol/v11_agile_7612_comm.py b/pylabrobot/agilent/bravo/protocol/v11_agile_7612_comm.py new file mode 100644 index 00000000000..dd380677b5e --- /dev/null +++ b/pylabrobot/agilent/bravo/protocol/v11_agile_7612_comm.py @@ -0,0 +1,118 @@ +"""V11 command framing for the Agile 7612 Bravo generation -- swapped frame order. + +Standard V11 (:class:`~.v11_comm.V11DeviceComm`) sends +``[length_u16_LE][cmd][data]`` and receives ``[length_u16_LE][error][data]``. +The Agile 7612 generation instead puts the command byte before the length in +both directions: send ``[cmd][length_u16_LE][data]``, receive +``[cmd][length_u16_LE][error][data]``. Sending a standard-order frame to an +Agile 7612 controller, or vice versa, produces a frame the firmware silently +ignores rather than one it rejects with an error. +""" + +from __future__ import annotations + +import logging +import struct + +from pylabrobot.io import LOG_LEVEL_IO + +from ..errors import BravoError, ErrorType, RabbitErrorCode, rabbit_error_to_bravo_error +from ..transport import Transport +from .commands import CommandID +from .v11_comm import V11DeviceComm + +logger = logging.getLogger(__name__) + +_LENGTH_HEADER_FMT = " bytes: + """Send and receive one Agile-7612-ordered V11 frame, without retrying. + + Args: + command_id: The command to send. + data: The command's payload bytes, if any. + timeout: Maximum time to wait for the response, in seconds. + + Returns: + The response payload, with the leading error-code byte removed. + + Raises: + ConnectionError: If the transport is not connected. + TimeoutError: If the response does not arrive within ``timeout``. + BravoError: If the response is empty, or the controller reports an error. + """ + if not self._transport.is_connected: + raise ConnectionError("Transport is not connected") + + payload_length = len(data) + frame = struct.pack(" bytes: + payload = bytes([error_code]) + data + return struct.pack(" Transport: + """The underlying transport, so a caller can reach it directly (e.g. to drain it).""" + return self._transport + + @property + def is_connected(self) -> bool: + """Whether the underlying transport is currently connected.""" + return self._transport.is_connected + + def send_command( + self, + command_id: CommandID, + data: bytes = b"", + timeout: float = DEFAULT_COMMAND_TIMEOUT, + ) -> bytes: + """Send a command and return its response data, with the error byte stripped. + + Args: + command_id: The command to send. + data: The command's payload bytes, if any. + timeout: Maximum time to wait for each attempt's response, in seconds. + + Returns: + The response payload, with the leading error-code byte removed. + + Raises: + BravoError: If every attempt fails, or if the controller reports a + hardware/protocol error. + """ + last_error: Optional[Exception] = None + + for attempt in range(1, MAX_COMMAND_RETRIES + 1): + try: + return self._send_once(command_id, data, timeout) + except TimeoutError as exc: + last_error = exc + logger.warning( + "Command 0x%02X attempt %d/%d timed out: %s", + command_id, + attempt, + MAX_COMMAND_RETRIES, + exc, + ) + except ConnectionError as exc: + last_error = exc + logger.warning( + "Command 0x%02X attempt %d/%d connection error: %s", + command_id, + attempt, + MAX_COMMAND_RETRIES, + exc, + ) + + raise BravoError( + ErrorType.NO_RESPONSE, + custom_text=( + f"Command 0x{command_id:02X} failed after {MAX_COMMAND_RETRIES} retries: {last_error}" + ), + ) + + def _send_once( + self, + command_id: CommandID, + data: bytes, + timeout: float, + ) -> bytes: + """Send and receive one V11 frame, without retrying. + + Args: + command_id: The command to send. + data: The command's payload bytes, if any. + timeout: Maximum time to wait for the response, in seconds. + + Returns: + The response payload, with the leading error-code byte removed. + + Raises: + ConnectionError: If the transport is not connected. + TimeoutError: If the response does not arrive within ``timeout``. + BravoError: If the response is empty, or the controller reports an error. + """ + if not self._transport.is_connected: + raise ConnectionError("Transport is not connected") + + # --- Build the V11 frame: [length (2 bytes LE)][command_id][data] --- + inner_payload = struct.pack(" None: + self.sent.append(data) + + def receive(self, timeout: float = 2.0) -> bytes: + return b"" + + def receive_exact(self, num_bytes: int, timeout: float = 2.0) -> bytes: + self.receive_exact_calls.append((num_bytes, timeout)) + if len(self._buffer) < num_bytes: + raise TimeoutError( + f"BufferedTransport: only {len(self._buffer)} of {num_bytes} bytes available" + ) + chunk = bytes(self._buffer[:num_bytes]) + del self._buffer[:num_bytes] + return chunk + + @property + def is_connected(self) -> bool: + return self._connected + + +def _v11_response_frame(error_code: int, data: bytes = b"") -> bytes: + payload = bytes([error_code]) + data + return struct.pack(" Date: Fri, 21 Aug 2026 10:59:35 -0700 Subject: [PATCH 4/9] Add Bravo controllers for the Agile generations BravoController is the interface every generation implements, taking an already-connected transport and exposing initialize for the synchronous post-connect handshake. AgileSrtController extends Agile7612Controller extends AgileController, matching how each generation varies on the one before. Per-axis settings come from typed AxisConfig values rather than runtime attribute probing. The SRT is gripperless and reports so through has_gripper, rejecting gripper operations by model name. Golden-frame tests pin the full command sequence for every homing routine, move, jog, and grip, so a change in packet content, field order, or phase ordering fails immediately. --- .../agilent/bravo/controllers/__init__.py | 5 + pylabrobot/agilent/bravo/controllers/agile.py | 1008 +++++ .../agilent/bravo/controllers/agile_7612.py | 2054 +++++++++ .../controllers/agile_golden_frame_tests.py | 339 ++ .../agilent/bravo/controllers/agile_srt.py | 488 +++ .../agilent/bravo/controllers/agile_tests.py | 468 +++ pylabrobot/agilent/bravo/controllers/base.py | 399 ++ .../agilent/bravo/controllers/base_tests.py | 330 ++ .../agilent/bravo/controllers/simulation.py | 370 ++ .../bravo/controllers/simulation_tests.py | 220 + .../testdata/agile_golden_frames.json | 3726 +++++++++++++++++ 11 files changed, 9407 insertions(+) create mode 100644 pylabrobot/agilent/bravo/controllers/__init__.py create mode 100644 pylabrobot/agilent/bravo/controllers/agile.py create mode 100644 pylabrobot/agilent/bravo/controllers/agile_7612.py create mode 100644 pylabrobot/agilent/bravo/controllers/agile_golden_frame_tests.py create mode 100644 pylabrobot/agilent/bravo/controllers/agile_srt.py create mode 100644 pylabrobot/agilent/bravo/controllers/agile_tests.py create mode 100644 pylabrobot/agilent/bravo/controllers/base.py create mode 100644 pylabrobot/agilent/bravo/controllers/base_tests.py create mode 100644 pylabrobot/agilent/bravo/controllers/simulation.py create mode 100644 pylabrobot/agilent/bravo/controllers/simulation_tests.py create mode 100644 pylabrobot/agilent/bravo/controllers/testdata/agile_golden_frames.json diff --git a/pylabrobot/agilent/bravo/controllers/__init__.py b/pylabrobot/agilent/bravo/controllers/__init__.py new file mode 100644 index 00000000000..a96b93149a3 --- /dev/null +++ b/pylabrobot/agilent/bravo/controllers/__init__.py @@ -0,0 +1,5 @@ +"""Bravo controller implementations. + +A controller drives one generation of Bravo hardware (or a software +simulation of it) through the :class:`~.base.BravoController` interface. +""" diff --git a/pylabrobot/agilent/bravo/controllers/agile.py b/pylabrobot/agilent/bravo/controllers/agile.py new file mode 100644 index 00000000000..4823dd87ce1 --- /dev/null +++ b/pylabrobot/agilent/bravo/controllers/agile.py @@ -0,0 +1,1008 @@ +"""Agile controller for legacy (non-7612) Bravo hardware. + +The Rabbit microcontroller sits between the host and the Agile motor +controllers. It accepts V11-framed commands over the transport and relays +10-byte Agile packets to one or two Agile motor controllers over an internal +bus: + +- Controller 1 (Agile bus ID 0): X, Y, Z, W axes. +- Controller 2 (Agile bus ID 1): G, Zg axes (gripper module). + +Homing, jogging, and coordinated moves are built from the same primitive: +``CMD_PREPARE_MOVE`` (or ``CMD_PREPARE_JOG``) loads a target into the +Rabbit, and an Agile ``MoveGo`` packet (sent via +``CMD_DIRECT_AGILE_COMMAND``) triggers it. A move is not observed complete +until ``GetGroupAStatus`` reports the target axis's trajectory-active bit +clear. +""" + +from __future__ import annotations + +import logging +import struct +import time +from typing import Any, Optional, Protocol, Type + +from ..errors import BravoError, ErrorType +from ..protocol import agile_packet +from ..protocol.agile_packet import ( + AGILE_PACKET_SIZE, + UNIQUE_VALUE_EXPECTED, + AgileRegister, +) +from ..protocol.commands import ( + AgileJogInfo, + AgileMoveInfo, + CommandID, + EEPROMAddress, + GripperParams, + LightCommandData, + SmartHeadEEPROMData, +) +from ..protocol.v11_comm import V11DeviceComm +from ..transport import Transport +from ..types import ( + ALL_AXES, + DEFAULT_W_TICKS_PER_UL, + GRIP_POSITION_TOLERANCE, + OPEN_GRIPPER_POSITION, + TICKS_PER_MM, + Axis, + DeviceStateFlag, + GripperDetectionState, + SpeedLevel, + axis_code, + axis_label, +) +from .base import AxisMoveInfo, BravoController, FirmwareVersion, JogParams + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Structural types for the swappable packet-codec module and move-info class +# --------------------------------------------------------------------------- +# +# self._agile_pkt is agile_packet (legacy Agile) on this class and +# agile_7612_packet on the Agile 7612 subclass; self._move_info_cls is +# AgileMoveInfo here and Agile7612MoveInfo there. Each pair exports the same +# call surface with a different wire encoding underneath, but the two +# modules and the two dataclasses are not related by inheritance, so a +# concrete type annotation naming one of them would be wrong for the other. +# These Protocols describe the shared shape structurally instead. + + +class AgileReplyLike(Protocol): + """The shape of a parsed Agile reply, common to both AgileReply classes.""" + + crc_valid: bool + + def get_register_value(self) -> int: ... + + +class AgilePacketModule(Protocol): + """The shared call surface of the agile_packet / agile_7612_packet modules.""" + + # AgileReply is a class, accessed as self._agile_pkt.AgileReply.from_packet(...): + # a classmethod reached through a class attribute reached through a module + # attribute. mypy's structural Protocol matching does not verify a chain + # this shape, so it is left untyped rather than given a check that cannot + # actually catch a mismatch; every other attribute below is checked. + AgileReply: Any + + def register_get(self, controller_id: int, register: int) -> bytes: ... + + def register_set_value(self, controller_id: int, register: int, value: int) -> bytes: ... + + def move_go(self, controller_id: int, axis_mask: int) -> bytes: ... + + def servo_enable(self, controller_id: int, axis: int) -> bytes: ... + + def servo_disable(self, controller_id: int, axis: int) -> bytes: ... + + def reset_faults(self, controller_id: int, axis_mask: int) -> bytes: ... + + def get_group_a_status(self, controller_id: int) -> bytes: ... + + +class MoveInfoLike(Protocol): + """The shape of a packed move-command payload, common to both move-info classes.""" + + position: float + velocity: float + acceleration: float + absolute_move: bool + + def pack(self) -> bytes: ... + + +class MoveInfoFactory(Protocol): + """The constructor shape shared by AgileMoveInfo and Agile7612MoveInfo.""" + + def __call__( + self, + *, + axis: Axis, + position: float, + velocity: float, + acceleration: float, + absolute_move: bool = ..., + check_for_homed: bool = ..., + home_complete_register: int = ..., + ) -> MoveInfoLike: ... + + +# --------------------------------------------------------------------------- +# Controller mapping +# --------------------------------------------------------------------------- + +_CONTROLLER_1_ID = 0 +_CONTROLLER_2_ID = 1 + +_CONTROLLER_1_AXES: frozenset[Axis] = frozenset({"x", "y", "z", "w"}) +_CONTROLLER_2_AXES: frozenset[Axis] = frozenset({"g", "zg"}) + +# --------------------------------------------------------------------------- +# Timing (seconds) +# --------------------------------------------------------------------------- + +_MOVE_POLL_INTERVAL = 0.010 +_HOME_POLL_INTERVAL = 0.050 +_DEFAULT_MOVE_TIMEOUT = 30.0 +_DEFAULT_HOME_TIMEOUT = 60.0 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _controller_for_axis(axis: Axis) -> int: + """Return the Agile bus controller ID that owns an axis. + + Args: + axis: The axis to look up. + + Returns: + ``1`` for the gripper axes (G, Zg), ``0`` for everything else. + """ + return _CONTROLLER_2_ID if axis in _CONTROLLER_2_AXES else _CONTROLLER_1_ID + + +def _local_axis_index(axis: Axis) -> int: + """Return an axis's 0-based index within its own Agile bus controller. + + Args: + axis: The axis to look up. + + Returns: + The axis's local index: 0-3 for X/Y/Z/W on controller 1, 0-1 for G/Zg + on controller 2. + """ + code = axis_code(axis) + return code - 4 if axis in _CONTROLLER_2_AXES else code + + +def _axis_bit(axis: Axis) -> int: + """Return a single-bit mask selecting an axis within its controller. + + Args: + axis: The axis to look up. + + Returns: + A bitmask with exactly one bit set, at the axis's local index. + """ + return 1 << _local_axis_index(axis) + + +def _parse_version_string(version_str: str) -> tuple[int, ...]: + """Parse a dotted firmware version string into a comparable tuple. + + Args: + version_str: A version string such as ``"2.0.0"``. + + Returns: + The parsed ``(major, minor, patch, ...)`` tuple, or ``(0, 0, 0)`` if + ``version_str`` cannot be parsed. + """ + try: + return tuple(int(x) for x in version_str.strip().split(".")) + except (ValueError, AttributeError): + return (0, 0, 0) + + +# --------------------------------------------------------------------------- +# AgileController +# --------------------------------------------------------------------------- + + +class AgileController(BravoController): + """Hardware controller for legacy Agile-generation Bravo liquid handlers. + + Speaks the V11 command protocol to a Rabbit microcontroller, which relays + 10-byte Agile packets to the motor controllers driving the gantry, head, + and gripper. + + Attributes: + has_gripper: Whether this model has a gripper accessory. + model_name: The human-readable model name, used in diagnostic messages. + """ + + _comm_cls: Type[V11DeviceComm] = V11DeviceComm + + has_gripper = True + model_name = "Bravo" + + def __init__(self, transport: Transport) -> None: + """Bind this controller to an already-connected transport. + + Args: + transport: The transport to communicate over. The caller owns its + connection lifecycle. + """ + super().__init__(transport) + self._comm: V11DeviceComm = self._comm_cls(transport) + self._last_error: Optional[BravoError] = None + self._firmware_version = FirmwareVersion() + self._firmware_tuple: tuple[int, ...] = (0, 0, 0) + self._homed: dict[Axis, bool] = {axis: False for axis in ALL_AXES} + + self._ticks_per_unit: dict[Axis, float] = { + **TICKS_PER_MM, + "w": DEFAULT_W_TICKS_PER_UL, + } + + # Both are swapped for Agile-7612-generation equivalents by that + # subclass -- see AgilePacketModule and MoveInfoFactory above. + self._agile_pkt: AgilePacketModule = agile_packet + self._move_info_cls: MoveInfoFactory = AgileMoveInfo + + # ----------------------------------------------------------------- + # Internal: firmware gate + # ----------------------------------------------------------------- + + @property + def _fw_at_least_2(self) -> bool: + """Whether the connected firmware is 2.0.0 or newer. + + Firmware 2.0.0 and later require an axis-index byte appended to every + ``CMD_DIRECT_AGILE_COMMAND`` payload so the Rabbit can route it to the + correct Agile controller. + """ + return self._firmware_tuple >= (2, 0, 0) + + # ----------------------------------------------------------------- + # Internal: communication primitives + # ----------------------------------------------------------------- + + def _require_connected(self) -> V11DeviceComm: + """Return the comm layer, raising if the transport is not connected. + + Returns: + The bound comm layer. + + Raises: + BravoError: If the transport is not currently connected. + """ + if not self._comm.is_connected: + raise BravoError(ErrorType.COULD_NOT_CONNECT) + return self._comm + + def _set_error(self, error: BravoError) -> None: + """Record the most recent error and log it. + + Args: + error: The error to record. + """ + self._last_error = error + logger.error("Bravo error: %s", error) + + def _send_agile( + self, + packet: bytes, + axis: Optional[Axis] = None, + timeout: float = 2.0, + ) -> bytes: + """Send a 10-byte Agile packet via ``CMD_DIRECT_AGILE_COMMAND``. + + Args: + packet: The 10-byte Agile packet to send. + axis: The axis this packet targets, if any. On firmware 2.0.0 and + later, its wire code is appended so the Rabbit can route the + packet to the correct controller. + timeout: Maximum time to wait for the response, in seconds. + + Returns: + The raw response payload. + """ + comm = self._require_connected() + payload = packet + if self._fw_at_least_2 and axis is not None: + payload = packet + struct.pack(" AgileReplyLike: + """Send an Agile packet and return a validated, parsed reply. + + Args: + packet: The 10-byte Agile packet to send. + axis: The axis this packet targets, if any. + timeout: Maximum time to wait for the response, in seconds. + + Returns: + The parsed Agile reply. + + Raises: + BravoError: If the response is too short, or its checksum is invalid. + """ + response = self._send_agile(packet, axis, timeout) + logger.debug("Agile response: %d bytes", len(response)) + if len(response) < AGILE_PACKET_SIZE: + raise BravoError(ErrorType.INVALID_AGILE_RESPONSE) + reply: AgileReplyLike = self._agile_pkt.AgileReply.from_packet(response[:AGILE_PACKET_SIZE]) + if not reply.crc_valid: + raise BravoError(ErrorType.AGILE_RABBIT_CRC) + return reply + + # ----------------------------------------------------------------- + # Internal: unit conversion + # ----------------------------------------------------------------- + + def _to_ticks(self, axis: Axis, value: float) -> float: + """Convert an engineering-unit value to encoder ticks. + + Args: + axis: The axis the value belongs to. + value: The value, in mm (or uL for the W axis). + + Returns: + The equivalent value in encoder ticks. + """ + return value * self._ticks_per_unit[axis] + + def _from_ticks(self, axis: Axis, ticks: float) -> float: + """Convert an encoder-tick value to engineering units. + + Args: + axis: The axis the value belongs to. + ticks: The value, in encoder ticks. + + Returns: + The equivalent value in mm (or uL for the W axis). + """ + return ticks / self._ticks_per_unit[axis] + + def _vel_to_ticks_per_ms(self, axis: Axis, mm_per_s: float) -> float: + """Convert a velocity from mm/s (or uL/s) to ticks/ms. + + Args: + axis: The axis the velocity belongs to. + mm_per_s: The velocity, in mm/s (or uL/s for the W axis). + + Returns: + The equivalent velocity in ticks/ms. + """ + return (mm_per_s * self._ticks_per_unit[axis]) / 1000.0 + + def _accel_to_ticks_per_ms2(self, axis: Axis, mm_per_s2: float) -> float: + """Convert an acceleration from mm/s^2 (or uL/s^2) to ticks/ms^2. + + Args: + axis: The axis the acceleration belongs to. + mm_per_s2: The acceleration, in mm/s^2 (or uL/s^2 for the W axis). + + Returns: + The equivalent acceleration in ticks/ms^2. + """ + return (mm_per_s2 * self._ticks_per_unit[axis]) / 1_000_000.0 + + # ----------------------------------------------------------------- + # Internal: controller verification + # ----------------------------------------------------------------- + + def _verify_controller(self, controller_id: int) -> bool: + """Confirm an Agile controller is alive by reading its unique-value register. + + Args: + controller_id: The Agile bus controller ID to verify. + + Returns: + True if the controller responds with the expected unique value. + """ + pkt = self._agile_pkt.register_get(controller_id, AgileRegister.UNIQUE_VALUE) + try: + reply = self._send_agile_parsed(pkt) + value = reply.get_register_value() + if value != UNIQUE_VALUE_EXPECTED: + logger.error( + "Controller %d unique-value mismatch: 0x%04X (expected 0x%04X)", + controller_id, + value, + UNIQUE_VALUE_EXPECTED, + ) + return False + logger.debug("Controller %d verified", controller_id) + return True + except BravoError as exc: + logger.error("Controller %d verification failed: %s", controller_id, exc) + return False + + # ----------------------------------------------------------------- + # Internal: motion polling + # ----------------------------------------------------------------- + + def _wait_for_in_position( + self, + axes: list[Axis], + timeout: float = _DEFAULT_MOVE_TIMEOUT, + ) -> None: + """Poll ``GetGroupAStatus`` until every target axis has settled. + + Args: + axes: The axes to wait for. + timeout: Maximum time to wait, in seconds. + + Raises: + BravoError: If any axis is still moving when ``timeout`` elapses. + """ + c1_mask = 0 + c2_mask = 0 + for axis in axes: + if axis in _CONTROLLER_1_AXES: + c1_mask |= _axis_bit(axis) + else: + c2_mask |= _axis_bit(axis) + + deadline = time.monotonic() + timeout + + while time.monotonic() < deadline: + all_settled = True + + if c1_mask: + pkt = self._agile_pkt.get_group_a_status(_CONTROLLER_1_ID) + reply = self._send_agile_parsed(pkt) + if reply.get_register_value() & c1_mask: + all_settled = False + + if c2_mask: + pkt = self._agile_pkt.get_group_a_status(_CONTROLLER_2_ID) + reply = self._send_agile_parsed(pkt) + if reply.get_register_value() & c2_mask: + all_settled = False + + if all_settled: + logger.debug("All axes in position: %s", [axis_label(a) for a in axes]) + return + + time.sleep(_MOVE_POLL_INTERVAL) + + raise BravoError( + ErrorType.MOVE_TIMEOUT, + custom_text=(f"Timed out waiting for axes {[axis_label(a) for a in axes]} ({timeout}s)"), + ) + + # ================================================================= + # BravoController interface -- Lifecycle + # ================================================================= + + def initialize(self) -> None: + """Reset homed state, query firmware version, and verify Agile controller 1 is alive. + + Every axis is marked unhomed first: nothing about a prior connection -- + including one this same controller instance held before a reconnect -- + can be trusted once initialize() runs again, since the physical axes + may have moved (or been power-cycled) while nothing was connected to + track them. + + Raises: + BravoError: If controller 1 does not respond with its expected + unique value. + """ + self._homed = {axis: False for axis in ALL_AXES} + try: + self._firmware_version = self.get_firmware_version() + self._firmware_tuple = _parse_version_string(self._firmware_version.master) + logger.info( + "Connected -- firmware master=%s sub1=%s sub2=%s", + self._firmware_version.master, + self._firmware_version.sub1, + self._firmware_version.sub2, + ) + except BravoError as exc: + logger.warning("Could not query firmware version: %s", exc) + + if not self._verify_controller(_CONTROLLER_1_ID): + raise BravoError( + ErrorType.CONTROLLER_UNIDENTIFIED, + custom_text="Controller 1 verification failed", + ) + logger.debug("Post-connect handshake complete") + + def ping(self) -> bool: + """Ping the Rabbit microcontroller.""" + try: + self._require_connected().send_command(CommandID.PING_DEVICE, timeout=1.0) + return True + except (BravoError, ConnectionError, TimeoutError): + return False + + @property + def is_connected(self) -> bool: + return self._comm.is_connected + + # ================================================================= + # BravoController interface -- Firmware + # ================================================================= + + def get_firmware_version(self) -> FirmwareVersion: + """Query firmware version strings from the Rabbit. + + The response contains up to three null-terminated ASCII strings + (master, sub-controller 1, sub-controller 2). + """ + comm = self._require_connected() + try: + data = comm.send_command(CommandID.QUERY_VERSION) + except BravoError as exc: + self._set_error(exc) + raise BravoError(ErrorType.COULD_NOT_QUERY_FIRMWARE) from exc + + parts = data.split(b"\x00") + strings = [p.decode("ascii", errors="replace") for p in parts if p] + + version = FirmwareVersion( + master=strings[0] if len(strings) > 0 else "", + sub1=strings[1] if len(strings) > 1 else "", + sub2=strings[2] if len(strings) > 2 else "", + ) + self._firmware_version = version + self._firmware_tuple = _parse_version_string(version.master) + return version + + # ================================================================= + # BravoController interface -- Motion + # ================================================================= + + def move( + self, + moves: list[AxisMoveInfo], + wait: bool = True, + timeout: float = _DEFAULT_MOVE_TIMEOUT, + ) -> None: + """Execute a coordinated multi-axis move. + + Converts each move to ticks and sends ``CMD_PREPARE_MOVE``, then groups + axes by controller and sends a ``MoveGo`` Agile packet via + ``CMD_DIRECT_AGILE_COMMAND``. If ``wait``, polls ``GetGroupAStatus`` + until all axes have settled. + + Args: + moves: The per-axis targets to move to together. + wait: Whether to block until the move finishes. + timeout: Maximum time to wait for the move to finish, in seconds. + """ + comm = self._require_connected() + + for m in moves: + info = self._move_info_cls( + axis=m.axis, + position=self._to_ticks(m.axis, m.position), + velocity=self._vel_to_ticks_per_ms(m.axis, m.velocity), + acceleration=self._accel_to_ticks_per_ms2(m.axis, m.acceleration), + absolute_move=m.absolute, + ) + logger.debug( + "Prepare move: %s pos=%.1f ticks vel=%.4f ticks/ms accel=%.6f ticks/ms^2 abs=%s", + axis_label(m.axis), + info.position, + info.velocity, + info.acceleration, + info.absolute_move, + ) + try: + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + except BravoError as exc: + self._set_error(exc) + raise + + c1_mask = 0 + c2_mask = 0 + for m in moves: + if m.axis in _CONTROLLER_1_AXES: + c1_mask |= _axis_bit(m.axis) + else: + c2_mask |= _axis_bit(m.axis) + + try: + if c1_mask: + pkt = self._agile_pkt.move_go(_CONTROLLER_1_ID, c1_mask) + self._send_agile(pkt) + logger.debug("MoveGo controller 1 mask=0x%02X", c1_mask) + if c2_mask: + pkt = self._agile_pkt.move_go(_CONTROLLER_2_ID, c2_mask) + self._send_agile(pkt) + logger.debug("MoveGo controller 2 mask=0x%02X", c2_mask) + except BravoError as exc: + self._set_error(exc) + raise + + if wait: + self._wait_for_in_position([m.axis for m in moves], timeout) + + def home_axes(self, axes: list[Axis], *, force: bool = False) -> None: + """Home one or more axes. + + Enables the servo for each axis and clears its home-flag register, then + sends ``MoveGo`` to start the homing sequence, then polls the home-flag + register until it reports non-zero (homed). + + Args: + axes: The axes to home. + force: Unused. Homing always runs unconditionally for the requested + axes. + + Raises: + BravoError: If any axis fails to report homed before the timeout. + """ + self._require_connected() + + for axis in axes: + cid = _controller_for_axis(axis) + local = _local_axis_index(axis) + + pkt = self._agile_pkt.servo_enable(cid, local) + self._send_agile(pkt, axis) + logger.debug("Servo enabled: %s (cid=%d local=%d)", axis_label(axis), cid, local) + + pkt = self._agile_pkt.register_set_value(cid, AgileRegister.HOME_FLAG, 0) + self._send_agile(pkt, axis) + + c1_mask = 0 + c2_mask = 0 + for axis in axes: + if axis in _CONTROLLER_1_AXES: + c1_mask |= _axis_bit(axis) + else: + c2_mask |= _axis_bit(axis) + + if c1_mask: + pkt = self._agile_pkt.move_go(_CONTROLLER_1_ID, c1_mask) + self._send_agile(pkt) + if c2_mask: + pkt = self._agile_pkt.move_go(_CONTROLLER_2_ID, c2_mask) + self._send_agile(pkt) + + deadline = time.monotonic() + _DEFAULT_HOME_TIMEOUT + pending = set(axes) + + while pending and time.monotonic() < deadline: + for axis in list(pending): + cid = _controller_for_axis(axis) + pkt = self._agile_pkt.register_get(cid, AgileRegister.HOME_FLAG) + try: + reply = self._send_agile_parsed(pkt, axis) + if reply.get_register_value() != 0: + self._homed[axis] = True + pending.discard(axis) + logger.info("Axis %s homed", axis_label(axis)) + except BravoError: + pass + + if pending: + time.sleep(_HOME_POLL_INTERVAL) + + if pending: + error = BravoError( + ErrorType.COULD_NOT_HOME, + custom_text=f"Homing timed out for: {[axis_label(a) for a in pending]}", + ) + self._set_error(error) + raise error + + def jog(self, params: JogParams) -> float: + """Execute a force-controlled jog move and return the final position.""" + comm = self._require_connected() + + info = AgileJogInfo( + axis=params.axis, + velocity=self._vel_to_ticks_per_ms(params.axis, params.velocity), + acceleration=self._accel_to_ticks_per_ms2(params.axis, params.acceleration), + max_position=self._to_ticks(params.axis, params.max_position), + tolerance=self._to_ticks(params.axis, params.tolerance), + peak_current=params.peak_current, + ) + + logger.debug("Preparing jog: %s", axis_label(params.axis)) + try: + comm.send_command(CommandID.PREPARE_JOG, info.pack()) + except BravoError as exc: + self._set_error(exc) + raise + + cid = _controller_for_axis(params.axis) + pkt = self._agile_pkt.move_go(cid, _axis_bit(params.axis)) + self._send_agile(pkt, params.axis) + + self._wait_for_in_position([params.axis]) + return self.get_position(params.axis) + + def get_position(self, axis: Axis) -> float: + """Read the current position of an axis, in engineering units (mm or uL).""" + comm = self._require_connected() + try: + data = comm.send_command( + CommandID.GET_POSITION, + struct.pack(" bool: + return self._homed[axis] + + def get_park_position(self, axis: Axis) -> float: + """Return the axis's park position. + + Returns: + ``0.0``. Legacy Agile hardware exposes no per-axis park metadata, so + every axis parks at its firmware zero. + """ + return 0.0 + + # ================================================================= + # BravoController interface -- Motor control + # ================================================================= + + def enable_motor(self, axis: Axis) -> None: + """Enable the servo drive for an axis.""" + cid = _controller_for_axis(axis) + pkt = self._agile_pkt.servo_enable(cid, _local_axis_index(axis)) + try: + self._send_agile(pkt, axis) + logger.debug("Motor enabled: %s", axis_label(axis)) + except BravoError as exc: + self._set_error(exc) + raise BravoError(ErrorType.COULD_NOT_ENABLE_MOTOR, axis=axis) from exc + + def disable_motor(self, axis: Axis) -> None: + """Disable the servo drive for an axis.""" + cid = _controller_for_axis(axis) + pkt = self._agile_pkt.servo_disable(cid, _local_axis_index(axis)) + try: + self._send_agile(pkt, axis) + logger.debug("Motor disabled: %s", axis_label(axis)) + except BravoError as exc: + self._set_error(exc) + raise BravoError(ErrorType.COULD_NOT_DISABLE_MOTOR, axis=axis) from exc + + def reset_faults(self, axes: list[Axis]) -> None: + """Clear fault flags on the given axes.""" + c1_mask = 0 + c2_mask = 0 + for axis in axes: + if axis in _CONTROLLER_1_AXES: + c1_mask |= _axis_bit(axis) + else: + c2_mask |= _axis_bit(axis) + + if c1_mask: + pkt = self._agile_pkt.reset_faults(_CONTROLLER_1_ID, c1_mask) + self._send_agile(pkt) + if c2_mask: + pkt = self._agile_pkt.reset_faults(_CONTROLLER_2_ID, c2_mask) + self._send_agile(pkt) + + logger.debug("Faults reset: %s", [axis_label(a) for a in axes]) + + # ================================================================= + # BravoController interface -- Device state + # ================================================================= + + def query_state(self) -> DeviceStateFlag: + """Query device-state flags from the Rabbit.""" + comm = self._require_connected() + try: + data = comm.send_command(CommandID.QUERY_STATE) + if len(data) < 1: + raise BravoError(ErrorType.COULD_NOT_QUERY_STATE) + return DeviceStateFlag(data[0]) + except BravoError as exc: + self._set_error(exc) + raise + + def is_go_button_pressed(self) -> bool: + """Check whether the front-panel Go button is pressed.""" + comm = self._require_connected() + try: + data = comm.send_command(CommandID.GO_BUTTON_PRESSED) + return len(data) >= 1 and data[0] != 0 + except BravoError as exc: + self._set_error(exc) + raise + + def clear_go_button(self) -> None: + """Clear the Go-button latch.""" + self._require_connected().send_command(CommandID.CLEAR_GO_BUTTON) + + # ================================================================= + # BravoController interface -- Lights + # ================================================================= + + def set_light(self, command: LightCommandData) -> None: + """Set an indicator light on the Bravo chassis.""" + comm = self._require_connected() + try: + comm.send_command(CommandID.SET_LIGHT, command.pack()) + logger.debug("Light set: %s", command) + except BravoError as exc: + self._set_error(exc) + raise BravoError(ErrorType.COULD_NOT_SET_LIGHT) from exc + + def clear_lights(self) -> None: + """Turn off all indicator lights.""" + self._require_connected().send_command(CommandID.CLEAR_LIGHTS) + logger.debug("Lights cleared") + + # ================================================================= + # BravoController interface -- Head detection + # ================================================================= + + def read_head_adc(self) -> int: + """Read the ADC value from the weigh-pad / head-detection resistor.""" + comm = self._require_connected() + try: + data = comm.send_command(CommandID.READ_AD_WEIGH_PAD) + if len(data) < 2: + raise BravoError(ErrorType.COULD_NOT_DETECT_HEAD) + adc = int(struct.unpack_from(" bool: + """Detect whether a smart head (PIC / EEPROM) is present. + + Returns: + True when the Rabbit receives an ACK from the PIC on the head's I2C + bus. + """ + comm = self._require_connected() + try: + data = comm.send_command(CommandID.DETECT_SMART_HEAD) + present = len(data) >= 1 and data[0] == 0x01 + logger.debug("Smart head detected: %s", present) + return present + except BravoError as exc: + self._set_error(exc) + raise BravoError(ErrorType.COULD_NOT_DETECT_SMART_HEAD) from exc + + def read_smart_head_type(self) -> int: + """Read the head-type code from the smart-head EEPROM at address 0x01.""" + comm = self._require_connected() + request = SmartHeadEEPROMData(address=EEPROMAddress.HEAD_TYPE, length=1) + try: + data = comm.send_command(CommandID.GET_EEPROM_DATA, request.pack()) + result = SmartHeadEEPROMData.unpack(data) + head_code = result.data[0] if result.data else 0 + logger.debug("Smart head type code: %d", head_code) + return head_code + except BravoError as exc: + self._set_error(exc) + raise + + # ================================================================= + # BravoController interface -- Gripper + # ================================================================= + + def detect_gripper(self) -> GripperDetectionState: + """Detect whether a gripper module is attached.""" + comm = self._require_connected() + try: + data = comm.send_command(CommandID.DETECT_GRIPPER) + if len(data) < 1: + return GripperDetectionState.NOT_YET_DETECTED + state = GripperDetectionState(data[0]) + logger.debug("Gripper detection: %s", state.name) + return state + except BravoError as exc: + self._set_error(exc) + raise BravoError(ErrorType.COULD_NOT_DETECT_GRIPPER) from exc + + def grip(self, speed: SpeedLevel, position: float, grip_lid: bool = False) -> None: + """Close the gripper jaws to the given position.""" + comm = self._require_connected() + + tpu = self._ticks_per_unit.get("g", TICKS_PER_MM["g"]) + current = 0.5 if speed == "fast" else 0.3 + params = GripperParams( + grip_current=current, + grip_velocity=self._vel_to_ticks_per_ms("g", 10.0), + grip_acceleration=self._accel_to_ticks_per_ms2("g", 100.0), + target_position=self._to_ticks("g", position), + position_tolerance=float(GRIP_POSITION_TOLERANCE), + max_gripper_current=0.5, + original_max_pos_error=1000.0, + original_velocity=self._vel_to_ticks_per_ms("g", 20.0), + original_acceleration=self._accel_to_ticks_per_ms2("g", 200.0), + ticks_per_eng_unit=tpu, + ) + + try: + comm.send_command(CommandID.GRIP, params.pack()) + logger.debug("Grip executed: position=%.3f mm speed=%s", position, speed) + except BravoError as exc: + self._set_error(exc) + raise + + def open_gripper(self, position: Optional[float] = None) -> None: + """Open the gripper jaws.""" + self.move( + [ + AxisMoveInfo( + axis="g", + position=OPEN_GRIPPER_POSITION if position is None else float(position), + velocity=20.0, + acceleration=200.0, + absolute=True, + ), + ] + ) + + def is_plate_in_gripper(self) -> bool: + """Return whether the gripper's jaw position indicates a plate is held.""" + try: + pos_ticks = self._to_ticks("g", self.get_position("g")) + open_ticks = self._to_ticks("g", OPEN_GRIPPER_POSITION) + return abs(pos_ticks - open_ticks) > GRIP_POSITION_TOLERANCE + except BravoError: + return False + + # ================================================================= + # BravoController interface -- Generic command dispatch + # ================================================================= + + def send_command(self, command_id: int, data: bytes = b"", timeout: float = 2.0) -> bytes: + """Send a raw V11 command, for extensibility beyond this interface.""" + return self._require_connected().send_command(CommandID(command_id), data, timeout) + + # ================================================================= + # BravoController interface -- Last error + # ================================================================= + + @property + def last_error(self) -> Optional[BravoError]: + return self._last_error + + # ================================================================= + # Configuration + # ================================================================= + + def set_w_axis_scale(self, ticks_per_ul: float) -> None: + """Set the W (plunger) axis encoder scale for the installed head. + + Different head types use different syringe volumes and thus different + ticks-per-uL ratios. Call this after head detection. + + Args: + ticks_per_ul: The W axis's encoder ticks per microlitre. + """ + self._ticks_per_unit["w"] = ticks_per_ul + logger.info("W-axis scale set to %.2f ticks/uL", ticks_per_ul) + + @property + def firmware_version(self) -> FirmwareVersion: + """The most recently queried firmware version.""" + return self._firmware_version diff --git a/pylabrobot/agilent/bravo/controllers/agile_7612.py b/pylabrobot/agilent/bravo/controllers/agile_7612.py new file mode 100644 index 00000000000..d21088ce410 --- /dev/null +++ b/pylabrobot/agilent/bravo/controllers/agile_7612.py @@ -0,0 +1,2054 @@ +"""Agile 7612 controller for Bravo hardware with 7612-generation wire encoding. + +Speaks the same Agile V11 protocol as :mod:`.agile`, with generation-specific +differences: + +- V11 frame byte order: ``[cmd][length]`` instead of ``[length][cmd]``. +- CRC-8/MAXIM instead of CRC-8/SMBUS. +- The move-command payload packs ``home_complete_register`` as a 16-bit + value instead of 32-bit. +- No ``move_go`` / ``servo_enable`` / ``get_group_a_status`` Agile commands; + motion is triggered and polled through header/subtype byte sequences + instead. +- Two-phase, host-driven homing with per-axis servo configuration. +- Force-controlled jog via ``CMD_PREPARE_JOG`` plus a 0x80-header trigger. +- Servo write header is ``local_axis_index * 0x10``, not a fixed value. +""" + +from __future__ import annotations + +import logging +import struct +import time +from typing import Optional, Union, cast + +from ..axis_config import AxisConfig, default_axis_config +from ..errors import BravoError, ErrorType +from ..protocol import agile_7612_packet +from ..protocol.agile_7612_commands import Agile7612MoveInfo +from ..protocol.agile_7612_crc import crc8_maxim +from ..protocol.agile_packet import AGILE_PACKET_SIZE +from ..protocol.commands import CommandID +from ..protocol.v11_agile_7612_comm import V11Agile7612DeviceComm +from ..transport import Transport +from ..types import ( + ALL_AXES, + Axis, + DeviceStateFlag, + GripperDetectionState, + HeadType, + SpeedLevel, + axis_code, + axis_display_name, + axis_label, +) +from .agile import ( + _CONTROLLER_1_AXES, + _CONTROLLER_2_AXES, + _CONTROLLER_2_ID, + AgileController, + _axis_bit, + _local_axis_index, +) +from .base import AxisMoveInfo, JogParams + +logger = logging.getLogger(__name__) + +_HOMING_DISTANCE_MM = 10_000.0 +_STOP_RETRIES = 3 +_STOP_RETRY_DELAY = 0.200 # seconds + +# Firmware park position (in mm from the home sensor) for each axis. Every +# axis parks at firmware 0 (the home sensor) except Zg, which parks at +# -20mm (its nesting/docking position, hardcoded in ``_home_zg``). +_FIRMWARE_PARK_MM: dict[Axis, float] = {"zg": -20.0} + +# Per-axis servo register 0xA0 values used during homing. +_HOMING_SERVO_REG_A0: dict[Axis, bytes] = { + "x": bytes.fromhex("60c1762bfd1000"), + "y": bytes.fromhex("60c1762bfd1000"), + "z": bytes.fromhex("7ae147aeff1000"), + "w": bytes.fromhex("7ae147aeff1000"), # assumed same as Z + "g": bytes.fromhex("489122ebff1000"), + "zg": bytes.fromhex("78f1e7d5fe1000"), +} + +# Servo register values: initial, between-phase swap, and post-phase reset. +_SERVO_A3_INITIAL = bytes.fromhex("40000000011000") +_SERVO_A4_INITIAL = bytes.fromhex("00000000001000") +_SERVO_A3_SWAPPED = bytes.fromhex("00000000001000") # A3 gets A4's initial value +_SERVO_A4_SWAPPED = bytes.fromhex("40000000011000") # A4 gets A3's initial value +_SERVO_A4_RESET = bytes.fromhex("00000000001000") # A4 reset after phase 2 + +# Home register enable/update values. +_HOME_REG_ENABLE = bytes.fromhex("00000000001000") +_HOME_REG_HOMED = bytes.fromhex("40000000011000") + +# Fallback sensor-flag bitmask per axis, used when an axis's configuration +# leaves home_flag_bitmask at its zero default. Each Controller 1 axis has +# its own bit (X=0x01, Y=0x02, Z=0x04, W=0x08); Controller 2 axes reuse +# 0x01/0x02 (G, Zg). +_DEFAULT_HOME_SENSOR_BITMASK: dict[Axis, int] = {"x": 1, "y": 2, "z": 4, "w": 8, "g": 1, "zg": 2} + + +def _homing_servo_registers(axis: Axis) -> list[tuple[int, bytes]]: + """Build the per-axis servo register writes used to prepare an axis for homing. + + Args: + axis: The axis to build servo register values for. + + Returns: + A list of ``(register, data)`` pairs to write in order. + """ + local_idx = _local_axis_index(axis) + axis_byte = local_idx + 1 + reg_a0 = _HOMING_SERVO_REG_A0.get(axis, bytes.fromhex("7ae147aeff1000")) + ae_data = bytearray.fromhex("40000000001000") + ae_data[4] = axis_byte + b0_data = bytearray.fromhex("40000000001000") + b0_data[4] = axis_byte + return [ + (0xA0, reg_a0), + (0xAD, bytes.fromhex("488000000c1000")), + (0xAE, bytes(ae_data)), + (0xAF, bytes.fromhex("00000000001000")), + (0xB0, bytes(b0_data)), + (0xBD, bytes.fromhex("00000000001000")), + ] + + +def _home_reg_register(axis: Axis) -> int: + """Return the Agile register number for an axis's home-complete register. + + X/G use 0x5E, Y/Zg use 0x5F, Z uses 0x60, W uses 0x61. + + Args: + axis: The axis to look up. + + Returns: + The register address. + """ + mapping = {0: 0x5E, 1: 0x5F, 2: 0x60, 3: 0x61, 4: 0x5E, 5: 0x5F} + return mapping.get(axis_code(axis), 0x5E) + + +class Agile7612Controller(AgileController): + """Agile controller for Agile 7612-generation Bravo hardware. + + Attributes: + has_gripper: Whether this model has a gripper accessory. + model_name: The human-readable model name, used in diagnostic messages. + """ + + _comm_cls = V11Agile7612DeviceComm + + has_gripper = True + model_name = "Bravo 7612" + + def __init__( + self, + transport: Transport, + axis_config: Optional[dict[Axis, AxisConfig]] = None, + ) -> None: + """Bind this controller to an already-connected transport. + + Args: + transport: The transport to communicate over. The caller owns its + connection lifecycle. + axis_config: Per-axis motion configuration, keyed by axis. An axis + missing from this mapping falls back to + :func:`~pylabrobot.agilent.bravo.axis_config.default_axis_config`. + Every axis's :attr:`~pylabrobot.agilent.bravo.axis_config.AxisConfig.ticks_per_eng_unit` + (whether from the given mapping or the default) becomes this + controller's encoder scale for that axis. + """ + super().__init__(transport) + self._agile_pkt = agile_7612_packet + self._move_info_cls = Agile7612MoveInfo + self._head_type: HeadType = "unknown" + + provided = axis_config or {} + self._axis_config: dict[Axis, AxisConfig] = { + axis: provided.get(axis, default_axis_config(axis)) for axis in ALL_AXES + } + for axis, cfg in self._axis_config.items(): + self._ticks_per_unit[axis] = cfg.ticks_per_eng_unit + + self._home_raw: dict[Axis, float] = {} + self._tracked_position: dict[Axis, float] = {} + + def initialize(self) -> None: + """Perform the base handshake, then clear this generation's tracked motion state. + + ``_home_raw`` and ``_tracked_position`` are only valid for the + connection that produced them: once initialize() runs again (a fresh + connect, or a reconnect on the same instance), any position they + recorded is no longer trustworthy. Cleared here alongside the homed + state the base class resets, rather than left to accumulate stale + entries across reconnects. + """ + super().initialize() + self._home_raw.clear() + self._tracked_position.clear() + + # ================================================================= + # Connection & verification + # ================================================================= + + _AGILE_7612_VERIFY_HEADER = 0x09 + _AGILE_7612_VERIFY_REGISTER = 0x90 + _AGILE_7612_UNIQUE_VALUE = 0x2A55 + + def _verify_controller(self, controller_id: int) -> bool: + """Confirm an Agile 7612 controller is alive by reading its unique-value register. + + Args: + controller_id: The Agile bus controller ID to verify. + + Returns: + True if the controller responds with the expected unique value. + """ + raw = bytearray(AGILE_PACKET_SIZE) + raw[0] = self._AGILE_7612_VERIFY_HEADER + raw[1] = self._AGILE_7612_VERIFY_REGISTER + raw[9] = crc8_maxim(raw, 9) + axis_index = 4 if controller_id == _CONTROLLER_2_ID else 0 + try: + comm = self._require_connected() + payload = bytes(raw) + struct.pack(" dict[str, object]: + """Return a snapshot of this controller's comm-layer diagnostics. + + Returns: + A dict with ``connected``, and when connected, ``command_counts`` + (commands sent, by name), ``errors`` (the comm layer's error log), + and ``error_count``. + """ + if not self._comm.is_connected: + return {"connected": False} + # self._comm is always a V11Agile7612DeviceComm for this class -- set + # from _comm_cls in AgileController.__init__ -- so this narrows the + # inherited V11DeviceComm type rather than checking anything at runtime. + comm = cast(V11Agile7612DeviceComm, self._comm) + return { + "connected": True, + "command_counts": dict(comm.command_counts), + "errors": list(comm.error_log), + "error_count": len(comm.error_log), + } + + # ================================================================= + # STOP command + # ================================================================= + + def stop(self) -> None: + """Send ``CMD_STOP``, retrying if it is not acknowledged.""" + comm = self._require_connected() + for attempt in range(1, _STOP_RETRIES + 1): + try: + comm.send_command(CommandID.STOP, timeout=1.0) + logger.info("STOP acknowledged on attempt %d", attempt) + return + except (BravoError, TimeoutError): + if attempt < _STOP_RETRIES: + time.sleep(_STOP_RETRY_DELAY) + logger.warning("STOP not acknowledged after %d attempts", _STOP_RETRIES) + + # ================================================================= + # Agile packet helpers + # ================================================================= + + def _send_agile(self, packet: bytes, axis: Optional[Axis] = None, timeout: float = 2.0) -> bytes: + """Send a 10-byte Agile packet via ``CMD_DIRECT_AGILE_COMMAND``. + + Unlike the base class, this always appends a trailing axis-index byte + -- there is no firmware-version gate on the Agile 7612 generation, only + the legacy generation's firmware 2.0.0+ requirement. When no axis is + given, the index is inferred from the packet's own controller-id byte + (byte 1) rather than left off. + + Args: + packet: The 10-byte Agile packet to send. + axis: The axis this packet targets, if any. Falls back to inferring + the controller from ``packet[1]`` when omitted. + timeout: Maximum time to wait for the response, in seconds. + + Returns: + The raw response payload. + """ + comm = self._require_connected() + if axis is not None: + axis_index = axis_code(axis) + else: + cid = packet[1] if len(packet) > 1 else 0 + axis_index = 4 if cid == 1 else 0 + payload = packet + struct.pack(" bytes: + """Read a register with the standard per-axis header (``0x01 + local_idx * 0x10``).""" + local_idx = _local_axis_index(axis) + header = 0x01 + (local_idx * 0x10) + raw = bytearray(10) + raw[0] = header + raw[1] = register & 0xFF + raw[9] = crc8_maxim(raw, 9) + return self._send_agile(bytes(raw), axis) + + def _agile_7612_ext_read(self, register: int, axis: Axis) -> bytes: + """Read a register with the extended header (``0x09``).""" + raw = bytearray(10) + raw[0] = 0x09 + raw[1] = register & 0xFF + raw[9] = crc8_maxim(raw, 9) + return self._send_agile(bytes(raw), axis) + + def _agile_7612_status_read(self, register: int, axis_index: int) -> bytes: + """Read controller status. The register goes in byte 7, not byte 1.""" + raw = bytearray(10) + raw[0] = 0x00 + raw[7] = register & 0xFF + raw[9] = crc8_maxim(raw, 9) + comm = self._require_connected() + payload = bytes(raw) + struct.pack(" None: + """Write a servo register. The header is ``local_axis_index * 0x10``.""" + header = _local_axis_index(axis) * 0x10 + raw = bytearray(10) + raw[0] = header + raw[1] = register & 0xFF + for i, b in enumerate(data[:6]): + raw[2 + i] = b + raw[8] = data[6] if len(data) > 6 else 0 + raw[9] = crc8_maxim(raw, 9) + self._send_agile(bytes(raw), axis) + + def _agile_7612_write_home_reg(self, axis: Axis, data: bytes) -> None: + """Write an axis's home-complete register. + + The firmware expects header ``0x01`` (not the servo write header) for + this register, targeting the Agile register address for this axis + (X/G=0x5E, Y/Zg=0x5F, Z=0x60, W=0x61) -- not the PREPARE_MOVE payload's + home_complete_register field, which is a different value entirely (an + AxisConfig field defaulting to 0 unless a caller overrides it). + """ + reg = _home_reg_register(axis) + raw = bytearray(10) + raw[0] = 0x01 + raw[1] = reg & 0xFF + for i, b in enumerate(data[:6]): + raw[2 + i] = b + raw[8] = data[6] if len(data) > 6 else 0 + raw[9] = crc8_maxim(raw, 9) + self._send_agile(bytes(raw), axis) + + def _agile_7612_fault_reset_ctrl2(self) -> None: + """Reset controller 2's fault state after a move, as the firmware expects.""" + raw = bytearray(10) + raw[0] = 0x00 + raw[1] = 0x01 + raw[7] = 0x31 + raw[9] = crc8_maxim(raw, 9) + try: + comm = self._require_connected() + payload = bytes(raw) + struct.pack(" None: + """Discard stale bytes left in the transport after a comm error.""" + self._comm.transport.drain() + + # ================================================================= + # Unsupported commands + # ================================================================= + + _UNSUPPORTED_COMMANDS = frozenset( + { + CommandID.CLEAR_MOTOR_POWER_FAULT, + CommandID.QUERY_MOTOR_POWER, + CommandID.GET_POSITION, + CommandID.DETECT_SMART_HEAD, + CommandID.READ_AD_WEIGH_PAD, + } + ) + + def send_command(self, command_id: int, data: bytes = b"", timeout: float = 2.0) -> bytes: + """Send a raw V11 command, silently no-op-ing commands this generation does not support. + + Unlike the base class, a command in :attr:`_UNSUPPORTED_COMMANDS` + (register queries the Agile 7612 firmware does not implement) returns + ``b""`` instead of being sent -- the base class's contract of sending + whatever it is given and surfacing the device's own response or error + does not hold here for those specific commands. + + Args: + command_id: The command to send. + data: The command's payload bytes, if any. + timeout: Maximum time to wait for the response, in seconds. + + Returns: + The response payload, or ``b""`` for an unsupported command. + """ + cid = CommandID(command_id) if isinstance(command_id, int) else command_id + if cid in self._UNSUPPORTED_COMMANDS: + logger.debug("Agile7612: skipping unsupported command 0x%02X", command_id) + return b"" + return super().send_command(command_id, data, timeout) + + # ================================================================= + # Position reading + # ================================================================= + + _CTRL2_EFFECTIVE_TPU: dict[Axis, float] = { + "g": 126.8 * (944.882 / 787.402), + "zg": 126.8, + } + + # Position register resolution multiplier per axis: the register changes + # by (ticks_sent x multiplier) for each move. X/Y are 16x, Z is 8x; W is + # not yet independently measured and uses the same 8x as Z until it is. + _CTRL1_POSITION_SCALE: dict[Axis, float] = { + "x": 16.0, + "y": 16.0, + "z": 8.0, + "w": 8.0, + } + + def _read_raw_position(self, axis: Axis) -> float: + """Read the raw position register and convert it to engineering units. + + Args: + axis: The axis to read. + + Returns: + The position, in mm (or uL for the W axis), relative to firmware + zero. + + Raises: + BravoError: If the register read returns too little data. + """ + response = self._agile_7612_agile_read(0x07, axis) + if len(response) < 10: + raise BravoError(ErrorType.COULD_NOT_READ_POSITION, axis=axis) + raw_be_u16 = struct.unpack_from(">H", response, 2)[0] + if axis in _CONTROLLER_1_AXES: + scale = self._CTRL1_POSITION_SCALE.get(axis, 8.0) + tpu = self._ticks_per_unit.get(axis, 314.96) + return float(raw_be_u16) / (tpu * scale / 2.0) + sign = -1.0 if (raw_be_u16 & 0x8000) else 1.0 + magnitude = raw_be_u16 & 0x7FFF + eff_tpu = self._CTRL2_EFFECTIVE_TPU.get(axis, 126.8) + return sign * float(magnitude) * 2.0 / eff_tpu + + def get_position(self, axis: Axis) -> float: + """Return the current position of an axis, in engineering units (mm or uL). + + Unlike the base class, this does not necessarily read the device: the + last position :meth:`move` (or homing) computed for this axis is + trusted and returned directly when available, since this generation's + position registers wrap and cannot always be decoded back to an + absolute engineering-unit value on their own. Only when nothing is + tracked yet does this fall back to reading the raw position register, + which it then offsets against the raw reading captured at the last + home. + + Args: + axis: The axis to read. + + Returns: + The tracked position if one is known, otherwise the raw register + reading (offset against the last home, if the axis has one). + """ + if axis in self._tracked_position: + return self._tracked_position[axis] + raw = self._read_raw_position(axis) + if axis in self._home_raw: + home_offset = self.get_park_position(axis) + return (raw - self._home_raw[axis]) + home_offset + return raw + + def get_all_positions(self) -> dict[str, float]: + """Return every axis's position, keyed by its display name. + + Returns: + A dict from axis display name (e.g. ``"Zg"``) to position, in mm (or + uL for W). An axis whose read fails is omitted rather than raising. + """ + out: dict[str, float] = {} + for axis in ALL_AXES: + try: + out[axis_display_name(axis)] = self.get_position(axis) + except Exception as exc: # noqa: BLE001 - a single axis read must not abort the rest + logger.debug("get_all_positions: %s read failed: %s", axis_display_name(axis), exc) + return out + + def _capture_home_position(self, axis: Axis) -> None: + """Record the tracked and raw positions once an axis finishes homing. + + Args: + axis: The axis that just finished homing. + """ + park = self.get_park_position(axis) + self._tracked_position[axis] = park + try: + self._home_raw[axis] = self._read_raw_position(axis) + except BravoError: + pass + logger.info("Home position %s: tracked=%.3f", axis_label(axis), park) + + # ================================================================= + # Motion -- PREPARE_MOVE + trigger + # ================================================================= + + _MOVE_POLL_INTERVAL = 0.050 + _STATUS_REG_GENERAL = 0x90 + _STATUS_SETTLED = 0xB0 + _TRIGGER_SUBTYPE = 0x38 + _JOG_TRIGGER_HEADER = 0x80 + _JOG_TRIGGER_SUBTYPE = 0x36 + + def _home_reg_for_axis(self, axis: Axis) -> int: + """Return the axis's configured home-complete register.""" + return self._axis_config[axis].home_complete_register + + def _agile_7612_move_go(self, axes: list[Axis]) -> None: + """Trigger pending moves, one axis at a time. + + Each trigger is header=0x00, byte[1]=axis bitmask, byte[7]=0x38, + routed by the axis's own wire code -- not combined into one bitmasked + command for every axis at once. + + Args: + axes: The axes whose pending moves should start. + """ + comm = self._require_connected() + for axis in axes: + raw = bytearray(10) + raw[0] = 0x00 + raw[1] = _axis_bit(axis) + raw[7] = self._TRIGGER_SUBTYPE + raw[9] = crc8_maxim(raw, 9) + comm.send_command( + CommandID.DIRECT_AGILE_COMMAND, bytes(raw) + struct.pack(" None: + """Trigger a force-controlled jog: header=0x80, byte[7]=0x36. + + Args: + axis: The axis to trigger the jog on. + """ + raw = bytearray(10) + raw[0] = self._JOG_TRIGGER_HEADER + raw[2] = 0x40 + raw[6] = 0x05 + raw[7] = self._JOG_TRIGGER_SUBTYPE + raw[9] = crc8_maxim(raw, 9) + comm = self._require_connected() + comm.send_command( + CommandID.DIRECT_AGILE_COMMAND, bytes(raw) + struct.pack(" tuple[float, float]: + """Return the velocity/acceleration pair configured for an axis and speed level. + + Args: + axis: The axis to look up. + level: The speed level to look up. + + Returns: + A ``(velocity, acceleration)`` pair. Falls back to ``(50.0, 100.0)`` + if the axis's configuration has no entry for ``level``. + """ + profile = self._axis_config[axis].speeds.get(level) + if profile is not None: + return (profile.velocity, profile.acceleration) + return (50.0, 100.0) + + def _default_vel_accel(self, axis: Axis) -> tuple[float, float]: + """Return the axis's "safe" speed level, used when a move requests no velocity.""" + return self._speed_for_level(axis, "safe") + + def move(self, moves: list[AxisMoveInfo], wait: bool = True, timeout: float = 30.0) -> None: + """Execute motion via ``CMD_PREPARE_MOVE`` plus a per-axis trigger. + + Args: + moves: The per-axis targets to move to together. + wait: Whether to block until the move finishes. + timeout: Maximum time to wait for the move to finish, in seconds. + + Raises: + BravoError: If any targeted axis has not been homed, or a target + falls outside the axis's configured range. + """ + if not moves: + return + for m in moves: + if not self._homed[m.axis]: + raise BravoError( + ErrorType.COULD_NOT_MOVE_TO_POSITION, + custom_text=( + f"{axis_display_name(m.axis)} axis not initialized; " + "home the axis before issuing a move." + ), + ) + comm = self._require_connected() + for m in moves: + self._validate_target(m) + for m in moves: + vel = m.velocity + accel = m.acceleration + if vel == 0.0: + vel, accel = self._default_vel_accel(m.axis) + if m.absolute: + origin = self._move_origin(m.axis) + firmware_mm = m.position - origin + else: + firmware_mm = m.position + info = self._move_info_cls( + axis=m.axis, + position=self._to_ticks(m.axis, firmware_mm), + velocity=self._vel_to_ticks_per_ms(m.axis, vel), + acceleration=self._accel_to_ticks_per_ms2(m.axis, accel), + absolute_move=m.absolute, + check_for_homed=True, + home_complete_register=self._home_reg_for_axis(m.axis), + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([m.axis for m in moves]) + if wait: + self._agile_7612_wait_for_settled([m.axis for m in moves], timeout) + for m in moves: + if m.absolute: + self._tracked_position[m.axis] = m.position + elif m.axis in self._tracked_position: + self._tracked_position[m.axis] += m.position + self._agile_7612_fault_reset_ctrl2() + + def _move_origin(self, axis: Axis) -> float: + """Return the engineering-unit position corresponding to firmware zero ticks. + + After homing, firmware 0 is the home sensor. The engineering position + there is the axis's homing offset minus whatever firmware park offset + its homing method used (0 for most axes, -20mm for Zg). + + Args: + axis: The axis to compute the origin for. + + Returns: + The engineering-unit position of firmware zero. + """ + park_offset = self.get_park_position(axis) + firmware_park = _FIRMWARE_PARK_MM.get(axis, 0.0) + return park_offset - firmware_park + + def _validate_target(self, m: AxisMoveInfo) -> None: + """Reject a move whose target falls outside the axis's configured range. + + Args: + m: The move to validate. + + Raises: + BravoError: If the resolved target is outside the axis's range. + """ + ax_cfg = self._axis_config[m.axis] + lo = ax_cfg.range.min_pos + hi = ax_cfg.range.max_pos + if m.absolute: + target = m.position + else: + current = self.get_position(m.axis) + target = current + m.position + if not (lo <= target <= hi): + raise BravoError( + ErrorType.COULD_NOT_MOVE_TO_POSITION, + custom_text=( + f"Move target {target:.4f} mm on {axis_display_name(m.axis)} is outside " + f"software limits [{lo:.4f}, {hi:.4f}]." + ), + ) + + def _agile_7612_wait_for_settled(self, axes: list[Axis], timeout: float = 30.0) -> None: + """Poll status until the given axes are settled. + + Only the status bytes for the axes actually moving are checked, not + every byte in the response -- an uninitialized axis (e.g. W before + homing) can show a permanently busy status byte, which would block + settle detection if every byte were checked regardless of whether that + axis was part of this move. + + Args: + axes: The axes to wait for. + timeout: Maximum time to wait, in seconds. + + Raises: + BravoError: If any axis is still unsettled when ``timeout`` elapses. + """ + ctrl1_positions = [] + ctrl2_positions = [] + for axis in axes: + local = _local_axis_index(axis) + if axis in _CONTROLLER_1_AXES: + ctrl1_positions.append(local) + else: + ctrl2_positions.append(local) + + deadline = time.monotonic() + timeout + self._require_connected() + poll_count = 0 + while time.monotonic() < deadline: + try: + all_settled = True + stuck_info = [] + if ctrl1_positions: + resp1 = self._agile_7612_status_read(self._STATUS_REG_GENERAL, 0) + if len(resp1) < 6: + all_settled = False + stuck_info.append("ctrl1: short response") + else: + for pos in ctrl1_positions: + b = resp1[2 + pos] + if b != 0x00 and (b & 0xF0) != self._STATUS_SETTLED: + all_settled = False + stuck_info.append(f"ctrl1[{pos}]=0x{b:02X}") + if ctrl2_positions: + resp2 = self._agile_7612_status_read(self._STATUS_REG_GENERAL, 4) + if len(resp2) < 6: + all_settled = False + stuck_info.append("ctrl2: short response") + else: + for pos in ctrl2_positions: + b = resp2[2 + pos] + if b != 0x00 and (b & 0xF0) != self._STATUS_SETTLED: + all_settled = False + stuck_info.append(f"ctrl2[{pos}]=0x{b:02X}") + if all_settled: + return + poll_count += 1 + if poll_count % 50 == 0: + elapsed = timeout - (deadline - time.monotonic()) + logger.warning( + "Settle wait %.1fs axes=%s stuck: %s", + elapsed, + [axis_display_name(a) for a in axes], + ", ".join(stuck_info), + ) + except (BravoError, TimeoutError, ConnectionError): + pass + time.sleep(self._MOVE_POLL_INTERVAL) + stuck_info_final = [] + try: + if ctrl1_positions: + resp1 = self._agile_7612_status_read(self._STATUS_REG_GENERAL, 0) + stuck_info_final.append(f"ctrl1={resp1.hex() if resp1 else 'None'}") + if ctrl2_positions: + resp2 = self._agile_7612_status_read(self._STATUS_REG_GENERAL, 4) + stuck_info_final.append(f"ctrl2={resp2.hex() if resp2 else 'None'}") + except Exception: # noqa: BLE001 - this is best-effort diagnostics before raising + pass + logger.warning( + "Settle TIMEOUT axes=%s final_status: %s", + [axis_display_name(a) for a in axes], + ", ".join(stuck_info_final), + ) + raise BravoError( + ErrorType.MOVE_TIMEOUT, + custom_text=f"Timed out: {[axis_label(a) for a in axes]} ({timeout}s)", + ) + + # ================================================================= + # Homing -- two-phase with between-phase servo swaps + # ================================================================= + + def _homing_vel_accel(self, axis: Axis) -> tuple[float, float]: + """Return an axis's homing velocity and acceleration, converted to ticks.""" + vel_mms, accel_mms2 = self._speed_for_level(axis, "homing") + return ( + self._vel_to_ticks_per_ms(axis, vel_mms), + self._accel_to_ticks_per_ms2(axis, accel_mms2), + ) + + def _homing_depart_direction(self, axis: Axis) -> int: + """Return the direction sign that moves an axis away from its home sensor. + + Args: + axis: The axis to look up. + + Returns: + ``-1`` if the sensor sits at the positive end of travel, ``1`` + otherwise. + """ + if self._axis_config[axis].home_in_positive_direction: + return -1 + return 1 + + def _home_sensor_bitmask(self, axis: Axis) -> int: + """Return the bitmask for an axis's sensor flag in the register-0x10 status byte. + + Args: + axis: The axis to look up. + + Returns: + The configured bitmask, or a per-axis default if the axis's + configuration leaves it unset. + """ + bitmask = self._axis_config[axis].home_flag_bitmask + if bitmask: + return bitmask + return _DEFAULT_HOME_SENSOR_BITMASK.get(axis, 4) + + def _agile_7612_servo_config_for_homing(self, axis: Axis) -> None: + """Write every homing servo register for an axis, ignoring individual failures.""" + for reg, data in _homing_servo_registers(axis): + try: + self._agile_7612_servo_write(reg, data, axis) + except BravoError as exc: + logger.warning("Homing servo 0x%02X failed: %s", reg, exc) + + # ================================================================= + # Per-axis homing methods + # ================================================================= + # Each axis has its own byte-exact sequence rather than one parameterized + # direction-search routine, because the phase order and servo register + # values are fixed per axis by the firmware, not derivable from a shared + # formula. + + def _home_x(self) -> None: + """Home the X axis. + + Reads register 0x10 (header 0x09) after servo configuration to pick + the phase pattern: ``0x7F`` means the axis is on or past the sensor + (2-phase: negative fast, positive slow); anything else means it is off + the sensor (3-phase: positive fast, negative fast, positive slow). + """ + comm = self._require_connected() + axis: Axis = "x" + vel, accel = self._homing_vel_accel(axis) + home_reg = self._home_reg_for_axis(axis) + large_ticks = _HOMING_DISTANCE_MM * self._ticks_per_unit.get(axis, 314.96) + + try: + self._agile_7612_agile_read(0x60, axis) + except BravoError: + pass + try: + self._agile_7612_agile_read(0x4A, axis) + except BravoError: + pass + try: + self._agile_7612_write_home_reg(axis, _HOME_REG_ENABLE) + except BravoError: + pass + + self._agile_7612_servo_config_for_homing(axis) + + on_sensor = False + try: + resp = self._agile_7612_ext_read(0x10, axis) + if len(resp) >= 3: + sensor_byte = resp[2] + on_sensor = bool(sensor_byte & self._home_sensor_bitmask(axis)) + logger.info( + "Agile7612 homing X: reg 0x10 sensor byte=0x%02X -> %s", + sensor_byte, + "on sensor" if on_sensor else "off sensor", + ) + except BravoError: + logger.warning("Agile7612 homing X: reg 0x10 read failed, defaulting to 3-phase") + + if on_sensor: + logger.info("Agile7612 homing X: 2-phase (negative fast, positive slow)") + self._agile_7612_servo_write(0xA3, _SERVO_A3_INITIAL, axis) + self._agile_7612_servo_write(0xA4, _SERVO_A4_INITIAL, axis) + info = self._move_info_cls( + axis=axis, + position=-large_ticks, + velocity=vel, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + else: + logger.info("Agile7612 homing X: 3-phase (positive fast, negative fast, positive slow)") + self._agile_7612_servo_write(0xA3, _SERVO_A3_INITIAL, axis) + self._agile_7612_servo_write(0xA4, _SERVO_A4_INITIAL, axis) + info = self._move_info_cls( + axis=axis, + position=large_ticks, + velocity=vel, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + + self._agile_7612_servo_write(0xA3, _SERVO_A3_INITIAL, axis) + self._agile_7612_servo_write(0xA4, _SERVO_A4_INITIAL, axis) + info = self._move_info_cls( + axis=axis, + position=-large_ticks, + velocity=vel, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + + self._agile_7612_servo_write(0xA4, _SERVO_A4_SWAPPED, axis) + self._agile_7612_servo_write(0xA3, _SERVO_A3_SWAPPED, axis) + info = self._move_info_cls( + axis=axis, + position=large_ticks, + velocity=vel / 10.0, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + + try: + self._agile_7612_servo_write(0xA4, _SERVO_A4_RESET, axis) + except BravoError: + pass + try: + raw = bytearray(10) + raw[0] = 0x00 + raw[1] = _axis_bit(axis) + raw[7] = 0x52 + raw[9] = crc8_maxim(raw, 9) + self._send_agile(bytes(raw), axis) + except BravoError: + pass + try: + self._agile_7612_write_home_reg(axis, _HOME_REG_HOMED) + except BravoError: + pass + + self._homed[axis] = True + self._capture_home_position(axis) + logger.info("Axis X homed") + + def _home_y(self) -> None: + """Home the Y axis. Identical pattern to X: register 0x10 byte 2 picks the phase.""" + comm = self._require_connected() + axis: Axis = "y" + vel, accel = self._homing_vel_accel(axis) + home_reg = self._home_reg_for_axis(axis) + large_ticks = _HOMING_DISTANCE_MM * self._ticks_per_unit.get(axis, 314.96) + + try: + self._agile_7612_agile_read(0x4A, axis) + except BravoError: + pass + try: + self._agile_7612_write_home_reg(axis, _HOME_REG_ENABLE) + except BravoError: + pass + + self._agile_7612_servo_config_for_homing(axis) + + on_sensor = False + try: + resp = self._agile_7612_ext_read(0x10, axis) + if len(resp) >= 3: + sensor_byte = resp[2] + on_sensor = bool(sensor_byte & self._home_sensor_bitmask(axis)) + logger.info( + "Agile7612 homing Y: reg 0x10 sensor byte=0x%02X -> %s", + sensor_byte, + "on sensor" if on_sensor else "off sensor", + ) + except BravoError: + logger.warning("Agile7612 homing Y: reg 0x10 read failed, defaulting to 3-phase") + + if on_sensor: + logger.info("Agile7612 homing Y: 2-phase (negative fast, positive slow)") + self._agile_7612_servo_write(0xA3, _SERVO_A3_INITIAL, axis) + self._agile_7612_servo_write(0xA4, _SERVO_A4_INITIAL, axis) + info = self._move_info_cls( + axis=axis, + position=-large_ticks, + velocity=vel, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + else: + logger.info("Agile7612 homing Y: 3-phase (positive fast, negative fast, positive slow)") + self._agile_7612_servo_write(0xA3, _SERVO_A3_INITIAL, axis) + self._agile_7612_servo_write(0xA4, _SERVO_A4_INITIAL, axis) + info = self._move_info_cls( + axis=axis, + position=large_ticks, + velocity=vel, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + + self._agile_7612_servo_write(0xA3, _SERVO_A3_INITIAL, axis) + self._agile_7612_servo_write(0xA4, _SERVO_A4_INITIAL, axis) + info = self._move_info_cls( + axis=axis, + position=-large_ticks, + velocity=vel, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + + self._agile_7612_servo_write(0xA4, _SERVO_A4_SWAPPED, axis) + self._agile_7612_servo_write(0xA3, _SERVO_A3_SWAPPED, axis) + info = self._move_info_cls( + axis=axis, + position=large_ticks, + velocity=vel / 10.0, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + + try: + self._agile_7612_servo_write(0xA4, _SERVO_A4_RESET, axis) + except BravoError: + pass + try: + raw = bytearray(10) + raw[0] = 0x00 + raw[1] = _axis_bit(axis) + raw[7] = 0x52 + raw[9] = crc8_maxim(raw, 9) + self._send_agile(bytes(raw), axis) + except BravoError: + pass + try: + self._agile_7612_write_home_reg(axis, _HOME_REG_HOMED) + except BravoError: + pass + + self._homed[axis] = True + self._capture_home_position(axis) + logger.info("Axis Y homed") + + def _home_z(self) -> None: + """Home the Z axis. + + Z's home sensor is at the top (negative end), so its directions are + flipped relative to X/Y: on-sensor departs positive (down) and + approaches negative (up) slowly; off-sensor approaches negative first. + Parks with an absolute move to position 0 (the top) once homed. + """ + comm = self._require_connected() + axis: Axis = "z" + vel, accel = self._homing_vel_accel(axis) + home_reg = self._home_reg_for_axis(axis) + large_ticks = _HOMING_DISTANCE_MM * self._ticks_per_unit.get(axis, 1600.0) + + try: + self._agile_7612_agile_read(0x4A, axis) + except BravoError: + pass + try: + self._agile_7612_write_home_reg(axis, _HOME_REG_ENABLE) + except BravoError: + pass + + self._agile_7612_servo_config_for_homing(axis) + + on_sensor = False + try: + resp = self._agile_7612_ext_read(0x10, axis) + if len(resp) >= 3: + sensor_byte = resp[2] + on_sensor = bool(sensor_byte & self._home_sensor_bitmask(axis)) + logger.info( + "Agile7612 homing Z: reg 0x10 sensor byte=0x%02X -> %s", + sensor_byte, + "on sensor" if on_sensor else "off sensor", + ) + except BravoError: + logger.warning("Agile7612 homing Z: reg 0x10 read failed, defaulting to 3-phase") + + if on_sensor: + logger.info("Agile7612 homing Z: 2-phase (positive fast, negative slow)") + self._agile_7612_servo_write(0xA3, _SERVO_A3_INITIAL, axis) + self._agile_7612_servo_write(0xA4, _SERVO_A4_INITIAL, axis) + info = self._move_info_cls( + axis=axis, + position=large_ticks, + velocity=vel, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + else: + logger.info("Agile7612 homing Z: 3-phase (negative fast, positive fast, negative slow)") + self._agile_7612_servo_write(0xA3, _SERVO_A3_INITIAL, axis) + self._agile_7612_servo_write(0xA4, _SERVO_A4_INITIAL, axis) + info = self._move_info_cls( + axis=axis, + position=-large_ticks, + velocity=vel, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + + self._agile_7612_servo_write(0xA3, _SERVO_A3_INITIAL, axis) + self._agile_7612_servo_write(0xA4, _SERVO_A4_INITIAL, axis) + info = self._move_info_cls( + axis=axis, + position=large_ticks, + velocity=vel, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + + self._agile_7612_servo_write(0xA4, _SERVO_A4_SWAPPED, axis) + self._agile_7612_servo_write(0xA3, _SERVO_A3_SWAPPED, axis) + info = self._move_info_cls( + axis=axis, + position=-large_ticks, + velocity=vel / 10.0, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + + try: + self._agile_7612_servo_write(0xA4, _SERVO_A4_RESET, axis) + except BravoError: + pass + try: + raw = bytearray(10) + raw[0] = 0x00 + raw[1] = _axis_bit(axis) + raw[7] = 0x52 + raw[9] = crc8_maxim(raw, 9) + self._send_agile(bytes(raw), axis) + except BravoError: + pass + try: + self._agile_7612_write_home_reg(axis, _HOME_REG_HOMED) + except BravoError: + pass + + info = self._move_info_cls( + axis=axis, + position=0.0, + velocity=vel, + acceleration=accel, + absolute_move=True, + check_for_homed=True, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + ctrl_base = 4 if axis in _CONTROLLER_2_AXES else 0 + raw = bytearray(10) + raw[0] = 0x00 + raw[1] = _axis_bit(axis) + raw[7] = 0x38 + raw[9] = crc8_maxim(raw, 9) + comm.send_command(CommandID.DIRECT_AGILE_COMMAND, bytes(raw) + struct.pack(" None: + """Home the W axis (plunger). + + On controller 1, home_in_positive_direction is False (sensor at the + negative end, like Z). Register 0x10 selects the phase pattern the + same way as X/Y. Parks with an absolute move to position 0 once homed. + """ + comm = self._require_connected() + axis: Axis = "w" + vel, accel = self._homing_vel_accel(axis) + home_reg = self._home_reg_for_axis(axis) + large_ticks = _HOMING_DISTANCE_MM * self._ticks_per_unit.get(axis, 448.0) + + try: + self._agile_7612_agile_read(0x4A, axis) + except BravoError: + pass + try: + self._agile_7612_write_home_reg(axis, _HOME_REG_ENABLE) + except BravoError: + pass + + self._agile_7612_servo_config_for_homing(axis) + + on_sensor = False + try: + resp = self._agile_7612_ext_read(0x10, axis) + if len(resp) >= 3: + sensor_byte = resp[2] + on_sensor = bool(sensor_byte & self._home_sensor_bitmask(axis)) + logger.info( + "Agile7612 homing W: reg 0x10 sensor byte=0x%02X -> %s", + sensor_byte, + "on sensor" if on_sensor else "off sensor", + ) + except BravoError: + logger.warning("Agile7612 homing W: reg 0x10 read failed, defaulting to 3-phase") + + if on_sensor: + logger.info("Agile7612 homing W: 2-phase (positive fast, negative slow)") + self._agile_7612_servo_write(0xA3, _SERVO_A3_INITIAL, axis) + self._agile_7612_servo_write(0xA4, _SERVO_A4_INITIAL, axis) + info = self._move_info_cls( + axis=axis, + position=large_ticks, + velocity=vel, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + else: + logger.info("Agile7612 homing W: 3-phase (negative fast, positive fast, negative slow)") + self._agile_7612_servo_write(0xA3, _SERVO_A3_INITIAL, axis) + self._agile_7612_servo_write(0xA4, _SERVO_A4_INITIAL, axis) + info = self._move_info_cls( + axis=axis, + position=-large_ticks, + velocity=vel, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + + self._agile_7612_servo_write(0xA3, _SERVO_A3_INITIAL, axis) + self._agile_7612_servo_write(0xA4, _SERVO_A4_INITIAL, axis) + info = self._move_info_cls( + axis=axis, + position=large_ticks, + velocity=vel, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + + self._agile_7612_servo_write(0xA4, _SERVO_A4_SWAPPED, axis) + self._agile_7612_servo_write(0xA3, _SERVO_A3_SWAPPED, axis) + info = self._move_info_cls( + axis=axis, + position=-large_ticks, + velocity=vel / 10.0, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + + try: + self._agile_7612_servo_write(0xA4, _SERVO_A4_RESET, axis) + except BravoError: + pass + try: + raw = bytearray(10) + raw[0] = 0x00 + raw[1] = _axis_bit(axis) + raw[7] = 0x52 + raw[9] = crc8_maxim(raw, 9) + self._send_agile(bytes(raw), axis) + except BravoError: + pass + try: + self._agile_7612_write_home_reg(axis, _HOME_REG_HOMED) + except BravoError: + pass + + info = self._move_info_cls( + axis=axis, + position=0.0, + velocity=vel, + acceleration=accel, + absolute_move=True, + check_for_homed=True, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + ctrl_base = 4 if axis in _CONTROLLER_2_AXES else 0 + raw = bytearray(10) + raw[0] = 0x00 + raw[1] = _axis_bit(axis) + raw[7] = 0x38 + raw[9] = crc8_maxim(raw, 9) + comm.send_command(CommandID.DIRECT_AGILE_COMMAND, bytes(raw) + struct.pack(" None: + """Home the G axis (gripper jaws). + + Controller 2, sensor at the negative end. Always uses the same + 2-phase pattern (positive fast, negative slow) regardless of starting + position. Moves G to 0 both before and after the homing sequence, + with a controller-2 fault reset around each move. + """ + comm = self._require_connected() + axis: Axis = "g" + vel, accel = self._homing_vel_accel(axis) + home_reg = self._home_reg_for_axis(axis) + large_ticks = _HOMING_DISTANCE_MM * self._ticks_per_unit.get(axis, 944.882) + + try: + self._agile_7612_agile_read(0x5E, axis) + except BravoError: + pass + try: + info = self._move_info_cls( + axis=axis, + position=0.0, + velocity=vel, + acceleration=accel, + absolute_move=True, + check_for_homed=True, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=30.0) + except BravoError as exc: + logger.warning("G homing: pre-move to 0 failed: %s", exc) + self._agile_7612_fault_reset_ctrl2() + + try: + self._agile_7612_write_home_reg(axis, _HOME_REG_ENABLE) + except BravoError: + pass + + self._agile_7612_servo_config_for_homing(axis) + + try: + resp = self._agile_7612_ext_read(0x10, axis) + if len(resp) >= 3: + logger.info("Agile7612 homing G: reg 0x10 byte=0x%02X (ignored -- always 2-phase)", resp[2]) + except BravoError: + pass + + logger.info("Agile7612 homing G: 2-phase (positive fast, negative slow)") + + self._agile_7612_servo_write(0xA3, _SERVO_A3_INITIAL, axis) + self._agile_7612_servo_write(0xA4, _SERVO_A4_INITIAL, axis) + info = self._move_info_cls( + axis=axis, + position=large_ticks, + velocity=vel, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + + self._agile_7612_servo_write(0xA4, _SERVO_A4_SWAPPED, axis) + self._agile_7612_servo_write(0xA3, _SERVO_A3_SWAPPED, axis) + info = self._move_info_cls( + axis=axis, + position=-large_ticks, + velocity=vel / 10.0, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + + try: + self._agile_7612_servo_write(0xA4, _SERVO_A4_RESET, axis) + except BravoError: + pass + try: + raw = bytearray(10) + raw[0] = 0x00 + raw[1] = _axis_bit(axis) + raw[7] = 0x52 + raw[9] = crc8_maxim(raw, 9) + self._send_agile(bytes(raw), axis) + except BravoError: + pass + try: + self._agile_7612_write_home_reg(axis, _HOME_REG_HOMED) + except BravoError: + pass + + try: + self._agile_7612_agile_read(0x5E, axis) + except BravoError: + pass + try: + info = self._move_info_cls( + axis=axis, + position=0.0, + velocity=vel, + acceleration=accel, + absolute_move=True, + check_for_homed=True, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=30.0) + except BravoError as exc: + logger.warning("G homing: post-move to 0 failed: %s", exc) + self._agile_7612_fault_reset_ctrl2() + + self._homed[axis] = True + self._capture_home_position(axis) + logger.info("Axis G homed") + + def _home_zg(self) -> None: + """Home the Zg axis (gripper vertical travel). + + Controller 2, sensor at the top. Moves G to 0 first (a pre-move, with + its own controller-2 fault reset), then always uses the same 2-phase + pattern (positive fast/depart down, negative slow/approach up). Parks + at -20mm (the nesting/docking position) once homed. + """ + comm = self._require_connected() + axis: Axis = "zg" + vel, accel = self._homing_vel_accel(axis) + home_reg = self._home_reg_for_axis(axis) + large_ticks = _HOMING_DISTANCE_MM * self._ticks_per_unit.get(axis, 787.402) + + try: + self._agile_7612_agile_read(0x5E, "g") + except BravoError: + pass + try: + g_home_reg = self._home_reg_for_axis("g") + g_vel, g_accel = self._homing_vel_accel("g") + info = self._move_info_cls( + axis="g", + position=0.0, + velocity=g_vel, + acceleration=g_accel, + absolute_move=True, + check_for_homed=True, + home_complete_register=g_home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go(["g"]) + self._agile_7612_wait_for_settled(["g"], timeout=30.0) + except BravoError as exc: + logger.warning("Zg homing: G pre-move failed: %s", exc) + self._agile_7612_fault_reset_ctrl2() + + try: + self._agile_7612_write_home_reg(axis, _HOME_REG_ENABLE) + except BravoError: + pass + + self._agile_7612_servo_config_for_homing(axis) + + try: + resp = self._agile_7612_ext_read(0x10, axis) + if len(resp) >= 3: + logger.info( + "Agile7612 homing Zg: reg 0x10 byte=0x%02X (ignored -- always 2-phase)", resp[2] + ) + except BravoError: + pass + + logger.info("Agile7612 homing Zg: 2-phase (positive fast, negative slow)") + + self._agile_7612_servo_write(0xA3, _SERVO_A3_INITIAL, axis) + self._agile_7612_servo_write(0xA4, _SERVO_A4_INITIAL, axis) + info = self._move_info_cls( + axis=axis, + position=large_ticks, + velocity=vel, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + + self._agile_7612_servo_write(0xA4, _SERVO_A4_SWAPPED, axis) + self._agile_7612_servo_write(0xA3, _SERVO_A3_SWAPPED, axis) + info = self._move_info_cls( + axis=axis, + position=-large_ticks, + velocity=vel / 10.0, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=60.0) + + try: + self._agile_7612_servo_write(0xA4, _SERVO_A4_RESET, axis) + except BravoError: + pass + try: + raw = bytearray(10) + raw[0] = 0x00 + raw[1] = _axis_bit(axis) + raw[7] = 0x52 + raw[9] = crc8_maxim(raw, 9) + self._send_agile(bytes(raw), axis) + except BravoError: + pass + try: + self._agile_7612_write_home_reg(axis, _HOME_REG_HOMED) + except BravoError: + pass + + park_ticks = -20.0 * self._ticks_per_unit.get(axis, 787.402) + info = self._move_info_cls( + axis=axis, + position=park_ticks, + velocity=vel, + acceleration=accel, + absolute_move=True, + check_for_homed=True, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + ctrl_base = 4 if axis in _CONTROLLER_2_AXES else 0 + raw = bytearray(10) + raw[0] = 0x00 + raw[1] = _axis_bit(axis) + raw[7] = 0x38 + raw[9] = crc8_maxim(raw, 9) + comm.send_command(CommandID.DIRECT_AGILE_COMMAND, bytes(raw) + struct.pack(" None: + """Move an axis to absolute position 0, requiring it to already be homed. + + Args: + axis: The axis to move. + """ + vel_mms, accel_mms2 = self._speed_for_level(axis, "slow") + self.move( + [ + AxisMoveInfo( + axis=axis, position=0.0, velocity=vel_mms, acceleration=accel_mms2, absolute=True + ) + ] + ) + + def home_axes(self, axes: list[Axis], *, force: bool = False) -> None: + """Home axes in a fixed, byte-exact sequence. + + Homes in the order Z (safety clearance first), Zg, G, X, then Y, then + W -- each axis fully homed in turn, not overlapped. Faults are cleared + on every axis first. + + Args: + axes: The axes to home. + force: Unused. Homing always runs unconditionally for the requested + axes. + """ + self.reset_faults(list(ALL_AXES)) + ctrl1_main = [a for a in axes if a in ("x", "y", "z", "w")] + ctrl2_axes = [a for a in axes if a in ("g", "zg")] + + if "z" in ctrl1_main: + self._home_z() + + if "zg" in ctrl2_axes: + self._home_zg() + + if "g" in ctrl2_axes: + self._home_g() + + xy_axes = [a for a in ctrl1_main if a in ("x", "y")] + if "x" in xy_axes: + self._home_x() + if "y" in xy_axes: + self._home_y() + + if "w" in ctrl1_main: + self._home_w() + + # ================================================================= + # Force-controlled jog (tip pickup only, not UI jog) + # ================================================================= + + def jog(self, params: JogParams) -> float: + """Execute a force-controlled jog via ``CMD_PREPARE_JOG`` plus a 0x80 trigger. + + Used only for tip pickup, where Z descends with a force limit until + tips engage. Interactive jog moves go through :meth:`move` instead. + + Args: + params: The jog parameters. + + Returns: + The axis's final position, in mm. + + Raises: + BravoError: If the axis has not been homed. + """ + axis = params.axis + if not self._homed[axis]: + raise BravoError( + ErrorType.COULD_NOT_MOVE_TO_POSITION, + custom_text=f"{axis_display_name(axis)} axis not initialized; home before jogging.", + ) + comm = self._require_connected() + + current_pos = self.get_position(axis) + if current_pos >= params.max_position: + logger.warning( + "jog: %s already at %.3f, past max_position %.3f -- skipping", + axis_display_name(axis), + current_pos, + params.max_position, + ) + return current_pos + + home_reg = self._home_reg_for_axis(axis) + try: + self._agile_7612_servo_write(0x23, bytes(7), axis) + except BravoError: + pass + payload = struct.pack("H", home_reg) + payload += struct.pack(" 0 and final_pos > params.max_position + params.tolerance: + logger.warning( + "jog: %s final position %.3f exceeds max_position %.3f + tolerance %.3f", + axis_display_name(axis), + final_pos, + params.max_position, + params.tolerance, + ) + + try: + comm.send_command(CommandID.QUERY_JOG_STATUS, timeout=1.0) + except (BravoError, TimeoutError): + pass + return final_pos + + def tip_force_jog(self, axis: Axis, peak_current: float, max_position: float) -> float: + """Force-controlled jog for tip-pickup experimentation. + + Kept separate from :meth:`jog` so this does not affect the working + jog. Returns ``max_position`` rather than a freshly-read position, + because a Controller 1 axis's raw position register wraps at 16 bits + and cannot be trusted after a long force jog. + + Args: + axis: The axis to jog. + peak_current: Current limit for the force-controlled phase, in amps. + max_position: The position limit the jog will not move past, in mm. + + Returns: + ``max_position``. + + Raises: + BravoError: If the axis has not been homed. + """ + if not self._homed[axis]: + raise BravoError( + ErrorType.COULD_NOT_MOVE_TO_POSITION, + custom_text=f"{axis_display_name(axis)} axis not initialized; home before jogging.", + ) + comm = self._require_connected() + + current_pos = self.get_position(axis) + if current_pos >= max_position: + logger.warning( + "tip_force_jog: %s at %.3f, past max %.3f -- skipping", + axis_display_name(axis), + current_pos, + max_position, + ) + return current_pos + + home_reg = self._home_reg_for_axis(axis) + approach_mm = 8.0 + + approach_target = max_position - approach_mm + if approach_target > current_pos: + logger.info("tip_force_jog: approaching Z=%.1f before force jog", approach_target) + safe_vel, safe_accel = self._speed_for_level(axis, "safe") + self.move( + [ + AxisMoveInfo( + axis=axis, + position=approach_target, + velocity=safe_vel, + acceleration=safe_accel, + ) + ], + wait=True, + ) + + vel = self._vel_to_ticks_per_ms(axis, 10.0) + accel = self._accel_to_ticks_per_ms2(axis, 100.0) + target_ticks = self._to_ticks(axis, max_position) + + logger.warning( + "tip_force_jog: %s force jog %.1f -> %.1f mm (%.0f ticks), peak=%.3fA", + axis_display_name(axis), + approach_target, + max_position, + target_ticks, + peak_current, + ) + + info = self._move_info_cls( + axis=axis, + position=target_ticks, + velocity=vel, + acceleration=accel, + absolute_move=True, + check_for_homed=True, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + + time.sleep(0.4) + + try: + self._agile_7612_servo_write(0x02, bytes.fromhex("4ccccccc001000"), axis) + except BravoError: + pass + try: + self._agile_7612_servo_write(0x23, bytes(7), axis) + except BravoError: + pass + try: + self._agile_7612_servo_write(0x23, bytes.fromhex("00000000001000"), axis) + except BravoError: + pass + + payload = struct.pack("H", home_reg) + payload += struct.pack("= 3: + break + else: + stable_count = 0 + prev_raw = raw + except (BravoError, TimeoutError, ConnectionError): + pass + time.sleep(0.1) + + self._tracked_position[axis] = max_position + + try: + comm.send_command(CommandID.QUERY_JOG_STATUS, timeout=1.0) + except (BravoError, TimeoutError): + pass + return max_position + + # ================================================================= + # Gripper -- retry logic + # ================================================================= + + _GRIP_RETRIES = 4 + _GRIP_RETRY_DELAYS = [0.2, 0.3, 0.4, 0.6] + _DETECT_GRIPPER_RETRIES = 4 + _DETECT_GRIPPER_DELAYS = [0.2, 0.3, 0.6, 1.0] + + def detect_gripper(self) -> GripperDetectionState: + comm = self._require_connected() + for attempt in range(self._DETECT_GRIPPER_RETRIES): + try: + data = comm.send_command(CommandID.DETECT_GRIPPER, timeout=2.0) + if len(data) >= 1 and data[0] != 0: + return GripperDetectionState(data[0]) + except (BravoError, TimeoutError): + pass + if attempt < self._DETECT_GRIPPER_RETRIES - 1: + time.sleep(self._DETECT_GRIPPER_DELAYS[attempt]) + return GripperDetectionState.NOT_DETECTED + + def grip(self, speed: SpeedLevel, position: float, grip_lid: bool = False) -> None: + comm = self._require_connected() + vel, accel = self._speed_for_level("g", speed) + home_reg = self._home_reg_for_axis("g") + info = self._move_info_cls( + axis="g", + position=self._to_ticks("g", position), + velocity=self._vel_to_ticks_per_ms("g", vel), + acceleration=self._accel_to_ticks_per_ms2("g", accel), + absolute_move=True, + check_for_homed=True, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go(["g"]) + grip_stalled = False + try: + self._agile_7612_wait_for_settled(["g"], timeout=3.0) + except BravoError: + grip_stalled = True + self._tracked_position["g"] = position + self._agile_7612_fault_reset_ctrl2() + if not grip_stalled: + raise BravoError( + ErrorType.COULD_NOT_MOVE_TO_POSITION, + custom_text="Gripper closed to target without resistance -- no plate detected", + ) + + # ================================================================= + # Motor control + # ================================================================= + + def get_park_position(self, axis: Axis) -> float: + """Return the axis's configured park position. + + Unlike the base class, which reports every axis parked at 0.0 (legacy + Agile hardware exposes no per-axis park metadata), this returns the + axis's configured ``homing_offset`` -- nonzero for Zg, which parks at + -20mm rather than at its home sensor. + + Args: + axis: The axis to look up. + + Returns: + The configured park position, in mm. + """ + return self._axis_config[axis].homing_offset + + def open_gripper(self, position: Optional[float] = None) -> None: + comm = self._require_connected() + target = 0.0 if position is None else float(position) + vel, accel = self._speed_for_level("g", "safe") + home_reg = self._home_reg_for_axis("g") + info = self._move_info_cls( + axis="g", + position=self._to_ticks("g", target), + velocity=self._vel_to_ticks_per_ms("g", vel), + acceleration=self._accel_to_ticks_per_ms2("g", accel), + absolute_move=True, + check_for_homed=True, + home_complete_register=home_reg, + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go(["g"]) + self._agile_7612_wait_for_settled(["g"], timeout=30.0) + self._tracked_position["g"] = target + self._agile_7612_fault_reset_ctrl2() + + def enable_motor(self, axis: Axis) -> None: + logger.debug("Agile7612: enable_motor(%s) no-op", axis_label(axis)) + + def disable_motor(self, axis: Axis) -> None: + logger.debug("Agile7612: disable_motor(%s) no-op", axis_label(axis)) + + def is_motor_enabled(self, axis: Axis) -> bool: + return self._homed[axis] + + def _is_estop_engaged(self) -> bool: + try: + state = self.query_state() + return bool(state & DeviceStateFlag.ROBOT_DISABLE) + except Exception: # noqa: BLE001 - any query failure means "cannot confirm safe", not crash + return False + + def recover(self, axes: Optional[list[Axis]] = None) -> dict[Axis, str]: + """Clear faults and mark axes unhomed, so the next move re-homes them. + + Args: + axes: The axes to recover. Defaults to every axis. + + Returns: + A dict from axis to ``"enabled"``, for every recovered axis. + + Raises: + BravoError: If the E-stop is still engaged. + """ + if axes is None: + axes = list(ALL_AXES) + if self._is_estop_engaged(): + raise BravoError( + ErrorType.ROBOT_DISABLE, + custom_text="Cannot recover: E-stop still engaged. Release E-stop and retry.", + ) + self.reset_faults(axes) + for a in axes: + self._homed[a] = False + self._home_raw.clear() + self._tracked_position.clear() + return {a: "enabled" for a in axes} + + def read_plate_sensor(self, transient: float = 0.0) -> bool: + logger.debug("Agile7612: read_plate_sensor() not supported, returning False") + return False + + def scan_stack_with_gripper( + self, + *, + start_zg: float, + end_zg: float, + speed: SpeedLevel, + transient: float = 0.0, + ) -> dict[str, Union[float, bool, None]]: + raise BravoError( + ErrorType.DARWIN_GENERIC, + custom_text="scan_stack_with_gripper is not supported on Agile 7612 hardware", + ) + + def set_head_type(self, head_type: HeadType) -> None: + """Record the installed head type, for callers that report it elsewhere. + + Args: + head_type: The head type to report from now on. + """ + self._head_type = head_type + logger.info("Agile7612: head type set to %s", head_type) + + def get_head_type(self) -> HeadType: + """Return the head type most recently set with :meth:`set_head_type`.""" + return self._head_type + + def read_head_identification(self) -> dict[str, object]: + """Return head-identification data. + + Returns: + A dict with ``eeprom_byte``, ``adc_counts``, and ``has_smart_head``. + Agile 7612 hardware exposes none of these, so the values are always + ``None``, ``0``, and ``False``. + """ + return {"eeprom_byte": None, "adc_counts": 0, "has_smart_head": False} + + def reset_faults(self, axes: list[Axis]) -> None: + for axis in axes: + raw = bytearray(10) + raw[0] = 0x00 + raw[1] = _axis_bit(axis) + raw[7] = 0x31 + raw[9] = crc8_maxim(raw, 9) + try: + self._send_agile(bytes(raw), axis) + except BravoError: + pass + + def detect_smart_head(self) -> bool: + return False + + def read_smart_head_type(self) -> int: + return 0 + + def clear_go_button(self) -> None: + try: + super().clear_go_button() + except (BravoError, TimeoutError): + logger.debug("Agile7612: clear_go_button not acknowledged") + + def read_head_adc(self) -> int: + return 0 diff --git a/pylabrobot/agilent/bravo/controllers/agile_golden_frame_tests.py b/pylabrobot/agilent/bravo/controllers/agile_golden_frame_tests.py new file mode 100644 index 00000000000..71bbd5e2e28 --- /dev/null +++ b/pylabrobot/agilent/bravo/controllers/agile_golden_frame_tests.py @@ -0,0 +1,339 @@ +"""Golden-frame tests: byte-for-byte wire output against a checked-in fixture. + +``testdata/agile_golden_frames.json`` holds ``(command_id, payload_hex)`` +sequences: the expected byte-level output for each scenario, captured from a +reference implementation of ``Agile7612Controller`` and ``AgileSrtController`` +driven through a recording fake comm layer. Every test here drives the same +call against these controllers through an equivalent recording fake, with an +explicit ``axis_config`` chosen to configure both trees identically (real +per-axis speeds and home-sensor bitmasks, rather than each tree's own +no-profile fallback, so a mismatch here is a genuine packet-building bug and +not one of the three documented default-configuration differences), and +asserts the captured sequence matches the fixture exactly. The fixture is +checked in so a change in packet content, field order, or phase sequencing +fails immediately. + +This is what actually exercises the per-axis homing routines, jog, +tip_force_jog, grip, and the underlying packet builders end to end -- unit +tests on individual helper methods do not catch a wrong byte inside a +19-command homing sequence the way a full recorded comparison does. +""" + +from __future__ import annotations + +import json +import struct +import time +import unittest +from pathlib import Path + +from pylabrobot.agilent.bravo.axis_config import default_axis_config +from pylabrobot.agilent.bravo.controllers.agile_7612 import Agile7612Controller +from pylabrobot.agilent.bravo.controllers.agile_srt import AgileSrtController +from pylabrobot.agilent.bravo.controllers.base import AxisMoveInfo, JogParams +from pylabrobot.agilent.bravo.errors import BravoError +from pylabrobot.agilent.bravo.protocol.v11_comm_tests import BufferedTransport +from pylabrobot.agilent.bravo.types import ALL_AXES + +_GOLDEN_PATH = Path(__file__).parent / "testdata" / "agile_golden_frames.json" +with open(_GOLDEN_PATH) as _f: + GOLDEN: dict = json.load(_f) + +# The per-axis fallback bitmask this port's controllers fall back to when an +# axis's home_flag_bitmask is left at its 0 default. Setting it explicitly +# here (rather than leaving it at 0) makes the source's own *direct* profile +# read produce the same on/off-sensor branching as this port's fallback, so +# the fixture's on-sensor and off-sensor scenarios are actually reachable +# and comparable in both trees. +_HOME_FLAG_BITMASK: dict = {"x": 1, "y": 2, "z": 4, "w": 8, "g": 1, "zg": 2} + + +def _matching_axis_config() -> dict: + """Build an axis_config mapping that configures both trees identically. + + Every field but home_flag_bitmask already matches the fixture's fake + profile through default_axis_config's own values (real per-axis speeds + and ranges, the shared W ticks-per-uL constant); only the bitmask needs + overriding away from its 0 default. + """ + config = {} + for axis in ALL_AXES: + cfg = default_axis_config(axis) + cfg.home_flag_bitmask = _HOME_FLAG_BITMASK[axis] + config[axis] = cfg + return config + + +class RecordingComm: + """Fake comm layer: records every ``(command_id, payload_hex)`` sent. + + Response content is inert except for register 0x10 (home-sensor state) + reads, whose on/off-sensor byte a test controls directly, and status + reads, which always report settled so ``_agile_7612_wait_for_settled`` + returns on its first poll instead of looping. + """ + + def __init__(self) -> None: + self.calls: list[tuple[int, str]] = [] + self.is_connected = True + self.sensor_byte = 0xFF + self.command_counts: dict = {} + self.error_log: list = [] + + @property + def transport(self) -> "RecordingComm": + return self + + def drain(self) -> int: + return 0 + + def send_command(self, command_id, data: bytes = b"", timeout: float = 2.0) -> bytes: + self.calls.append((int(command_id), data.hex())) + if len(data) > 1 and data[1] == 0x10: + return bytes([0x00, 0x00, self.sensor_byte, 0x00, 0x00, 0x00, 0x00, 0x00]) + if len(data) > 7 and data[0] == 0x00 and data[7] == 0x90: + return bytes([0x00, 0x00, 0xB0, 0xB0, 0xB0, 0xB0, 0x00, 0x00, 0x00, 0x00]) + if len(data) > 1 and data[0] == 0x09 and data[1] == 0x90: + return bytes([0x00, 0x00, 0x55, 0x2A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + return bytes(10) + + +def _new_controller(cls, sensor_byte: int = 0xFF) -> tuple[Agile7612Controller, RecordingComm]: + controller = cls(BufferedTransport(), axis_config=_matching_axis_config()) + comm = RecordingComm() + comm.sensor_byte = sensor_byte + controller._comm = comm + for axis in ALL_AXES: + controller._homed[axis] = True + return controller, comm + + +def _run(cls, sensor_byte: int, action) -> list[tuple[int, str]]: + controller, comm = _new_controller(cls, sensor_byte) + try: + action(controller) + except (BravoError, NotImplementedError): + pass + return comm.calls + + +class GoldenFrameTestCase(unittest.TestCase): + """Base class: silences real sleeps so polling loops (jog, tip_force_jog) run fast.""" + + def setUp(self) -> None: + self._real_sleep = time.sleep + time.sleep = lambda *_a, **_k: None + + def tearDown(self) -> None: + time.sleep = self._real_sleep + + def assert_matches_golden(self, scenario: str, calls: list) -> None: + expected = [tuple(pair) for pair in GOLDEN[scenario]] + self.assertEqual(calls, expected, f"{scenario}: captured frames diverge from golden") + + +class Agile7612HomingGoldenTests(GoldenFrameTestCase): + def test_home_x_on_sensor(self): + calls = _run(Agile7612Controller, 0xFF, lambda c: c._home_x()) + self.assert_matches_golden("agile7612_home_x_on_sensor", calls) + + def test_home_x_off_sensor(self): + calls = _run(Agile7612Controller, 0x00, lambda c: c._home_x()) + self.assert_matches_golden("agile7612_home_x_off_sensor", calls) + + def test_home_y_on_sensor(self): + calls = _run(Agile7612Controller, 0xFF, lambda c: c._home_y()) + self.assert_matches_golden("agile7612_home_y_on_sensor", calls) + + def test_home_y_off_sensor(self): + calls = _run(Agile7612Controller, 0x00, lambda c: c._home_y()) + self.assert_matches_golden("agile7612_home_y_off_sensor", calls) + + def test_home_z_on_sensor(self): + calls = _run(Agile7612Controller, 0xFF, lambda c: c._home_z()) + self.assert_matches_golden("agile7612_home_z_on_sensor", calls) + + def test_home_z_off_sensor(self): + calls = _run(Agile7612Controller, 0x00, lambda c: c._home_z()) + self.assert_matches_golden("agile7612_home_z_off_sensor", calls) + + def test_home_w_on_sensor(self): + calls = _run(Agile7612Controller, 0xFF, lambda c: c._home_w()) + self.assert_matches_golden("agile7612_home_w_on_sensor", calls) + + def test_home_w_off_sensor(self): + calls = _run(Agile7612Controller, 0x00, lambda c: c._home_w()) + self.assert_matches_golden("agile7612_home_w_off_sensor", calls) + + def test_home_g(self): + calls = _run(Agile7612Controller, 0xFF, lambda c: c._home_g()) + self.assert_matches_golden("agile7612_home_g", calls) + + def test_home_zg(self): + calls = _run(Agile7612Controller, 0xFF, lambda c: c._home_zg()) + self.assert_matches_golden("agile7612_home_zg", calls) + + def test_home_axes_order(self): + calls = _run(Agile7612Controller, 0xFF, lambda c: c.home_axes(list(ALL_AXES))) + self.assert_matches_golden("agile7612_home_axes_order", calls) + + +class Agile7612MotionGoldenTests(GoldenFrameTestCase): + def test_move(self): + def action(c): + c.move( + [ + AxisMoveInfo(axis="x", position=100.0, velocity=50.0, acceleration=100.0, absolute=True), + AxisMoveInfo(axis="g", position=2.0, velocity=10.0, acceleration=50.0, absolute=True), + ], + wait=True, + ) + + calls = _run(Agile7612Controller, 0xFF, action) + self.assert_matches_golden("agile7612_move", calls) + + def test_jog(self): + def action(c): + c.jog( + JogParams( + axis="z", + velocity=5.0, + acceleration=20.0, + max_position=50.0, + tolerance=1.0, + peak_current=0.2, + ) + ) + + calls = _run(Agile7612Controller, 0xFF, action) + self.assert_matches_golden("agile7612_jog", calls) + + def test_tip_force_jog(self): + calls = _run(Agile7612Controller, 0xFF, lambda c: c.tip_force_jog("z", 0.15, 30.0)) + self.assert_matches_golden("agile7612_tip_force_jog", calls) + + def test_grip(self): + calls = _run(Agile7612Controller, 0xFF, lambda c: c.grip("slow", 3.0)) + self.assert_matches_golden("agile7612_grip", calls) + + +class MoveOriginOffsetTests(GoldenFrameTestCase): + """Zg parks at firmware -20mm (hardcoded, independent of any axis_config), so an + absolute move to Zg is the one case in this default configuration where + _move_origin is nonzero -- exercising it directly, since none of the golden + move scenarios happen to touch Zg. + """ + + def test_absolute_move_to_zg_subtracts_the_firmware_park_offset(self): + controller, comm = _new_controller(Agile7612Controller) + controller.move( + [AxisMoveInfo(axis="zg", position=10.0, velocity=25.0, acceleration=250.0, absolute=True)], + wait=True, + ) + + prepare_move_calls = [hexdata for cid, hexdata in comm.calls if cid == 0xA2] + self.assertEqual(len(prepare_move_calls), 1) + payload = bytes.fromhex(prepare_move_calls[0]) + position_ticks = struct.unpack_from(" bytes: + # Register 0x07 reads (raw position) get a deliberately asymmetric + # big-endian value; everything else keeps RecordingComm's normal + # canned responses. + if len(data) > 1 and data[1] == 0x07: + comm.calls.append((int(command_id), data.hex())) + return bytes([0x00, 0x00, 0x12, 0x34, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + return real_send_command(command_id, data, timeout) + + comm.send_command = send_command # type: ignore[method-assign] + + position = controller.get_position("x") + + # _read_raw_position's own documented formula for a controller-1 axis: + # float(raw_be_u16) / (ticks_per_eng_unit * scale / 2.0), scale=16.0 for X. + raw_be_u16 = 0x1234 + ticks_per_eng_unit = controller._ticks_per_unit["x"] + expected = float(raw_be_u16) / (ticks_per_eng_unit * 16.0 / 2.0) + self.assertAlmostEqual(position, expected) + # A little-endian misreading of the same two bytes (0x3412) would give a + # visibly different result, so this also fails if the byte order flips. + wrong_le = float(0x3412) / (ticks_per_eng_unit * 16.0 / 2.0) + self.assertNotAlmostEqual(position, wrong_le) + + def test_controller_2_axis_decodes_sign_and_magnitude(self): + controller, comm = _new_controller(Agile7612Controller) + real_send_command = comm.send_command + + def send_command(command_id, data: bytes = b"", timeout: float = 2.0) -> bytes: + if len(data) > 1 and data[1] == 0x07: + comm.calls.append((int(command_id), data.hex())) + # High bit set (sign) + magnitude 0x0100 in the low 15 bits. + return bytes([0x00, 0x00, 0x81, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + return real_send_command(command_id, data, timeout) + + comm.send_command = send_command # type: ignore[method-assign] + + position = controller.get_position("g") + + eff_tpu = controller._CTRL2_EFFECTIVE_TPU.get("g", 126.8) + expected = -1.0 * float(0x0100) * 2.0 / eff_tpu + self.assertAlmostEqual(position, expected) + self.assertLess(position, 0.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/controllers/agile_srt.py b/pylabrobot/agilent/bravo/controllers/agile_srt.py new file mode 100644 index 00000000000..4205423fbbf --- /dev/null +++ b/pylabrobot/agilent/bravo/controllers/agile_srt.py @@ -0,0 +1,488 @@ +"""Agile controller for the Bravo SRT, a gripperless four-axis variant. + +The Bravo SRT speaks the same wire protocol as the Agile 7612 generation: +V11 framing with the command byte before the length, CRC-8/MAXIM, 10-byte +Agile packets carried in ``CMD_DIRECT_AGILE_COMMAND`` frames, the 17-byte +``CMD_PREPARE_MOVE`` payload, and the same controller-verify exchange. + +It differs from the Agile 7612 in what hardware it drives: + +- No gripper: only X, Y, Z, W. :attr:`AgileSrtController.has_gripper` is + False, and the gripper-related :class:`~.base.BravoController` methods + raise :class:`NotImplementedError` naming the model. +- Four servo controllers indexed 0-3 (X, Y, Z, W), not the Agile 7612's two + (0 and 4). Homing servo headers are 0x00/0x10/0x20/0x30. +- Homing order is Z, W, X, Y. +- The ``home_complete_register`` field in ``CMD_PREPARE_MOVE`` is encoded + as ``0x01nn``. +- The W (pipettor) axis needs a pump-parameter pre-configuration block + before its homing servo configuration, plus a distinct register-0xA0 + value. + +The homing routine here emits a byte-exact frame sequence for a cold, +un-homed start; keep every constant exact when touching it, since even a +structurally-equivalent rewrite can produce a checksum byte the firmware +silently rejects. Jog and the Agile 7612's sensor-adaptive per-axis homers +are not reused here -- their move parameters and servo constants are tuned +for different hardware. +""" + +from __future__ import annotations + +import logging +import struct +from typing import NamedTuple, NoReturn, Optional, Union + +from ..errors import BravoError, ErrorType +from ..protocol.agile_7612_crc import crc8_maxim +from ..protocol.commands import CommandID +from ..types import Axis, GripperDetectionState, SpeedLevel +from .agile import _axis_bit +from .agile_7612 import ( + _HOME_REG_ENABLE, + _HOME_REG_HOMED, + _SERVO_A3_INITIAL, + _SERVO_A3_SWAPPED, + _SERVO_A4_INITIAL, + _SERVO_A4_RESET, + _SERVO_A4_SWAPPED, + Agile7612Controller, + _home_reg_register, +) +from .base import JogParams + +logger = logging.getLogger(__name__) + +_SRT_AXES: frozenset[Axis] = frozenset({"x", "y", "z", "w"}) +_SRT_HOME_ORDER: tuple[Axis, ...] = ("z", "w", "x", "y") +_SRT_HOME_TIMEOUT = 60.0 # seconds + + +def _f32(hex4: str) -> float: + """Decode a little-endian float32 from a 4-byte hex string. + + Storing homing move parameters as their raw encoded form guarantees that + re-packing them reproduces the exact bytes the firmware expects, with no + float round-tripping error. + + Args: + hex4: An 8-character hex string encoding 4 bytes. + + Returns: + The decoded float. + """ + return float(struct.unpack(" NoReturn: + """Raise, naming this model, for an operation that needs a gripper. + + Args: + operation: The name of the unsupported operation. + + Raises: + NotImplementedError: Always. + """ + raise NotImplementedError(f"{self.model_name} has no gripper; {operation} is not available.") + + # ================================================================= + # Homing + # ================================================================= + + def _home_reg_for_axis(self, axis: Axis) -> int: + """Return the ``home_complete_register`` field for ``CMD_PREPARE_MOVE``. + + The SRT encodes this field as ``0x01nn``, where ``nn`` is the same + per-axis register the Agile 7612 generation uses directly. + """ + return 0x0100 | _home_reg_register(axis) + + def home_axes(self, axes: list[Axis], *, force: bool = False) -> None: + """Home the given axes in safety order: Z, W, X, Y. + + Args: + axes: The axes to home. Must be a subset of X, Y, Z, W. + force: Unused. Homing always runs unconditionally for the requested + axes. + + Raises: + BravoError: If ``axes`` includes G or Zg, which this SRT has no + hardware for. + """ + unsupported = sorted({a for a in axes} - _SRT_AXES) + if unsupported: + raise BravoError( + ErrorType.COULD_NOT_HOME, + custom_text=( + f"{self.model_name} has no {', '.join(unsupported)} axis (this SRT has no gripper)." + ), + ) + requested = set(axes) + # Clear faults on the X/Y/Z controllers before homing (header 0x00, + # axis bitmask, byte 7 = 0x31). + for axis in ("x", "y", "z"): + if axis in requested: + self._srt_axis_op(0x31, axis) + for axis in _SRT_HOME_ORDER: + if axis in requested: + logger.info("SRT homing %s", axis) + self._srt_home_axis(axis) + + def _srt_axis_op(self, byte7: int, axis: Axis, data: bytes = b"") -> None: + """Send a header-0x00 axis-bitmask op (fault reset / trigger / marker). + + Args: + byte7: The subtype byte to place at packet offset 7. + axis: The axis this op targets. + data: Up to 5 bytes to place at packet offset 2. + """ + raw = bytearray(10) + raw[0] = 0x00 + raw[1] = _axis_bit(axis) + for i, b in enumerate(data[:5]): + raw[2 + i] = b + raw[7] = byte7 & 0xFF + raw[9] = crc8_maxim(raw, 9) + self._send_agile(bytes(raw), axis) + + def _srt_servo_config(self, axis: Axis) -> None: + """Write the six homing servo registers (A0, AD, AE, AF, B0, BD).""" + spec = _SRT_HOMING[axis] + ab = spec["axis_byte"] + ae_b0 = bytes.fromhex("40000000") + bytes([ab]) + bytes.fromhex("1000") + for reg, data in ( + (0xA0, spec["a0"]), + (0xAD, bytes.fromhex("488000000c1000")), + (0xAE, ae_b0), + (0xAF, bytes.fromhex("00000000001000")), + (0xB0, ae_b0), + (0xBD, bytes.fromhex("00000000001000")), + ): + self._agile_7612_servo_write(reg, data, axis) + + def _srt_w_pump_preconfig(self) -> None: + """Write the W pump-parameter pre-config block, sent twice as the firmware expects.""" + for _ in range(2): + for step in _SRT_W_PUMP_BLOCK: + if step.kind == "reg": + self._agile_7612_servo_write(step.reg_or_byte7, bytes.fromhex(step.hex_data), "w") + else: + self._srt_axis_op(step.reg_or_byte7, "w") + + def _srt_home_move(self, axis: Axis, position: float, velocity: float, accel: float) -> None: + """Send one ``CMD_PREPARE_MOVE`` homing-search phase and wait for it to settle. + + Args: + axis: The axis to move. + position: The move distance, in ticks, relative to the axis's + current position. + velocity: The move velocity, in ticks/ms. + accel: The move acceleration, in ticks/ms^2. + """ + comm = self._require_connected() + info = self._move_info_cls( + axis=axis, + position=position, + velocity=velocity, + acceleration=accel, + absolute_move=False, + check_for_homed=False, + home_complete_register=self._home_reg_for_axis(axis), + ) + comm.send_command(CommandID.PREPARE_MOVE, info.pack()) + self._agile_7612_move_go([axis]) + self._agile_7612_wait_for_settled([axis], timeout=_SRT_HOME_TIMEOUT) + + def _srt_read_home_sensor(self, axis: Axis) -> bool: + """Read register 0x10 to check whether an axis is on its home sensor. + + The home-sensor state selects the homing phase pattern. The bit per + axis is X=0x01, Y=0x02, Z=0x04, W=0x08 (the same as :func:`_axis_bit`). + The SRT's axis configuration leaves ``home_flag_bitmask`` at its zero + default, so that field cannot be used here. + + Args: + axis: The axis to read. + + Returns: + True if the axis is currently on its home sensor. + """ + try: + resp = self._agile_7612_ext_read(0x10, axis) + except BravoError: + logger.warning("SRT homing %s: 0x10 read failed; assuming off-sensor", axis) + return False + if len(resp) < 3: + return False + on_sensor = bool(resp[2] & _axis_bit(axis)) + logger.info( + "SRT homing %s: 0x10 sensor byte=0x%02X -> %s", + axis, + resp[2], + "on sensor (2-phase)" if on_sensor else "off sensor (3-phase)", + ) + return on_sensor + + def _srt_home_axis(self, axis: Axis) -> None: + """Home one SRT axis from a cold, un-homed start. + + Reads register 0x4A, enables the home register, writes the homing + servo configuration, reads the home-sensor state (register 0x10) to + pick the phase pattern, runs the search/approach move phases (each + preceded by an A3/A4 servo set, with the final precision phase using + the swapped set), latches the homing-complete marker, and writes the + home register HOMED. + + Phase pattern depends on the home-sensor state: + + - On sensor: 2-phase -- depart fast, then slow approach back. + - Off sensor: 3-phase -- approach fast, depart overshoot, slow + approach. + + W additionally needs the pump-parameter pre-config block first. + + Args: + axis: The axis to home. + """ + spec = _SRT_HOMING[axis] + + if axis == "w": + self._srt_w_pump_preconfig() + self._srt_safe_agile_read(0x4A, axis) + self._srt_axis_op(0x30, axis) + + self._srt_safe_agile_read(0x4A, axis) + self._srt_safe_write_home_reg(axis, _HOME_REG_ENABLE) + self._srt_servo_config(axis) + + depart = spec["depart"] + if self._srt_read_home_sensor(axis): + moves = [(depart, "fast"), (-depart, "slow")] + else: + moves = [(-depart, "fast"), (depart, "fast"), (-depart, "slow")] + + for idx, (sign, speed) in enumerate(moves): + is_final = idx == len(moves) - 1 + if is_final: + self._agile_7612_servo_write(0xA4, _SERVO_A4_SWAPPED, axis) + self._agile_7612_servo_write(0xA3, _SERVO_A3_SWAPPED, axis) + else: + self._agile_7612_servo_write(0xA3, _SERVO_A3_INITIAL, axis) + self._agile_7612_servo_write(0xA4, _SERVO_A4_INITIAL, axis) + velocity = spec["v_slow"] if speed == "slow" else spec["v_fast"] + self._srt_home_move(axis, sign * spec["pos"], velocity, spec["accel"]) + + try: + self._agile_7612_servo_write(0xA4, _SERVO_A4_RESET, axis) + except BravoError: + pass + try: + self._srt_axis_op(0x52, axis) # homing-complete marker (empty data) + except BravoError: + pass + self._srt_safe_write_home_reg(axis, _HOME_REG_HOMED) + + self._homed[axis] = True + self._capture_home_position(axis) + logger.info("Axis %s homed", axis) + + def _srt_safe_agile_read(self, register: int, axis: Axis) -> None: + """Read a register, discarding any error. + + Args: + register: The register to read. + axis: The axis to read it from. + """ + try: + self._agile_7612_agile_read(register, axis) + except BravoError: + pass + + def _srt_safe_write_home_reg(self, axis: Axis, data: bytes) -> None: + """Write an axis's home-complete register, discarding any error. + + Args: + axis: The axis whose register to write. + data: The 7-byte register value. + """ + try: + self._agile_7612_write_home_reg(axis, data) + except BravoError: + pass + + def _agile_7612_fault_reset_ctrl2(self) -> None: + """Do nothing: this SRT has no controller 2 (no G/Zg).""" + return + + # ================================================================= + # Jog -- not yet implemented for the SRT + # ================================================================= + + def jog(self, params: JogParams) -> float: + """Execute a force-controlled jog move. + + Raises: + BravoError: Always. Force-controlled jog is not implemented for the + Bravo SRT (its move parameters and servo constants are tuned for + the Agile 7612, not this hardware). + """ + raise BravoError( + ErrorType.COULD_NOT_MOVE_TO_POSITION, + custom_text=f"Jog is not yet implemented for the {self.model_name}.", + ) + + # ================================================================= + # Gripper -- this SRT has none + # ================================================================= + + def detect_gripper(self) -> GripperDetectionState: + """Return whether the gripper accessory is currently detected. + + Raises: + NotImplementedError: Always. This SRT has no gripper. + """ + self._no_gripper("detect_gripper") + + def grip(self, speed: SpeedLevel, position: float, grip_lid: bool = False) -> None: + """Close the gripper jaws to the given position. + + Raises: + NotImplementedError: Always. This SRT has no gripper. + """ + self._no_gripper("grip") + + def open_gripper(self, position: Optional[float] = None) -> None: + """Open the gripper jaws. + + Raises: + NotImplementedError: Always. This SRT has no gripper. + """ + self._no_gripper("open_gripper") + + def is_plate_in_gripper(self) -> bool: + """Return whether a plate is currently held in the gripper. + + Raises: + NotImplementedError: Always. This SRT has no gripper. + """ + self._no_gripper("is_plate_in_gripper") + + def scan_stack_with_gripper( + self, + *, + start_zg: float, + end_zg: float, + speed: SpeedLevel, + transient: float = 0.0, + ) -> dict[str, Union[float, bool, None]]: + """Scan the Zg axis between two heights until the plate sensor detects a stack top. + + Raises: + NotImplementedError: Always. This SRT has no gripper. + """ + self._no_gripper("scan_stack_with_gripper") diff --git a/pylabrobot/agilent/bravo/controllers/agile_tests.py b/pylabrobot/agilent/bravo/controllers/agile_tests.py new file mode 100644 index 00000000000..0ea291d70b0 --- /dev/null +++ b/pylabrobot/agilent/bravo/controllers/agile_tests.py @@ -0,0 +1,468 @@ +import inspect +import struct +import unittest + +from pylabrobot.agilent.bravo.axis_config import AxisConfig, default_axis_config +from pylabrobot.agilent.bravo.controllers.agile import AgileController +from pylabrobot.agilent.bravo.controllers.agile_7612 import Agile7612Controller +from pylabrobot.agilent.bravo.controllers.agile_srt import AgileSrtController +from pylabrobot.agilent.bravo.controllers.base import AxisMoveInfo, BravoController, JogParams +from pylabrobot.agilent.bravo.errors import BravoError, ErrorType, RabbitErrorCode +from pylabrobot.agilent.bravo.protocol.agile_packet import ( + AGILE_PACKET_SIZE, + UNIQUE_VALUE_EXPECTED, + crc8, +) +from pylabrobot.agilent.bravo.protocol.commands import CommandID +from pylabrobot.agilent.bravo.protocol.v11_comm_tests import BufferedTransport +from pylabrobot.agilent.bravo.types import ALL_AXES, DEFAULT_W_TICKS_PER_UL + + +def _v11_frame(error_code: int, data: bytes = b"") -> bytes: + """Build a legacy-Agile V11 response frame: ``[length_u16][error][data]``.""" + payload = bytes([error_code]) + data + return struct.pack(" bytes: + """Build an Agile 7612 V11 response frame: ``[cmd][length_u16][error][data]``.""" + payload = bytes([error_code]) + data + return struct.pack(" bytes: + """Build a 10-byte legacy-Agile reply packet with a valid CRC-8/SMBUS checksum. + + The register value lands where ``AgileReply.get_register_value`` reads it: + 4 bytes at absolute packet offset 4. + """ + pkt = bytearray(AGILE_PACKET_SIZE) + pkt[0] = 0x01 + pkt[1] = 0x00 + struct.pack_into(" bytes: + """Build the 10-byte payload ``Agile7612Controller._verify_controller`` expects. + + Unlike the legacy Agile reply, this is read directly with + ``struct.unpack_from(" BufferedTransport: + fw_frame = _v11_7612_frame(CommandID.QUERY_VERSION, RabbitErrorCode.NONE, version) + verify_frame = _v11_7612_frame( + CommandID.DIRECT_AGILE_COMMAND, RabbitErrorCode.NONE, _agile_7612_verify_packet(0x2A55) + ) + return BufferedTransport(fw_frame + verify_frame) + + def test_agile_controller_initialize_clears_stale_homed_state(self): + fw_frame = _v11_frame(RabbitErrorCode.NONE, b"1.2.3\x00") + verify_frame = _v11_frame(RabbitErrorCode.NONE, _agile_reply_packet(UNIQUE_VALUE_EXPECTED)) + controller = AgileController(BufferedTransport(fw_frame + verify_frame)) + controller._homed["x"] = True + + controller.initialize() + + self.assertFalse(controller._homed["x"]) + + def test_agile_7612_controller_initialize_clears_stale_homed_and_tracked_state(self): + controller = Agile7612Controller(self._handshake_transport()) + controller._homed["x"] = True + controller._home_raw["x"] = 12345.0 + controller._tracked_position["x"] = 42.0 + + controller.initialize() + + self.assertFalse(controller._homed["x"]) + self.assertEqual(controller._home_raw, {}) + self.assertEqual(controller._tracked_position, {}) + + +class AxisConfigDefaultsTests(unittest.TestCase): + def test_no_axis_config_uses_defaults_for_every_axis(self): + controller = Agile7612Controller(BufferedTransport()) + for axis in ALL_AXES: + self.assertEqual(controller._axis_config[axis], default_axis_config(axis)) + + def test_srt_also_defaults_every_axis_with_no_axis_config(self): + controller = AgileSrtController(BufferedTransport()) + for axis in ALL_AXES: + self.assertEqual(controller._axis_config[axis], default_axis_config(axis)) + + def test_explicit_axis_config_overrides_the_given_axis(self): + override = AxisConfig( + axis="zg", + ticks_per_eng_unit=999.0, + range=default_axis_config("zg").range, + homing_offset=-20.0, + home_complete_register=0x5F, + ) + controller = Agile7612Controller(BufferedTransport(), axis_config={"zg": override}) + + self.assertEqual(controller._axis_config["zg"].home_complete_register, 0x5F) + self.assertEqual(controller._axis_config["zg"].homing_offset, -20.0) + self.assertEqual(controller._ticks_per_unit["zg"], 999.0) + + def test_axis_config_override_does_not_touch_other_axes(self): + override = AxisConfig( + axis="zg", ticks_per_eng_unit=999.0, range=default_axis_config("zg").range + ) + controller = Agile7612Controller(BufferedTransport(), axis_config={"zg": override}) + + for axis in ALL_AXES: + if axis != "zg": + self.assertEqual(controller._axis_config[axis], default_axis_config(axis)) + self.assertEqual(controller._ticks_per_unit["w"], DEFAULT_W_TICKS_PER_UL) + + def test_w_ticks_per_unit_is_48_when_every_axis_is_given_its_own_default(self): + # Regression: building the axis_config mapping explicitly (rather than + # omitting it) must not silently change the W scale. Both paths through + # AxisConfig now resolve to the same DEFAULT_W_TICKS_PER_UL constant, so + # there is no longer a distinction between "axis missing from the + # mapping" and "axis present with its own default". + explicit = {axis: default_axis_config(axis) for axis in ALL_AXES} + controller = Agile7612Controller(BufferedTransport(), axis_config=explicit) + self.assertEqual(controller._ticks_per_unit["w"], 48.0) + + +class SrtGripperlessTests(unittest.TestCase): + def setUp(self): + self.controller = AgileSrtController(BufferedTransport()) + + def test_detect_gripper_raises_not_implemented_naming_the_model(self): + with self.assertRaises(NotImplementedError) as ctx: + self.controller.detect_gripper() + self.assertIn("Bravo SRT", str(ctx.exception)) + + def test_grip_raises_not_implemented_naming_the_model(self): + with self.assertRaises(NotImplementedError) as ctx: + self.controller.grip("slow", 5.0) + self.assertIn("Bravo SRT", str(ctx.exception)) + + def test_open_gripper_raises_not_implemented_naming_the_model(self): + with self.assertRaises(NotImplementedError) as ctx: + self.controller.open_gripper() + self.assertIn("Bravo SRT", str(ctx.exception)) + + def test_is_plate_in_gripper_raises_not_implemented_naming_the_model(self): + with self.assertRaises(NotImplementedError) as ctx: + self.controller.is_plate_in_gripper() + self.assertIn("Bravo SRT", str(ctx.exception)) + + def test_scan_stack_with_gripper_raises_not_implemented_naming_the_model(self): + with self.assertRaises(NotImplementedError) as ctx: + self.controller.scan_stack_with_gripper(start_zg=0.0, end_zg=10.0, speed="slow") + self.assertIn("Bravo SRT", str(ctx.exception)) + + def test_home_axes_rejects_gripper_axes(self): + with self.assertRaises(BravoError): + self.controller.home_axes(["g"]) + + def test_home_axes_rejects_gripper_axes_naming_the_model(self): + with self.assertRaises(BravoError) as ctx: + self.controller.home_axes(["zg"]) + self.assertIn("Bravo SRT", str(ctx.exception)) + + def test_jog_not_yet_implemented(self): + params = JogParams( + axis="x", velocity=1.0, acceleration=1.0, max_position=10.0, tolerance=0.1, peak_current=0.1 + ) + with self.assertRaises(BravoError): + self.controller.jog(params) + + +class MoveTests(unittest.TestCase): + def test_agile_controller_move_sends_prepare_move_and_move_go(self): + frame = _v11_frame(RabbitErrorCode.NONE) * 2 + transport = BufferedTransport(frame) + controller = AgileController(transport) + + controller.move( + [AxisMoveInfo(axis="x", position=10.0, velocity=50.0, acceleration=100.0)], wait=False + ) + + # One PREPARE_MOVE, plus one MoveGo for controller 1. + self.assertEqual(len(transport.sent), 2) + + def test_agile_7612_controller_move_requires_axis_homed(self): + controller = Agile7612Controller(BufferedTransport()) + + with self.assertRaises(BravoError) as ctx: + controller.move([AxisMoveInfo(axis="x", position=10.0)]) + self.assertEqual(ctx.exception.error_type, ErrorType.COULD_NOT_MOVE_TO_POSITION) + + def test_agile_7612_controller_move_sends_bytes_once_homed(self): + frame = _v11_7612_frame(CommandID.PREPARE_MOVE, RabbitErrorCode.NONE) + _v11_7612_frame( + CommandID.DIRECT_AGILE_COMMAND, RabbitErrorCode.NONE + ) + transport = BufferedTransport(frame) + controller = Agile7612Controller(transport) + controller._homed["x"] = True + + controller.move( + [AxisMoveInfo(axis="x", position=10.0, velocity=50.0, acceleration=100.0)], wait=False + ) + + # PREPARE_MOVE and the per-axis trigger both went out; a trailing + # fault-reset attempt (which the empty rest of the buffer times out) may + # add more, so this only checks the floor. + self.assertGreaterEqual(len(transport.sent), 2) + + def test_agile_7612_controller_move_rejects_out_of_range_target(self): + controller = Agile7612Controller(BufferedTransport()) + controller._homed["x"] = True + + with self.assertRaises(BravoError) as ctx: + controller.move([AxisMoveInfo(axis="x", position=99_999.0)]) + self.assertEqual(ctx.exception.error_type, ErrorType.COULD_NOT_MOVE_TO_POSITION) + + +class ProfileReplacementLogicTests(unittest.TestCase): + """Exercises the typed AxisConfig reads that replaced the profile reflection.""" + + def test_home_reg_for_axis_reads_the_configured_value(self): + cfg = default_axis_config("z") + cfg.home_complete_register = 0x0160 + controller = Agile7612Controller(BufferedTransport(), axis_config={"z": cfg}) + self.assertEqual(controller._home_reg_for_axis("z"), 0x0160) + + def test_home_reg_for_axis_default_is_zero(self): + controller = Agile7612Controller(BufferedTransport()) + self.assertEqual(controller._home_reg_for_axis("x"), 0) + + def test_home_sensor_bitmask_falls_back_to_the_per_axis_default_when_unset(self): + controller = Agile7612Controller(BufferedTransport()) + self.assertEqual(controller._home_sensor_bitmask("x"), 1) + self.assertEqual(controller._home_sensor_bitmask("y"), 2) + self.assertEqual(controller._home_sensor_bitmask("z"), 4) + self.assertEqual(controller._home_sensor_bitmask("w"), 8) + self.assertEqual(controller._home_sensor_bitmask("g"), 1) + self.assertEqual(controller._home_sensor_bitmask("zg"), 2) + + def test_home_sensor_bitmask_uses_the_configured_value_when_set(self): + cfg = default_axis_config("x") + cfg.home_flag_bitmask = 0x40 + controller = Agile7612Controller(BufferedTransport(), axis_config={"x": cfg}) + self.assertEqual(controller._home_sensor_bitmask("x"), 0x40) + + def test_homing_depart_direction_uses_the_configured_flag(self): + cfg = default_axis_config("x") + cfg.home_in_positive_direction = True + controller = Agile7612Controller(BufferedTransport(), axis_config={"x": cfg}) + self.assertEqual(controller._homing_depart_direction("x"), -1) + self.assertEqual(controller._homing_depart_direction("y"), 1) + + def test_speed_for_level_uses_the_configured_profile(self): + controller = Agile7612Controller(BufferedTransport()) + self.assertEqual(controller._speed_for_level("x", "fast"), (400.0, 2000.0)) + + def test_speed_for_level_falls_back_when_the_level_is_missing(self): + cfg = default_axis_config("x") + cfg.speeds = {} + controller = Agile7612Controller(BufferedTransport(), axis_config={"x": cfg}) + self.assertEqual(controller._speed_for_level("x", "fast"), (50.0, 100.0)) + + def test_get_park_position_reads_the_configured_homing_offset(self): + cfg = default_axis_config("zg") + cfg.homing_offset = -20.0 + controller = Agile7612Controller(BufferedTransport(), axis_config={"zg": cfg}) + self.assertEqual(controller.get_park_position("zg"), -20.0) + + def test_srt_home_reg_for_axis_sets_the_0x0100_bit(self): + cfg = default_axis_config("z") + cfg.home_complete_register = 0x60 + controller = AgileSrtController(BufferedTransport(), axis_config={"z": cfg}) + self.assertEqual(controller._home_reg_for_axis("z"), 0x0160) + + +class DiagnosticsTests(unittest.TestCase): + def test_get_diagnostics_reports_disconnected(self): + controller = Agile7612Controller(BufferedTransport(connected=False)) + self.assertEqual(controller.get_diagnostics(), {"connected": False}) + + def test_get_diagnostics_reports_command_counts_and_errors(self): + frame = _v11_7612_frame(CommandID.PING_DEVICE, RabbitErrorCode.NONE) + controller = Agile7612Controller(BufferedTransport(frame)) + + controller.send_command(CommandID.PING_DEVICE) + diag = controller.get_diagnostics() + + self.assertTrue(diag["connected"]) + command_counts = diag["command_counts"] + assert isinstance(command_counts, dict) + self.assertEqual(command_counts["PING_DEVICE"], 1) + self.assertEqual(diag["error_count"], 0) + + +class DrainDelegationTests(unittest.TestCase): + def test_drain_tcp_buffer_delegates_to_transport_drain(self): + controller = Agile7612Controller(BufferedTransport()) + # Must not raise: Transport.drain() is unconditional on the ABC. + controller._drain_tcp_buffer() + + +class TimeoutUnitsAreSecondsTests(unittest.TestCase): + def test_agile_controller_move_default_timeout_is_30_seconds(self): + default = inspect.signature(AgileController.move).parameters["timeout"].default + self.assertEqual(default, 30.0) + self.assertIsInstance(default, float) + + def test_agile_7612_controller_move_default_timeout_is_30_seconds(self): + default = inspect.signature(Agile7612Controller.move).parameters["timeout"].default + self.assertEqual(default, 30.0) + self.assertIsInstance(default, float) + + def test_send_agile_default_timeout_is_2_seconds(self): + default = inspect.signature(AgileController._send_agile).parameters["timeout"].default + self.assertEqual(default, 2.0) + self.assertIsInstance(default, float) + + def test_ping_uses_a_one_second_timeout(self): + # A regression that reintroduces a millisecond-scale value (e.g. 1000 + # instead of 1.0) here would be a thousand-fold unit error. + frame = _v11_frame(RabbitErrorCode.NONE) + transport = BufferedTransport(frame, connected=True) + controller = AgileController(transport) + self.assertTrue(controller.ping()) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/controllers/base.py b/pylabrobot/agilent/bravo/controllers/base.py new file mode 100644 index 00000000000..728fd5a3769 --- /dev/null +++ b/pylabrobot/agilent/bravo/controllers/base.py @@ -0,0 +1,399 @@ +"""Abstract controller interface for the Bravo. + +The interface that every Bravo controller implements, whether it drives real +hardware over a transport or simulates the instrument in software. Anything +that operates the Bravo goes through this interface, so a new backend only +has to satisfy these methods. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Optional, Union + +from ..errors import BravoError +from ..protocol.commands import LightCommandData +from ..transport import Transport +from ..types import Axis, DeviceStateFlag, GripperDetectionState, HeadType, SpeedLevel + + +@dataclass +class AxisMoveInfo: + """A single axis's target in a coordinated move, in engineering units. + + Attributes: + axis: The axis to move. + position: Target position, in mm (or uL for the W axis). + velocity: Move velocity, in mm/s. ``0`` leaves the controller's current + velocity setting unchanged. + acceleration: Move acceleration, in mm/s^2. ``0`` leaves the + controller's current acceleration setting unchanged. + absolute: Whether ``position`` is an absolute target (``True``) or a + relative offset from the axis's current position (``False``). + """ + + axis: Axis + position: float + velocity: float = 0.0 + acceleration: float = 0.0 + absolute: bool = True + + +@dataclass +class MultiAxisMove: + """A coordinated move across one or more axes. + + Attributes: + moves: The per-axis targets to move to together. + wait_for_complete: Whether to block until the move finishes. + timeout: Maximum time to wait for the move to finish, in seconds. + """ + + moves: list[AxisMoveInfo] = field(default_factory=list) + wait_for_complete: bool = True + timeout: float = 30.0 + + +@dataclass +class JogParams: + """Parameters for a force-controlled jog move. + + Attributes: + axis: The axis to jog. + velocity: Jog velocity, in mm/s. + acceleration: Jog acceleration, in mm/s^2. + max_position: The position limit the jog will not move past, in mm. + tolerance: Position tolerance for detecting that the jog has stalled + against an obstruction, in mm. + peak_current: Current limit for the jog move, in amps, written directly + to the peak-current register. For a tips-on jog, interpolate this by + tip count from the appropriate tip-current table rather than passing + a fixed value. + """ + + axis: Axis + velocity: float + acceleration: float + max_position: float + tolerance: float + peak_current: float + + +@dataclass +class FirmwareVersion: + """Firmware version information reported by the device. + + Attributes: + master: The master processor's firmware version string. + sub1: The first sub-controller's firmware version string. + sub2: The second sub-controller's firmware version string. + """ + + master: str = "" + sub1: str = "" + sub2: str = "" + + +class BravoController(ABC): + """Abstract interface for controlling a Bravo liquid handler. + + A controller is constructed around an already-connected + :class:`~pylabrobot.agilent.bravo.transport.Transport`; it never opens or + closes that transport itself. Call :meth:`initialize` once the transport + is up to bring the controller into a usable state before issuing any + motion or state-changing commands. + + Attributes: + has_gripper: Whether this controller's hardware has a gripper + accessory. A subclass without one should still implement every + gripper method the interface declares, typically by raising + :class:`NotImplementedError` naming the model, rather than omitting + them -- callers can check this flag first to avoid the exception + entirely. + model_name: The human-readable model name, used in diagnostic and + error messages. + """ + + has_gripper: bool = True + model_name: str = "Bravo" + + def __init__(self, transport: Transport): + """Create a controller bound to an already-connected transport. + + Args: + transport: The transport this controller communicates over. The + caller is responsible for setting it up before construction and + tearing it down afterwards. + """ + self._transport = transport + + # -- Lifecycle -- + + @abstractmethod + def initialize(self) -> None: + """Bring the controller into a usable state. + + Performs whatever generation-specific startup the hardware needs before + it will accept motion or state-changing commands -- for example + commutation or homing -- using the transport supplied to the + constructor. Called once, after the transport has been set up. + """ + + def deinitialize(self) -> None: + """Release whatever generation-specific resources :meth:`initialize` acquired. + + The counterpart to :meth:`initialize`: stops any background work a + subclass started (for example a wire-protocol engine's receive thread) + without touching the transport itself, which the caller still owns and + may tear down or reuse afterward. The default implementation is a no-op, + for subclasses with nothing to release. + """ + + @abstractmethod + def ping(self) -> bool: + """Return whether the device responds to a liveness check.""" + + @property + @abstractmethod + def is_connected(self) -> bool: + """Whether the underlying transport is currently connected.""" + + # -- Firmware -- + + @abstractmethod + def get_firmware_version(self) -> FirmwareVersion: + """Return the device's reported firmware version.""" + + # -- Motion -- + + @abstractmethod + def move(self, moves: list[AxisMoveInfo], wait: bool = True, timeout: float = 30.0) -> None: + """Execute a coordinated multi-axis move. + + Args: + moves: The per-axis targets to move to together. + wait: Whether to block until the move finishes. + timeout: Maximum time to wait for the move to finish, in seconds. + """ + + @abstractmethod + def home_axes(self, axes: list[Axis], *, force: bool = False) -> None: + """Home one or more axes. + + Args: + axes: The axes to home. + force: Re-runs the homing routine on an axis that already reports + itself homed. Backends that always home unconditionally may ignore + it. Use it for an explicit operator "home this axis" request, where + doing nothing because the axis looks homed is the wrong answer. + """ + + @abstractmethod + def jog(self, params: JogParams) -> float: + """Execute a force-controlled jog. + + Args: + params: The jog parameters. + + Returns: + The axis's final position, in mm. + """ + + @abstractmethod + def get_position(self, axis: Axis) -> float: + """Return the current position of an axis, in engineering units (mm or uL).""" + + @abstractmethod + def is_axis_homed(self, axis: Axis) -> bool: + """Return whether an axis currently reports itself homed.""" + + @abstractmethod + def get_park_position(self, axis: Axis) -> float: + """Return the configured park position for an axis, in mm.""" + + # -- Motor control -- + + @abstractmethod + def enable_motor(self, axis: Axis) -> None: + """Enable the motor drive for an axis.""" + + @abstractmethod + def disable_motor(self, axis: Axis) -> None: + """Disable the motor drive for an axis.""" + + @abstractmethod + def reset_faults(self, axes: list[Axis]) -> None: + """Clear any latched fault state on the given axes.""" + + # -- Device state -- + + @abstractmethod + def query_state(self) -> DeviceStateFlag: + """Return the device's current state flags.""" + + @abstractmethod + def is_go_button_pressed(self) -> bool: + """Return whether the Go button is currently pressed.""" + + @abstractmethod + def clear_go_button(self) -> None: + """Clear the latched Go-button-pressed state.""" + + # -- Lights -- + + @abstractmethod + def set_light(self, command: LightCommandData) -> None: + """Set the indicator light to the given color, blink period, and duty cycle.""" + + @abstractmethod + def clear_lights(self) -> None: + """Turn the indicator light off.""" + + # -- Head detection -- + + @abstractmethod + def read_head_adc(self) -> int: + """Read the raw ADC value used for resistor-based head detection.""" + + @abstractmethod + def detect_smart_head(self) -> bool: + """Return whether a smart head (with onboard PIC/EEPROM) is present.""" + + @abstractmethod + def read_smart_head_type(self) -> int: + """Read the head type code stored in the smart head's EEPROM.""" + + def get_head_type(self) -> HeadType: + """Return the head type this controller currently has cached. + + A caller that needs to know the installed head but was not itself + given one (for example a task constructed without an explicit + ``head_type`` argument) falls back to this. The default is the + all-around common head; a controller that tracks a detected or + assigned head type overrides this to return it. + + Returns: + The cached head type, or ``"96_d_70"`` if this controller does not + track one. + """ + return "96_d_70" + + # -- Unit conversion -- + + def ul_to_mm(self, volume_ul: float) -> float: + """Convert a pipette volume, in microliters, to W-axis millimetres. + + The W (plunger) axis's native engineering unit varies by controller + generation: some express W positions directly in microliters, others + in the millimetres of physical plunger travel that volume requires + for the currently installed head. This lets a caller building a W-axis + move convert once, in a controller-appropriate way, instead of + special-casing the generation itself. + + Args: + volume_ul: The volume to convert, in microliters. + + Returns: + ``volume_ul`` unchanged, for a controller whose W axis is already + microliter-native. A controller whose W axis is millimetre-native + overrides this to apply its head-specific conversion factor. + """ + return volume_ul + + # -- Gripper -- + + @abstractmethod + def detect_gripper(self) -> GripperDetectionState: + """Return whether the gripper accessory is currently detected.""" + + @abstractmethod + def grip(self, speed: SpeedLevel, position: float, grip_lid: bool = False) -> None: + """Close the gripper jaws to the given position. + + Args: + speed: The speed profile to grip at. + position: Target jaw position, in mm. + grip_lid: Whether this grip is closing on a plate lid rather than a + plate body. + """ + + @abstractmethod + def open_gripper(self, position: Optional[float] = None) -> None: + """Open the gripper jaws. + + Args: + position: Target jaw position, in mm. Defaults to the standard open + position when omitted. + """ + + @abstractmethod + def is_plate_in_gripper(self) -> bool: + """Return whether a plate is currently held in the gripper.""" + + def read_plate_sensor(self, transient: float = 0.0) -> bool: + """Read the physical plate-presence sensor, where the hardware supports it. + + Args: + transient: How long to allow for a transient sensor reading to settle, + in seconds, before treating it as final. + + Returns: + Whether the sensor currently reports a plate present. + + Raises: + NotImplementedError: If this controller does not support direct + plate-sensor reads. + """ + raise NotImplementedError( + f"{self.__class__.__name__} does not support direct plate-sensor reads" + ) + + def scan_stack_with_gripper( + self, + *, + start_zg: float, + end_zg: float, + speed: SpeedLevel, + transient: float = 0.0, + ) -> dict[str, Union[float, bool, None]]: + """Scan the Zg axis between two heights until the plate sensor detects a stack top. + + Args: + start_zg: Zg position to start the scan from, in mm. + end_zg: Zg position to stop the scan at if nothing is detected, in mm. + speed: The speed profile to scan at. + transient: How long to allow for a transient sensor reading to settle, + in seconds, before treating it as final. + + Returns: + A dict describing the scan result, at minimum ``"detected"`` (bool) + and ``"final_zg"`` (float). + + Raises: + NotImplementedError: If this controller does not support gripper + stack scanning. + """ + raise NotImplementedError(f"{self.__class__.__name__} does not support gripper stack scanning") + + # -- Generic command dispatch -- + + @abstractmethod + def send_command(self, command_id: int, data: bytes = b"", timeout: float = 2.0) -> bytes: + """Send a low-level command directly, for extensibility beyond this interface. + + Args: + command_id: The wire command ID to send. + data: The command payload. + timeout: Maximum time to wait for a response, in seconds. + + Returns: + The response payload. + """ + + # -- Last error -- + + @property + @abstractmethod + def last_error(self) -> Optional[BravoError]: + """The most recent error this controller recorded, if any.""" diff --git a/pylabrobot/agilent/bravo/controllers/base_tests.py b/pylabrobot/agilent/bravo/controllers/base_tests.py new file mode 100644 index 00000000000..f3a87f69692 --- /dev/null +++ b/pylabrobot/agilent/bravo/controllers/base_tests.py @@ -0,0 +1,330 @@ +import inspect +import unittest + +from pylabrobot.agilent.bravo.controllers.base import ( + AxisMoveInfo, + BravoController, + FirmwareVersion, + JogParams, + MultiAxisMove, +) +from pylabrobot.agilent.bravo.errors import BravoError, ErrorType +from pylabrobot.agilent.bravo.protocol.commands import LightCommandData +from pylabrobot.agilent.bravo.transport.base import Transport +from pylabrobot.agilent.bravo.types import DeviceStateFlag, GripperDetectionState, LightColor + + +class FakeTransport(Transport): + def send(self, data: bytes) -> None: + pass + + def receive(self, timeout: float = 2.0) -> bytes: + return b"" + + def receive_exact(self, num_bytes: int, timeout: float = 2.0) -> bytes: + return b"" + + @property + def is_connected(self) -> bool: + return True + + +class ConcreteController(BravoController): + """Minimal concrete controller used only to exercise the base contract.""" + + def initialize(self) -> None: + pass + + def ping(self) -> bool: + return True + + @property + def is_connected(self) -> bool: + return self._transport.is_connected + + def get_firmware_version(self) -> FirmwareVersion: + return FirmwareVersion() + + def move(self, moves, wait: bool = True, timeout: float = 30.0) -> None: + pass + + def home_axes(self, axes, *, force: bool = False) -> None: + pass + + def jog(self, params: JogParams) -> float: + return 0.0 + + def get_position(self, axis) -> float: + return 0.0 + + def is_axis_homed(self, axis) -> bool: + return False + + def get_park_position(self, axis) -> float: + return 0.0 + + def enable_motor(self, axis) -> None: + pass + + def disable_motor(self, axis) -> None: + pass + + def reset_faults(self, axes) -> None: + pass + + def query_state(self) -> DeviceStateFlag: + return DeviceStateFlag(0) + + def is_go_button_pressed(self) -> bool: + return False + + def clear_go_button(self) -> None: + pass + + def set_light(self, command: LightCommandData) -> None: + pass + + def clear_lights(self) -> None: + pass + + def read_head_adc(self) -> int: + return 0 + + def detect_smart_head(self) -> bool: + return False + + def read_smart_head_type(self) -> int: + return 0 + + def detect_gripper(self) -> GripperDetectionState: + return GripperDetectionState.NOT_YET_DETECTED + + def grip(self, speed, position: float, grip_lid: bool = False) -> None: + pass + + def open_gripper(self, position=None) -> None: + pass + + def is_plate_in_gripper(self) -> bool: + return False + + def send_command(self, command_id: int, data: bytes = b"", timeout: float = 2.0) -> bytes: + return b"" + + @property + def last_error(self): + return None + + +class BravoControllerConstructionTests(unittest.TestCase): + def test_constructor_stores_the_given_transport(self): + transport = FakeTransport() + controller = ConcreteController(transport) + # is_connected is implemented (above) purely in terms of self._transport, + # so this only passes if the constructor actually stored the instance we + # passed in, not e.g. a fresh transport or None. + self.assertTrue(controller.is_connected) + + def test_constructor_does_not_connect_or_open_anything(self): + # A controller must not expose open_serial/open_tcp/close: connecting is + # entirely the transport's job, done before the controller exists. + self.assertNotIn("open_serial", dir(BravoController)) + self.assertNotIn("open_tcp", dir(BravoController)) + self.assertNotIn("close", dir(BravoController)) + + def test_incomplete_subclass_cannot_be_instantiated(self): + class Incomplete(BravoController): + def initialize(self) -> None: + pass + + with self.assertRaises(TypeError): + Incomplete(FakeTransport()) # type: ignore[abstract] + + def test_subclass_missing_initialize_cannot_be_instantiated(self): + # initialize() must be a required abstract method, not an optional hook: + # a subclass implementing every other method still can't be built. + class MissingInitialize(BravoController): + def ping(self) -> bool: + return True + + @property + def is_connected(self) -> bool: + return True + + def get_firmware_version(self) -> FirmwareVersion: + return FirmwareVersion() + + def move(self, moves, wait: bool = True, timeout: float = 30.0) -> None: + pass + + def home_axes(self, axes, *, force: bool = False) -> None: + pass + + def jog(self, params: JogParams) -> float: + return 0.0 + + def get_position(self, axis) -> float: + return 0.0 + + def is_axis_homed(self, axis) -> bool: + return False + + def get_park_position(self, axis) -> float: + return 0.0 + + def enable_motor(self, axis) -> None: + pass + + def disable_motor(self, axis) -> None: + pass + + def reset_faults(self, axes) -> None: + pass + + def query_state(self) -> DeviceStateFlag: + return DeviceStateFlag(0) + + def is_go_button_pressed(self) -> bool: + return False + + def clear_go_button(self) -> None: + pass + + def set_light(self, command: LightCommandData) -> None: + pass + + def clear_lights(self) -> None: + pass + + def read_head_adc(self) -> int: + return 0 + + def detect_smart_head(self) -> bool: + return False + + def read_smart_head_type(self) -> int: + return 0 + + def detect_gripper(self) -> GripperDetectionState: + return GripperDetectionState.NOT_YET_DETECTED + + def grip(self, speed, position: float, grip_lid: bool = False) -> None: + pass + + def open_gripper(self, position=None) -> None: + pass + + def is_plate_in_gripper(self) -> bool: + return False + + def send_command(self, command_id: int, data: bytes = b"", timeout: float = 2.0) -> bytes: + return b"" + + @property + def last_error(self): + return None + + with self.assertRaises(TypeError): + MissingInitialize(FakeTransport()) # type: ignore[abstract] + + def test_concrete_subclass_can_be_instantiated(self): + controller = ConcreteController(FakeTransport()) + controller.initialize() + self.assertTrue(controller.ping()) + + +class BravoControllerDefaultMethodTests(unittest.TestCase): + def setUp(self): + self.controller = ConcreteController(FakeTransport()) + + def test_read_plate_sensor_default_raises_not_implemented(self): + with self.assertRaises(NotImplementedError): + self.controller.read_plate_sensor() + + def test_scan_stack_with_gripper_default_raises_not_implemented(self): + with self.assertRaises(NotImplementedError): + self.controller.scan_stack_with_gripper(start_zg=0.0, end_zg=10.0, speed="slow") + + def test_get_head_type_default_is_96_d_70(self): + self.assertEqual(self.controller.get_head_type(), "96_d_70") + + def test_ul_to_mm_default_is_identity(self): + self.assertEqual(self.controller.ul_to_mm(37.5), 37.5) + + +class TimeoutUnitsAreSecondsTests(unittest.TestCase): + """Every timeout on the interface is seconds, not milliseconds. + + A regression that reintroduces a millisecond-scale default (e.g. 30000 + instead of 30.0) is a thousand-fold unit error, so these check the actual + declared defaults rather than just that the parameter exists. + """ + + def test_move_timeout_default_is_30_seconds(self): + default = inspect.signature(BravoController.move).parameters["timeout"].default + self.assertEqual(default, 30.0) + self.assertIsInstance(default, float) + + def test_send_command_timeout_default_is_2_seconds(self): + default = inspect.signature(BravoController.send_command).parameters["timeout"].default + self.assertEqual(default, 2.0) + self.assertIsInstance(default, float) + + def test_multi_axis_move_timeout_default_is_30_seconds(self): + move = MultiAxisMove() + self.assertEqual(move.timeout, 30.0) + self.assertIsInstance(move.timeout, float) + + def test_read_plate_sensor_transient_default_is_in_seconds(self): + default = inspect.signature(BravoController.read_plate_sensor).parameters["transient"].default + self.assertEqual(default, 0.0) + self.assertIsInstance(default, float) + + def test_scan_stack_with_gripper_transient_default_is_in_seconds(self): + params = inspect.signature(BravoController.scan_stack_with_gripper).parameters + default = params["transient"].default + self.assertEqual(default, 0.0) + self.assertIsInstance(default, float) + + +class DataclassDefaultsTests(unittest.TestCase): + def test_axis_move_info_defaults(self): + move = AxisMoveInfo(axis="x", position=12.5) + self.assertEqual(move.velocity, 0.0) + self.assertEqual(move.acceleration, 0.0) + self.assertTrue(move.absolute) + + def test_jog_params_requires_all_fields(self): + params = JogParams( + axis="z", + velocity=5.0, + acceleration=10.0, + max_position=100.0, + tolerance=0.5, + peak_current=0.1, + ) + self.assertEqual(params.axis, "z") + self.assertEqual(params.peak_current, 0.1) + + def test_firmware_version_defaults_to_empty_strings(self): + version = FirmwareVersion() + self.assertEqual(version.master, "") + self.assertEqual(version.sub1, "") + self.assertEqual(version.sub2, "") + + +class BravoErrorIntegrationTests(unittest.TestCase): + def test_last_error_type_is_bravo_error(self): + err = BravoError(ErrorType.NOT_HOMED, axis="x") + self.assertIsInstance(err, BravoError) + + def test_light_command_data_round_trips_through_set_light(self): + controller = ConcreteController(FakeTransport()) + command = LightCommandData(light=LightColor.RED) + # No exception is the whole contract here: set_light must accept a + # LightCommandData built from the shared types module. + controller.set_light(command) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/controllers/simulation.py b/pylabrobot/agilent/bravo/controllers/simulation.py new file mode 100644 index 00000000000..30d721d85a5 --- /dev/null +++ b/pylabrobot/agilent/bravo/controllers/simulation.py @@ -0,0 +1,370 @@ +"""Simulation controller for the Bravo. + +A software-only substitute for the Bravo hardware that lets protocols and +higher layers run without a physical instrument. No axis move, homing +operation, or head/gripper query touches any transport; positions and state +are tracked entirely in memory. +""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass +from typing import Optional, Union + +from ..errors import BravoError +from ..protocol.commands import LightCommandData +from ..types import ( + ALL_AXES, + OPEN_GRIPPER_POSITION, + Axis, + DeviceStateFlag, + GripperDetectionState, + HeadType, + SpeedLevel, + axis_display_name, + head_type_code, +) +from .base import AxisMoveInfo, BravoController, FirmwareVersion, JogParams + +logger = logging.getLogger(__name__) + + +@dataclass +class SimulatedAxis: + """In-memory state tracked for one simulated axis. + + Attributes: + position: Current position, in mm (or uL for the W axis). + homed: Whether the axis currently reports itself homed. + motor_enabled: Whether the axis's motor drive is enabled. + """ + + position: float = 0.0 + homed: bool = False + motor_enabled: bool = False + + +class SimulationController(BravoController): + """Software simulation of the Bravo hardware. + + Tracks axis positions and state in memory, with no transport and no I/O. + Every axis starts homed at its configured offset, so the simulated + coordinate system is teachpoint-anchored from construction onward, the + same as the coordinate system a deck model expects. :meth:`home_axes` + returns a moved axis to that offset; :meth:`initialize` does the same for + every axis, so it is idempotent immediately after construction and only + has an observable effect once something has moved an axis away from its + offset. + """ + + def __init__( + self, head_type: HeadType = "96_d_70", homing_offsets: Optional[dict[Axis, float]] = None + ): + """Create a simulated controller. + + Args: + head_type: The pipetting head to simulate as installed. Determines + what :meth:`read_smart_head_type` and :meth:`read_head_adc` report, + and can be changed later with :meth:`set_head_type`. + homing_offsets: The position each axis is homed to, keyed by axis. + An axis with no entry homes to ``0.0``. Every axis starts at its + homing offset, already homed, so the simulated coordinate system + matches the teachpoint coordinate system from the start. + """ + offsets: dict[Axis, float] = homing_offsets or {} + self._homing_offsets: dict[Axis, float] = offsets + self._axes: dict[Axis, SimulatedAxis] = { + axis: SimulatedAxis(position=offsets.get(axis, 0.0), homed=True) for axis in ALL_AXES + } + self._head_type = head_type + self._gripper_detected = GripperDetectionState.DETECTED + self._plate_in_gripper = False + self._plate_sensor_present = False + self._simulated_scan_height_mm: Optional[float] = None + self._last_error: Optional[BravoError] = None + self._lights: Optional[LightCommandData] = None + self._go_button_pressed = False + logger.info("SimulationController created (head_type=%s)", head_type) + + # -- Lifecycle -- + + def initialize(self) -> None: + """Bring the simulated controller to a usable state. + + Homes every axis. Every axis already starts homed at its configured + offset, so calling this immediately after construction changes + nothing; it only has an observable effect on an axis that has since + moved away from its offset. No transport is involved: this controller + performs no I/O. + """ + self.home_axes(list(ALL_AXES)) + logger.info("Simulation: initialize() complete, all axes homed") + + def ping(self) -> bool: + return True + + @property + def is_connected(self) -> bool: + return True + + # -- Firmware -- + + def get_firmware_version(self) -> FirmwareVersion: + return FirmwareVersion(master="1.2.3", sub1="", sub2="") + + # -- Motion -- + + def move(self, moves: list[AxisMoveInfo], wait: bool = True, timeout: float = 30.0) -> None: + max_duration = 0.0 + for m in moves: + ax = self._axes[m.axis] + old_pos = ax.position + if m.absolute: + ax.position = m.position + else: + ax.position += m.position + # A velocity-based duration is simulated so W-axis moves (aspirate, + # dispense, mix) take the wall-clock time their velocity implies, + # letting liquid-class velocity parameters produce an observable + # difference in simulation. + if m.velocity > 0 and wait: + distance = abs(ax.position - old_pos) + duration = distance / m.velocity + max_duration = max(max_duration, duration) + logger.debug( + "Simulation: move %s to %.3f (abs=%s, vel=%.3f, accel=%.3f)", + axis_display_name(m.axis), + ax.position, + m.absolute, + m.velocity, + m.acceleration, + ) + if max_duration > 0: + logger.info("Simulation: waiting %.2fs for move (velocity-based timing)", max_duration) + time.sleep(max_duration) + + def home_axes(self, axes: list[Axis], *, force: bool = False) -> None: + for axis in axes: + self._axes[axis].position = self._homing_offsets.get(axis, 0.0) + self._axes[axis].homed = True + logger.debug("Simulation: homed %s", axis_display_name(axis)) + + def jog(self, params: JogParams) -> float: + ax = self._axes[params.axis] + ax.position += params.max_position + logger.debug( + "Simulation: jog %s to %.3f (max_pos=%.3f)", + axis_display_name(params.axis), + ax.position, + params.max_position, + ) + return ax.position + + def get_position(self, axis: Axis) -> float: + return self._axes[axis].position + + def is_axis_homed(self, axis: Axis) -> bool: + return self._axes[axis].homed + + def get_park_position(self, axis: Axis) -> float: + return self._homing_offsets.get(axis, 0.0) + + # -- Motor control -- + + def enable_motor(self, axis: Axis) -> None: + self._axes[axis].motor_enabled = True + logger.debug("Simulation: enable_motor(%s)", axis_display_name(axis)) + + def disable_motor(self, axis: Axis) -> None: + self._axes[axis].motor_enabled = False + logger.debug("Simulation: disable_motor(%s)", axis_display_name(axis)) + + def reset_faults(self, axes: list[Axis]) -> None: + logger.debug("Simulation: reset_faults [no-op]") + + # -- Device state -- + + def query_state(self) -> DeviceStateFlag: + return DeviceStateFlag(0) + + def is_go_button_pressed(self) -> bool: + return self._go_button_pressed + + def clear_go_button(self) -> None: + self._go_button_pressed = False + + # -- Lights -- + + def set_light(self, command: LightCommandData) -> None: + self._lights = command + logger.debug("Simulation: set_light(%s)", command) + + def clear_lights(self) -> None: + self._lights = None + + # -- Head detection -- + + # ADC values for the head types known to the resistor-divider table. Other + # head types have no sourced value and fall back to the 96_d_70 reading. + _HEAD_ADC_VALUES: dict[HeadType, int] = { + "96_d_70": 2745, + "96_d_200": 2600, + "384_d_70": 2400, + "96_f_50": 2200, + "8_d_lt": 2000, + } + + def read_head_adc(self) -> int: + """Return the ADC reading for the configured head type. + + Returns: + The value from the ADC-to-resistance table for the five head types it + covers. Any other head type falls back to the 96_d_70 value (2745), + which is this table's own answer for a head type it has no reading + for, not a value specific to that head. + """ + return self._HEAD_ADC_VALUES.get(self._head_type, 2745) + + def detect_smart_head(self) -> bool: + return True + + def read_smart_head_type(self) -> int: + return head_type_code(self._head_type) + + # -- Gripper -- + + def detect_gripper(self) -> GripperDetectionState: + return self._gripper_detected + + def grip(self, speed: SpeedLevel, position: float, grip_lid: bool = False) -> None: + self._plate_in_gripper = True + self._axes["g"].position = position + logger.debug("Simulation: grip at position %.3f", position) + + def open_gripper(self, position: Optional[float] = None) -> None: + self._plate_in_gripper = False + self._axes["g"].position = OPEN_GRIPPER_POSITION if position is None else float(position) + logger.debug("Simulation: open_gripper") + + def is_plate_in_gripper(self) -> bool: + return self._plate_in_gripper + + def read_plate_sensor(self, transient: float = 0.0) -> bool: + return bool(self._plate_sensor_present) + + def scan_stack_with_gripper( + self, + *, + start_zg: float, + end_zg: float, + speed: SpeedLevel, + transient: float = 0.0, + ) -> dict[str, Union[float, bool, None]]: + self._axes["zg"].position = start_zg + if self._simulated_scan_height_mm is None: + self._axes["zg"].position = end_zg + self._plate_sensor_present = False + return { + "detected": False, + "final_zg": float(end_zg), + } + self._plate_sensor_present = True + detected_zg = float(start_zg) + float(self._simulated_scan_height_mm) + detected_zg = max(min(detected_zg, max(start_zg, end_zg)), min(start_zg, end_zg)) + self._axes["zg"].position = detected_zg + return { + "detected": True, + "final_zg": float(detected_zg), + "measured_height_mm": float(self._simulated_scan_height_mm), + } + + # -- Generic command -- + + def send_command(self, command_id: int, data: bytes = b"", timeout: float = 2.0) -> bytes: + logger.debug("Simulation: send_command(0x%02X) [no-op]", command_id) + return b"" + + # -- Error -- + + @property + def last_error(self) -> Optional[BravoError]: + return self._last_error + + # -- Simulation-specific -- + + def set_head_type(self, head_type: HeadType) -> None: + """Change the simulated head type. + + Args: + head_type: The head type to report from now on. + """ + self._head_type = head_type + logger.info("Simulation: head type changed to %s", head_type) + + def get_head_type(self) -> HeadType: + """Return the head type most recently set with :meth:`set_head_type`.""" + return self._head_type + + def set_go_button(self, pressed: bool) -> None: + """Simulate the Go button being pressed or released. + + Args: + pressed: The new Go-button state. + """ + self._go_button_pressed = pressed + + def set_gripper_state(self, detected: GripperDetectionState) -> None: + """Configure what :meth:`detect_gripper` reports. + + Args: + detected: The gripper detection state to report from now on. + """ + self._gripper_detected = detected + + def set_plate_sensor_present(self, present: bool) -> None: + """Configure what :meth:`read_plate_sensor` reports. + + Args: + present: Whether the simulated plate sensor should report a plate + present. + """ + self._plate_sensor_present = bool(present) + + def set_simulated_scan_height_mm(self, height_mm: Optional[float]) -> None: + """Configure the stack height :meth:`scan_stack_with_gripper` detects. + + Args: + height_mm: The simulated distance from the scan's start position to + the detected stack top, in mm. ``None`` simulates no detection: the + scan runs to ``end_zg`` without finding anything. + """ + self._simulated_scan_height_mm = None if height_mm is None else float(height_mm) + + def get_all_positions(self) -> dict[str, float]: + """Return every axis's position, keyed by its display name (e.g. ``"Zg"``). + + Returns: + A dict from axis display name to position, in mm (or uL for W). + """ + return {axis_display_name(axis): ax.position for axis, ax in self._axes.items()} + + def get_all_homed(self) -> dict[Axis, bool]: + """Return every axis's homed state. + + Returns: + A dict from axis to whether it currently reports itself homed. + """ + return {axis: ax.homed for axis, ax in self._axes.items()} + + def get_all_motor_enabled(self) -> dict[Axis, bool]: + """Return every axis's motor-enabled state. + + Returns: + A dict from axis to whether its motor drive is currently enabled. + """ + return {axis: ax.motor_enabled for axis, ax in self._axes.items()} + + def is_motor_enabled(self, axis: Axis) -> bool: + return self._axes[axis].motor_enabled diff --git a/pylabrobot/agilent/bravo/controllers/simulation_tests.py b/pylabrobot/agilent/bravo/controllers/simulation_tests.py new file mode 100644 index 00000000000..4d5ac7e82d0 --- /dev/null +++ b/pylabrobot/agilent/bravo/controllers/simulation_tests.py @@ -0,0 +1,220 @@ +import unittest + +from pylabrobot.agilent.bravo.controllers.base import AxisMoveInfo +from pylabrobot.agilent.bravo.controllers.simulation import SimulationController +from pylabrobot.agilent.bravo.types import ( + ALL_AXES, + GripperDetectionState, + HeadType, + head_type_code, +) + + +class ConnectionWithoutTransportTests(unittest.TestCase): + def test_reports_connected_immediately_with_no_transport_argument(self): + # SimulationController() takes no transport at all. + controller = SimulationController() + self.assertTrue(controller.is_connected) + self.assertTrue(controller.ping()) + + +class HomingTests(unittest.TestCase): + def test_axes_start_homed_at_zero_with_no_configured_offsets(self): + controller = SimulationController() + for axis in ALL_AXES: + self.assertTrue(controller.is_axis_homed(axis), f"{axis} should start homed") + self.assertEqual(controller.get_position(axis), 0.0) + + def test_homing_offsets_set_the_initial_homed_position(self): + controller = SimulationController(homing_offsets={"x": 12.3, "zg": -4.0}) + self.assertTrue(controller.is_axis_homed("x")) + self.assertEqual(controller.get_position("x"), 12.3) + self.assertEqual(controller.get_position("zg"), -4.0) + # An axis with no entry in homing_offsets still starts homed, at 0.0. + self.assertTrue(controller.is_axis_homed("y")) + self.assertEqual(controller.get_position("y"), 0.0) + + def test_home_axes_returns_a_moved_axis_to_its_offset(self): + controller = SimulationController(homing_offsets={"z": 8.0}) + controller.move([AxisMoveInfo(axis="z", position=42.0)]) + self.assertNotEqual(controller.get_position("z"), 8.0) + controller.home_axes(["z"]) + self.assertEqual(controller.get_position("z"), 8.0) + self.assertEqual(controller.get_position("z"), controller.get_park_position("z")) + + def test_home_axes_only_resets_the_requested_axis(self): + controller = SimulationController() + controller.move([AxisMoveInfo(axis="x", position=50.0)]) + controller.move([AxisMoveInfo(axis="y", position=50.0)]) + controller.home_axes(["x"]) + self.assertEqual(controller.get_position("x"), 0.0) + # A mutation that homes every axis regardless of the argument would pass + # a test that only checks "x", so an untouched axis is checked too. + self.assertEqual(controller.get_position("y"), 50.0) + + +class MoveTests(unittest.TestCase): + def test_absolute_move_updates_reported_position(self): + controller = SimulationController() + controller.move([AxisMoveInfo(axis="x", position=55.0, absolute=True)]) + self.assertEqual(controller.get_position("x"), 55.0) + + def test_absolute_move_replaces_rather_than_adds_to_current_position(self): + # Starting from a nonzero position distinguishes an absolute move (lands + # exactly on the target) from a relative move (would land on the sum). + controller = SimulationController() + controller.move([AxisMoveInfo(axis="x", position=20.0, absolute=True)]) + controller.move([AxisMoveInfo(axis="x", position=55.0, absolute=True)]) + self.assertEqual(controller.get_position("x"), 55.0) + + def test_relative_move_adds_to_current_position(self): + controller = SimulationController() + controller.move([AxisMoveInfo(axis="x", position=10.0, absolute=True)]) + controller.move([AxisMoveInfo(axis="x", position=5.0, absolute=False)]) + self.assertEqual(controller.get_position("x"), 15.0) + + def test_move_only_changes_the_targeted_axis(self): + controller = SimulationController() + controller.move([AxisMoveInfo(axis="x", position=99.0)]) + self.assertEqual(controller.get_position("y"), 0.0) + + +class InitializeTests(unittest.TestCase): + def test_initialize_is_idempotent_immediately_after_construction(self): + # Every axis already starts homed at its offset, so initialize() right + # after construction should be a no-op, not a state transition. + controller = SimulationController(homing_offsets={"x": 7.5, "g": 2.0}) + positions_before = controller.get_all_positions() + homed_before = controller.get_all_homed() + controller.initialize() + self.assertEqual(controller.get_all_positions(), positions_before) + self.assertEqual(controller.get_all_homed(), homed_before) + + def test_initialize_rehomes_an_axis_that_has_moved(self): + controller = SimulationController(homing_offsets={"x": 7.5}) + controller.move([AxisMoveInfo(axis="x", position=99.0)]) + self.assertNotEqual(controller.get_position("x"), 7.5) + controller.initialize() + self.assertEqual(controller.get_position("x"), 7.5) + self.assertTrue(controller.is_axis_homed("x")) + + def test_initialize_leaves_controller_connected_and_pingable(self): + controller = SimulationController() + controller.initialize() + self.assertTrue(controller.is_connected) + self.assertTrue(controller.ping()) + + +class HeadTypeTests(unittest.TestCase): + def test_default_head_type_is_96_d_70(self): + controller = SimulationController() + self.assertEqual(controller.read_smart_head_type(), head_type_code("96_d_70")) + + def test_constructor_head_type_is_reflected_in_smart_head_type(self): + controller = SimulationController(head_type="384_d_70") + self.assertEqual(controller.read_smart_head_type(), head_type_code("384_d_70")) + self.assertNotEqual(controller.read_smart_head_type(), head_type_code("96_d_70")) + + def test_read_head_adc_matches_the_known_table_value(self): + expected: dict[HeadType, int] = { + "96_d_70": 2745, + "96_d_200": 2600, + "384_d_70": 2400, + "96_f_50": 2200, + "8_d_lt": 2000, + } + for head_type, adc_value in expected.items(): + controller = SimulationController(head_type=head_type) + self.assertEqual(controller.read_head_adc(), adc_value, head_type) + + def test_read_head_adc_falls_back_to_the_96_d_70_value_for_an_unknown_head_type(self): + # 1536_pintool has no entry in the ADC table; the source's own fallback + # is the 96_d_70 reading, not a guess specific to the unlisted head. + controller = SimulationController(head_type="1536_pintool") + self.assertEqual(controller.read_head_adc(), 2745) + + def test_set_head_type_changes_reported_head_type(self): + controller = SimulationController(head_type="96_d_70") + controller.set_head_type("16_d_st") + self.assertEqual(controller.read_smart_head_type(), head_type_code("16_d_st")) + + def test_detect_smart_head_reports_true(self): + controller = SimulationController() + self.assertTrue(controller.detect_smart_head()) + + +class MotorControlTests(unittest.TestCase): + def test_motor_starts_disabled_then_can_be_enabled_and_disabled(self): + controller = SimulationController() + self.assertFalse(controller.is_motor_enabled("x")) + controller.enable_motor("x") + self.assertTrue(controller.is_motor_enabled("x")) + controller.disable_motor("x") + self.assertFalse(controller.is_motor_enabled("x")) + + +class GripperTests(unittest.TestCase): + def test_grip_moves_g_axis_and_reports_plate_present(self): + controller = SimulationController() + self.assertFalse(controller.is_plate_in_gripper()) + controller.grip(speed="slow", position=3.2) + self.assertTrue(controller.is_plate_in_gripper()) + self.assertEqual(controller.get_position("g"), 3.2) + + def test_open_gripper_clears_plate_present_and_resets_position(self): + controller = SimulationController() + controller.grip(speed="slow", position=3.2) + controller.open_gripper() + self.assertFalse(controller.is_plate_in_gripper()) + self.assertEqual(controller.get_position("g"), 0.0) + + def test_open_gripper_accepts_explicit_position(self): + controller = SimulationController() + controller.open_gripper(position=-1.5) + self.assertEqual(controller.get_position("g"), -1.5) + + def test_detect_gripper_reflects_configured_state(self): + controller = SimulationController() + controller.set_gripper_state(GripperDetectionState.NOT_DETECTED) + self.assertEqual(controller.detect_gripper(), GripperDetectionState.NOT_DETECTED) + + +class PlateSensorAndScanTests(unittest.TestCase): + def test_read_plate_sensor_reflects_configured_state(self): + controller = SimulationController() + self.assertFalse(controller.read_plate_sensor()) + controller.set_plate_sensor_present(True) + self.assertTrue(controller.read_plate_sensor()) + + def test_scan_without_configured_height_reports_not_detected(self): + controller = SimulationController() + result = controller.scan_stack_with_gripper(start_zg=0.0, end_zg=50.0, speed="slow") + self.assertFalse(result["detected"]) + self.assertEqual(result["final_zg"], 50.0) + self.assertEqual(controller.get_position("zg"), 50.0) + + def test_scan_with_configured_height_reports_detected_position(self): + controller = SimulationController() + controller.set_simulated_scan_height_mm(12.0) + result = controller.scan_stack_with_gripper(start_zg=0.0, end_zg=50.0, speed="slow") + self.assertTrue(result["detected"]) + self.assertEqual(result["final_zg"], 12.0) + self.assertEqual(controller.get_position("zg"), 12.0) + + +class DeviceStateTests(unittest.TestCase): + def test_go_button_can_be_set_and_cleared(self): + controller = SimulationController() + self.assertFalse(controller.is_go_button_pressed()) + controller.set_go_button(True) + self.assertTrue(controller.is_go_button_pressed()) + controller.clear_go_button() + self.assertFalse(controller.is_go_button_pressed()) + + def test_last_error_starts_none(self): + controller = SimulationController() + self.assertIsNone(controller.last_error) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/controllers/testdata/agile_golden_frames.json b/pylabrobot/agilent/bravo/controllers/testdata/agile_golden_frames.json new file mode 100644 index 00000000000..a1ca7888775 --- /dev/null +++ b/pylabrobot/agilent/bravo/controllers/testdata/agile_golden_frames.json @@ -0,0 +1,3726 @@ +{ + "agile7612_home_x_on_sensor": [ + [ + 161, + "0160000000000000004500" + ], + [ + 161, + "014a000000000000004100" + ], + [ + 161, + "015e00000000001000ec00" + ], + [ + 161, + "00a060c1762bfd1000de00" + ], + [ + 161, + "00ad488000000c1000cd00" + ], + [ + 161, + "00ae400000000110000300" + ], + [ + 161, + "00af000000000010002c00" + ], + [ + 161, + "00b040000000011000ee00" + ], + [ + 161, + "00bd00000000001000fe00" + ], + [ + 161, + "091000000000000000ad00" + ], + [ + 161, + "00a3400000000110007f00" + ], + [ + 161, + "00a400000000001000c300" + ], + [ + 162, + "00803c40cacff77b417042213e00000000" + ], + [ + 161, + "0001000000000038001800" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "00a440000000011000af00" + ], + [ + 161, + "00a3000000000010001300" + ], + [ + 162, + "00803c404a0c93c93f7042213e00000000" + ], + [ + 161, + "0001000000000038001800" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "00a400000000001000c300" + ], + [ + 161, + "000100000000005200a500" + ], + [ + 161, + "015e400000000110008000" + ], + [ + 161, + "0107000000000000007400" + ] + ], + "agile7612_home_x_off_sensor": [ + [ + 161, + "0160000000000000004500" + ], + [ + 161, + "014a000000000000004100" + ], + [ + 161, + "015e00000000001000ec00" + ], + [ + 161, + "00a060c1762bfd1000de00" + ], + [ + 161, + "00ad488000000c1000cd00" + ], + [ + 161, + "00ae400000000110000300" + ], + [ + 161, + "00af000000000010002c00" + ], + [ + 161, + "00b040000000011000ee00" + ], + [ + 161, + "00bd00000000001000fe00" + ], + [ + 161, + "091000000000000000ad00" + ], + [ + 161, + "00a3400000000110007f00" + ], + [ + 161, + "00a400000000001000c300" + ], + [ + 162, + "00803c404acff77b417042213e00000000" + ], + [ + 161, + "0001000000000038001800" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "00a3400000000110007f00" + ], + [ + 161, + "00a400000000001000c300" + ], + [ + 162, + "00803c40cacff77b417042213e00000000" + ], + [ + 161, + "0001000000000038001800" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "00a440000000011000af00" + ], + [ + 161, + "00a3000000000010001300" + ], + [ + 162, + "00803c404a0c93c93f7042213e00000000" + ], + [ + 161, + "0001000000000038001800" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "00a400000000001000c300" + ], + [ + 161, + "000100000000005200a500" + ], + [ + 161, + "015e400000000110008000" + ], + [ + 161, + "0107000000000000007400" + ] + ], + "agile7612_home_y_on_sensor": [ + [ + 161, + "114a00000000000000fb01" + ], + [ + 161, + "015f00000000001000af01" + ], + [ + 161, + "10a060c1762bfd10006401" + ], + [ + 161, + "10ad488000000c10007701" + ], + [ + 161, + "10ae400000000210005d01" + ], + [ + 161, + "10af000000000010009601" + ], + [ + 161, + "10b040000000021000b001" + ], + [ + 161, + "10bd000000000010004401" + ], + [ + 161, + "091000000000000000ad01" + ], + [ + 161, + "10a340000000011000c501" + ], + [ + 161, + "10a4000000000010007901" + ], + [ + 162, + "01803c40cacff77b417042213e00000000" + ], + [ + 161, + "000200000000003800dd01" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "10a4400000000110001501" + ], + [ + 161, + "10a300000000001000a901" + ], + [ + 162, + "01803c404a0c93c93f7042213e00000000" + ], + [ + 161, + "000200000000003800dd01" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "10a4000000000010007901" + ], + [ + 161, + "0002000000000052006001" + ], + [ + 161, + "015f40000000011000c301" + ], + [ + 161, + "110700000000000000ce01" + ] + ], + "agile7612_home_y_off_sensor": [ + [ + 161, + "114a00000000000000fb01" + ], + [ + 161, + "015f00000000001000af01" + ], + [ + 161, + "10a060c1762bfd10006401" + ], + [ + 161, + "10ad488000000c10007701" + ], + [ + 161, + "10ae400000000210005d01" + ], + [ + 161, + "10af000000000010009601" + ], + [ + 161, + "10b040000000021000b001" + ], + [ + 161, + "10bd000000000010004401" + ], + [ + 161, + "091000000000000000ad01" + ], + [ + 161, + "10a340000000011000c501" + ], + [ + 161, + "10a4000000000010007901" + ], + [ + 162, + "01803c404acff77b417042213e00000000" + ], + [ + 161, + "000200000000003800dd01" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "10a340000000011000c501" + ], + [ + 161, + "10a4000000000010007901" + ], + [ + 162, + "01803c40cacff77b417042213e00000000" + ], + [ + 161, + "000200000000003800dd01" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "10a4400000000110001501" + ], + [ + 161, + "10a300000000001000a901" + ], + [ + 162, + "01803c404a0c93c93f7042213e00000000" + ], + [ + 161, + "000200000000003800dd01" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "10a4000000000010007901" + ], + [ + 161, + "0002000000000052006001" + ], + [ + 161, + "015f40000000011000c301" + ], + [ + 161, + "110700000000000000ce01" + ] + ], + "agile7612_home_z_on_sensor": [ + [ + 161, + "214a000000000000002c02" + ], + [ + 161, + "016000000000001000a902" + ], + [ + 161, + "20a07ae147aeff10002b02" + ], + [ + 161, + "20ad488000000c1000a002" + ], + [ + 161, + "20ae400000000310002102" + ], + [ + 161, + "20af000000000010004102" + ], + [ + 161, + "20b040000000031000cc02" + ], + [ + 161, + "20bd000000000010009302" + ], + [ + 161, + "091000000000000000ad02" + ], + [ + 161, + "20a3400000000110001202" + ], + [ + 161, + "20a400000000001000ae02" + ], + [ + 162, + "020024744b00002042cdcccc3e00000000" + ], + [ + 161, + "0004000000000038004e02" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "20a440000000011000c202" + ], + [ + 161, + "20a3000000000010007e02" + ], + [ + 162, + "02002474cb00008040cdcccc3e00000000" + ], + [ + 161, + "0004000000000038004e02" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "20a400000000001000ae02" + ], + [ + 161, + "000400000000005200f302" + ], + [ + 161, + "016040000000011000c502" + ], + [ + 162, + "020000000000002042cdcccc3e01010000" + ], + [ + 161, + "0004000000000038004e00" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "2107000000000000001902" + ] + ], + "agile7612_home_z_off_sensor": [ + [ + 161, + "214a000000000000002c02" + ], + [ + 161, + "016000000000001000a902" + ], + [ + 161, + "20a07ae147aeff10002b02" + ], + [ + 161, + "20ad488000000c1000a002" + ], + [ + 161, + "20ae400000000310002102" + ], + [ + 161, + "20af000000000010004102" + ], + [ + 161, + "20b040000000031000cc02" + ], + [ + 161, + "20bd000000000010009302" + ], + [ + 161, + "091000000000000000ad02" + ], + [ + 161, + "20a3400000000110001202" + ], + [ + 161, + "20a400000000001000ae02" + ], + [ + 162, + "02002474cb00002042cdcccc3e00000000" + ], + [ + 161, + "0004000000000038004e02" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "20a3400000000110001202" + ], + [ + 161, + "20a400000000001000ae02" + ], + [ + 162, + "020024744b00002042cdcccc3e00000000" + ], + [ + 161, + "0004000000000038004e02" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "20a440000000011000c202" + ], + [ + 161, + "20a3000000000010007e02" + ], + [ + 162, + "02002474cb00008040cdcccc3e00000000" + ], + [ + 161, + "0004000000000038004e02" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "20a400000000001000ae02" + ], + [ + 161, + "000400000000005200f302" + ], + [ + 161, + "016040000000011000c502" + ], + [ + 162, + "020000000000002042cdcccc3e01010000" + ], + [ + 161, + "0004000000000038004e00" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "2107000000000000001902" + ] + ], + "agile7612_home_w_on_sensor": [ + [ + 161, + "314a000000000000009603" + ], + [ + 161, + "016100000000001000ea03" + ], + [ + 161, + "30a07ae147aeff10009103" + ], + [ + 161, + "30ad488000000c10001a03" + ], + [ + 161, + "30ae40000000041000e103" + ], + [ + 161, + "30af00000000001000fb03" + ], + [ + 161, + "30b0400000000410000c03" + ], + [ + 161, + "30bd000000000010002903" + ], + [ + 161, + "091000000000000000ad03" + ], + [ + 161, + "30a340000000011000a803" + ], + [ + 161, + "30a4000000000010001403" + ], + [ + 162, + "030060ea489a99993fa69b443c00000000" + ], + [ + 161, + "0008000000000038007103" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "30a4400000000110007803" + ], + [ + 161, + "30a300000000001000c403" + ], + [ + 162, + "030060eac88fc2f53da69b443c00000000" + ], + [ + 161, + "0008000000000038007103" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "30a4000000000010001403" + ], + [ + 161, + "000800000000005200cc03" + ], + [ + 161, + "0161400000000110008603" + ], + [ + 162, + "03000000009a99993fa69b443c01010000" + ], + [ + 161, + "0008000000000038007100" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "310700000000000000a303" + ] + ], + "agile7612_home_w_off_sensor": [ + [ + 161, + "314a000000000000009603" + ], + [ + 161, + "016100000000001000ea03" + ], + [ + 161, + "30a07ae147aeff10009103" + ], + [ + 161, + "30ad488000000c10001a03" + ], + [ + 161, + "30ae40000000041000e103" + ], + [ + 161, + "30af00000000001000fb03" + ], + [ + 161, + "30b0400000000410000c03" + ], + [ + 161, + "30bd000000000010002903" + ], + [ + 161, + "091000000000000000ad03" + ], + [ + 161, + "30a340000000011000a803" + ], + [ + 161, + "30a4000000000010001403" + ], + [ + 162, + "030060eac89a99993fa69b443c00000000" + ], + [ + 161, + "0008000000000038007103" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "30a340000000011000a803" + ], + [ + 161, + "30a4000000000010001403" + ], + [ + 162, + "030060ea489a99993fa69b443c00000000" + ], + [ + 161, + "0008000000000038007103" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "30a4400000000110007803" + ], + [ + 161, + "30a300000000001000c403" + ], + [ + 162, + "030060eac88fc2f53da69b443c00000000" + ], + [ + 161, + "0008000000000038007103" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "30a4000000000010001403" + ], + [ + 161, + "000800000000005200cc03" + ], + [ + 161, + "0161400000000110008603" + ], + [ + 162, + "03000000009a99993fa69b443c01010000" + ], + [ + 161, + "0008000000000038007100" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "310700000000000000a303" + ] + ], + "agile7612_home_g": [ + [ + 161, + "015e000000000000000004" + ], + [ + 162, + "0400000000492e1741ed82c13d01010000" + ], + [ + 161, + "0001000000000038001804" + ], + [ + 161, + "000000000000009000c304" + ], + [ + 161, + "000100000000003100aa04" + ], + [ + 161, + "015e00000000001000ec04" + ], + [ + 161, + "00a0489122ebff10001204" + ], + [ + 161, + "00ad488000000c1000cd04" + ], + [ + 161, + "00ae400000000110000304" + ], + [ + 161, + "00af000000000010002c04" + ], + [ + 161, + "00b040000000011000ee04" + ], + [ + 161, + "00bd00000000001000fe04" + ], + [ + 161, + "091000000000000000ad04" + ], + [ + 161, + "00a3400000000110007f04" + ], + [ + 161, + "00a400000000001000c304" + ], + [ + 162, + "04602d104b492e1741ed82c13d00000000" + ], + [ + 161, + "0001000000000038001804" + ], + [ + 161, + "000000000000009000c304" + ], + [ + 161, + "00a440000000011000af04" + ], + [ + 161, + "00a3000000000010001304" + ], + [ + 162, + "04602d10cba8e3713fed82c13d00000000" + ], + [ + 161, + "0001000000000038001804" + ], + [ + 161, + "000000000000009000c304" + ], + [ + 161, + "00a400000000001000c304" + ], + [ + 161, + "000100000000005200a504" + ], + [ + 161, + "015e400000000110008004" + ], + [ + 161, + "015e000000000000000004" + ], + [ + 162, + "0400000000492e1741ed82c13d01010000" + ], + [ + 161, + "0001000000000038001804" + ], + [ + 161, + "000000000000009000c304" + ], + [ + 161, + "000100000000003100aa04" + ], + [ + 161, + "0107000000000000007404" + ] + ], + "agile7612_home_zg": [ + [ + 161, + "015e000000000000000004" + ], + [ + 162, + "0400000000492e1741ed82c13d01010000" + ], + [ + 161, + "0001000000000038001804" + ], + [ + 161, + "000000000000009000c304" + ], + [ + 161, + "000100000000003100aa04" + ], + [ + 161, + "015f00000000001000af05" + ], + [ + 161, + "10a078f1e7d5fe10007905" + ], + [ + 161, + "10ad488000000c10007705" + ], + [ + 161, + "10ae400000000210005d05" + ], + [ + 161, + "10af000000000010009605" + ], + [ + 161, + "10b040000000021000b005" + ], + [ + 161, + "10bd000000000010004405" + ], + [ + 161, + "091000000000000000ad05" + ], + [ + 161, + "10a340000000011000c505" + ], + [ + 161, + "10a4000000000010007905" + ], + [ + 162, + "05a04bf04ae17a9d410c93493e00000000" + ], + [ + 161, + "000200000000003800dd05" + ], + [ + 161, + "000000000000009000c304" + ], + [ + 161, + "10a4400000000110001505" + ], + [ + 161, + "10a300000000001000a905" + ], + [ + 162, + "05a04bf0cacff7fb3f0c93493e00000000" + ], + [ + 161, + "000200000000003800dd05" + ], + [ + 161, + "000000000000009000c304" + ], + [ + 161, + "10a4000000000010007905" + ], + [ + 161, + "0002000000000052006005" + ], + [ + 161, + "015f40000000011000c305" + ], + [ + 162, + "05001076c6e17a9d410c93493e01010000" + ], + [ + 161, + "000200000000003800dd04" + ], + [ + 161, + "000000000000009000c304" + ], + [ + 161, + "110700000000000000ce05" + ] + ], + "agile7612_home_axes_order": [ + [ + 161, + "000100000000003100aa00" + ], + [ + 161, + "0002000000000031006f01" + ], + [ + 161, + "000400000000003100fc02" + ], + [ + 161, + "000800000000003100c303" + ], + [ + 161, + "000100000000003100aa04" + ], + [ + 161, + "0002000000000031006f05" + ], + [ + 161, + "214a000000000000002c02" + ], + [ + 161, + "016000000000001000a902" + ], + [ + 161, + "20a07ae147aeff10002b02" + ], + [ + 161, + "20ad488000000c1000a002" + ], + [ + 161, + "20ae400000000310002102" + ], + [ + 161, + "20af000000000010004102" + ], + [ + 161, + "20b040000000031000cc02" + ], + [ + 161, + "20bd000000000010009302" + ], + [ + 161, + "091000000000000000ad02" + ], + [ + 161, + "20a3400000000110001202" + ], + [ + 161, + "20a400000000001000ae02" + ], + [ + 162, + "020024744b00002042cdcccc3e00000000" + ], + [ + 161, + "0004000000000038004e02" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "20a440000000011000c202" + ], + [ + 161, + "20a3000000000010007e02" + ], + [ + 162, + "02002474cb00008040cdcccc3e00000000" + ], + [ + 161, + "0004000000000038004e02" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "20a400000000001000ae02" + ], + [ + 161, + "000400000000005200f302" + ], + [ + 161, + "016040000000011000c502" + ], + [ + 162, + "020000000000002042cdcccc3e01010000" + ], + [ + 161, + "0004000000000038004e00" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "2107000000000000001902" + ], + [ + 161, + "015e000000000000000004" + ], + [ + 162, + "0400000000492e1741ed82c13d01010000" + ], + [ + 161, + "0001000000000038001804" + ], + [ + 161, + "000000000000009000c304" + ], + [ + 161, + "000100000000003100aa04" + ], + [ + 161, + "015f00000000001000af05" + ], + [ + 161, + "10a078f1e7d5fe10007905" + ], + [ + 161, + "10ad488000000c10007705" + ], + [ + 161, + "10ae400000000210005d05" + ], + [ + 161, + "10af000000000010009605" + ], + [ + 161, + "10b040000000021000b005" + ], + [ + 161, + "10bd000000000010004405" + ], + [ + 161, + "091000000000000000ad05" + ], + [ + 161, + "10a340000000011000c505" + ], + [ + 161, + "10a4000000000010007905" + ], + [ + 162, + "05a04bf04ae17a9d410c93493e00000000" + ], + [ + 161, + "000200000000003800dd05" + ], + [ + 161, + "000000000000009000c304" + ], + [ + 161, + "10a4400000000110001505" + ], + [ + 161, + "10a300000000001000a905" + ], + [ + 162, + "05a04bf0cacff7fb3f0c93493e00000000" + ], + [ + 161, + "000200000000003800dd05" + ], + [ + 161, + "000000000000009000c304" + ], + [ + 161, + "10a4000000000010007905" + ], + [ + 161, + "0002000000000052006005" + ], + [ + 161, + "015f40000000011000c305" + ], + [ + 162, + "05001076c6e17a9d410c93493e01010000" + ], + [ + 161, + "000200000000003800dd04" + ], + [ + 161, + "000000000000009000c304" + ], + [ + 161, + "110700000000000000ce05" + ], + [ + 161, + "015e000000000000000004" + ], + [ + 162, + "0400000000492e1741ed82c13d01010000" + ], + [ + 161, + "0001000000000038001804" + ], + [ + 161, + "000000000000009000c304" + ], + [ + 161, + "000100000000003100aa04" + ], + [ + 161, + "015e00000000001000ec04" + ], + [ + 161, + "00a0489122ebff10001204" + ], + [ + 161, + "00ad488000000c1000cd04" + ], + [ + 161, + "00ae400000000110000304" + ], + [ + 161, + "00af000000000010002c04" + ], + [ + 161, + "00b040000000011000ee04" + ], + [ + 161, + "00bd00000000001000fe04" + ], + [ + 161, + "091000000000000000ad04" + ], + [ + 161, + "00a3400000000110007f04" + ], + [ + 161, + "00a400000000001000c304" + ], + [ + 162, + "04602d104b492e1741ed82c13d00000000" + ], + [ + 161, + "0001000000000038001804" + ], + [ + 161, + "000000000000009000c304" + ], + [ + 161, + "00a440000000011000af04" + ], + [ + 161, + "00a3000000000010001304" + ], + [ + 162, + "04602d10cba8e3713fed82c13d00000000" + ], + [ + 161, + "0001000000000038001804" + ], + [ + 161, + "000000000000009000c304" + ], + [ + 161, + "00a400000000001000c304" + ], + [ + 161, + "000100000000005200a504" + ], + [ + 161, + "015e400000000110008004" + ], + [ + 161, + "015e000000000000000004" + ], + [ + 162, + "0400000000492e1741ed82c13d01010000" + ], + [ + 161, + "0001000000000038001804" + ], + [ + 161, + "000000000000009000c304" + ], + [ + 161, + "000100000000003100aa04" + ], + [ + 161, + "0107000000000000007404" + ], + [ + 161, + "0160000000000000004500" + ], + [ + 161, + "014a000000000000004100" + ], + [ + 161, + "015e00000000001000ec00" + ], + [ + 161, + "00a060c1762bfd1000de00" + ], + [ + 161, + "00ad488000000c1000cd00" + ], + [ + 161, + "00ae400000000110000300" + ], + [ + 161, + "00af000000000010002c00" + ], + [ + 161, + "00b040000000011000ee00" + ], + [ + 161, + "00bd00000000001000fe00" + ], + [ + 161, + "091000000000000000ad00" + ], + [ + 161, + "00a3400000000110007f00" + ], + [ + 161, + "00a400000000001000c300" + ], + [ + 162, + "00803c40cacff77b417042213e00000000" + ], + [ + 161, + "0001000000000038001800" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "00a440000000011000af00" + ], + [ + 161, + "00a3000000000010001300" + ], + [ + 162, + "00803c404a0c93c93f7042213e00000000" + ], + [ + 161, + "0001000000000038001800" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "00a400000000001000c300" + ], + [ + 161, + "000100000000005200a500" + ], + [ + 161, + "015e400000000110008000" + ], + [ + 161, + "0107000000000000007400" + ], + [ + 161, + "114a00000000000000fb01" + ], + [ + 161, + "015f00000000001000af01" + ], + [ + 161, + "10a060c1762bfd10006401" + ], + [ + 161, + "10ad488000000c10007701" + ], + [ + 161, + "10ae400000000210005d01" + ], + [ + 161, + "10af000000000010009601" + ], + [ + 161, + "10b040000000021000b001" + ], + [ + 161, + "10bd000000000010004401" + ], + [ + 161, + "091000000000000000ad01" + ], + [ + 161, + "10a340000000011000c501" + ], + [ + 161, + "10a4000000000010007901" + ], + [ + 162, + "01803c40cacff77b417042213e00000000" + ], + [ + 161, + "000200000000003800dd01" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "10a4400000000110001501" + ], + [ + 161, + "10a300000000001000a901" + ], + [ + 162, + "01803c404a0c93c93f7042213e00000000" + ], + [ + 161, + "000200000000003800dd01" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "10a4000000000010007901" + ], + [ + 161, + "0002000000000052006001" + ], + [ + 161, + "015f40000000011000c301" + ], + [ + 161, + "110700000000000000ce01" + ], + [ + 161, + "314a000000000000009603" + ], + [ + 161, + "016100000000001000ea03" + ], + [ + 161, + "30a07ae147aeff10009103" + ], + [ + 161, + "30ad488000000c10001a03" + ], + [ + 161, + "30ae40000000041000e103" + ], + [ + 161, + "30af00000000001000fb03" + ], + [ + 161, + "30b0400000000410000c03" + ], + [ + 161, + "30bd000000000010002903" + ], + [ + 161, + "091000000000000000ad03" + ], + [ + 161, + "30a340000000011000a803" + ], + [ + 161, + "30a4000000000010001403" + ], + [ + 162, + "030060ea489a99993fa69b443c00000000" + ], + [ + 161, + "0008000000000038007103" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "30a4400000000110007803" + ], + [ + 161, + "30a300000000001000c403" + ], + [ + 162, + "030060eac88fc2f53da69b443c00000000" + ], + [ + 161, + "0008000000000038007103" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "30a4000000000010001403" + ], + [ + 161, + "000800000000005200cc03" + ], + [ + 161, + "0161400000000110008603" + ], + [ + 162, + "03000000009a99993fa69b443c01010000" + ], + [ + 161, + "0008000000000038007100" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "310700000000000000a303" + ] + ], + "agile7612_move": [ + [ + 162, + "000010f646cff77b41f301013d01010000" + ], + [ + 162, + "045238ec44492e1741ed82413d01010000" + ], + [ + 161, + "0001000000000038001800" + ], + [ + 161, + "0001000000000038001804" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "000000000000009000c304" + ], + [ + 161, + "000100000000003100aa04" + ] + ], + "agile7612_jog": [ + [ + 161, + "2107000000000000001902" + ], + [ + 161, + "2023000000000000000002" + ], + [ + 170, + "02cdcc4c3e000001" + ], + [ + 161, + "800040000000053600d802" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "2107000000000000001902" + ], + [ + 174, + "" + ] + ], + "agile7612_tip_force_jog": [ + [ + 161, + "2107000000000000001902" + ], + [ + 162, + "02008009470000a042cdcc4c3f01010000" + ], + [ + 161, + "0004000000000038004e02" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "000100000000003100aa04" + ], + [ + 162, + "0200803b47000080410ad7233e01010000" + ], + [ + 161, + "0004000000000038004e02" + ], + [ + 161, + "20024ccccccc0010000f02" + ], + [ + 161, + "2023000000000000000002" + ], + [ + 161, + "202300000000001000ec02" + ], + [ + 170, + "029a99193e000001" + ], + [ + 161, + "800040000000053600d802" + ], + [ + 161, + "2107000000000000001902" + ], + [ + 161, + "2107000000000000001902" + ], + [ + 161, + "2107000000000000001902" + ], + [ + 161, + "2107000000000000001902" + ], + [ + 174, + "" + ] + ], + "agile7612_grip": [ + [ + 162, + "043d2a3145492e1741ed82c13d01010000" + ], + [ + 161, + "0001000000000038001804" + ], + [ + 161, + "000000000000009000c304" + ], + [ + 161, + "000100000000003100aa04" + ] + ], + "srt_home_x_on_sensor": [ + [ + 161, + "014a000000000000004100" + ], + [ + 161, + "015e00000000001000ec00" + ], + [ + 161, + "00a060c1762bfd1000de00" + ], + [ + 161, + "00ad488000000c1000cd00" + ], + [ + 161, + "00ae400000000110000300" + ], + [ + 161, + "00af000000000010002c00" + ], + [ + 161, + "00b040000000011000ee00" + ], + [ + 161, + "00bd00000000001000fe00" + ], + [ + 161, + "091000000000000000ad00" + ], + [ + 161, + "00a3400000000110007f00" + ], + [ + 161, + "00a400000000001000c300" + ], + [ + 162, + "00803c40cacef77b41f301013d00005e01" + ], + [ + 161, + "0001000000000038001800" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "00a440000000011000af00" + ], + [ + 161, + "00a3000000000010001300" + ], + [ + 162, + "00803c404a0c93c93ff301013d00005e01" + ], + [ + 161, + "0001000000000038001800" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "00a400000000001000c300" + ], + [ + 161, + "000100000000005200a500" + ], + [ + 161, + "015e400000000110008000" + ], + [ + 161, + "0107000000000000007400" + ] + ], + "srt_home_x_off_sensor": [ + [ + 161, + "014a000000000000004100" + ], + [ + 161, + "015e00000000001000ec00" + ], + [ + 161, + "00a060c1762bfd1000de00" + ], + [ + 161, + "00ad488000000c1000cd00" + ], + [ + 161, + "00ae400000000110000300" + ], + [ + 161, + "00af000000000010002c00" + ], + [ + 161, + "00b040000000011000ee00" + ], + [ + 161, + "00bd00000000001000fe00" + ], + [ + 161, + "091000000000000000ad00" + ], + [ + 161, + "00a3400000000110007f00" + ], + [ + 161, + "00a400000000001000c300" + ], + [ + 162, + "00803c404acef77b41f301013d00005e01" + ], + [ + 161, + "0001000000000038001800" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "00a3400000000110007f00" + ], + [ + 161, + "00a400000000001000c300" + ], + [ + 162, + "00803c40cacef77b41f301013d00005e01" + ], + [ + 161, + "0001000000000038001800" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "00a440000000011000af00" + ], + [ + 161, + "00a3000000000010001300" + ], + [ + 162, + "00803c404a0c93c93ff301013d00005e01" + ], + [ + 161, + "0001000000000038001800" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "00a400000000001000c300" + ], + [ + 161, + "000100000000005200a500" + ], + [ + 161, + "015e400000000110008000" + ], + [ + 161, + "0107000000000000007400" + ] + ], + "srt_home_y_on_sensor": [ + [ + 161, + "114a00000000000000fb01" + ], + [ + 161, + "015f00000000001000af01" + ], + [ + 161, + "10a060c1762bfd10006401" + ], + [ + 161, + "10ad488000000c10007701" + ], + [ + 161, + "10ae400000000210005d01" + ], + [ + 161, + "10af000000000010009601" + ], + [ + 161, + "10b040000000021000b001" + ], + [ + 161, + "10bd000000000010004401" + ], + [ + 161, + "091000000000000000ad01" + ], + [ + 161, + "10a340000000011000c501" + ], + [ + 161, + "10a4000000000010007901" + ], + [ + 162, + "01803c40cacef77b41f301013d00005f01" + ], + [ + 161, + "000200000000003800dd01" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "10a4400000000110001501" + ], + [ + 161, + "10a300000000001000a901" + ], + [ + 162, + "01803c404a0c93c93ff301013d00005f01" + ], + [ + 161, + "000200000000003800dd01" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "10a4000000000010007901" + ], + [ + 161, + "0002000000000052006001" + ], + [ + 161, + "015f40000000011000c301" + ], + [ + 161, + "110700000000000000ce01" + ] + ], + "srt_home_y_off_sensor": [ + [ + 161, + "114a00000000000000fb01" + ], + [ + 161, + "015f00000000001000af01" + ], + [ + 161, + "10a060c1762bfd10006401" + ], + [ + 161, + "10ad488000000c10007701" + ], + [ + 161, + "10ae400000000210005d01" + ], + [ + 161, + "10af000000000010009601" + ], + [ + 161, + "10b040000000021000b001" + ], + [ + 161, + "10bd000000000010004401" + ], + [ + 161, + "091000000000000000ad01" + ], + [ + 161, + "10a340000000011000c501" + ], + [ + 161, + "10a4000000000010007901" + ], + [ + 162, + "01803c404acef77b41f301013d00005f01" + ], + [ + 161, + "000200000000003800dd01" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "10a340000000011000c501" + ], + [ + 161, + "10a4000000000010007901" + ], + [ + 162, + "01803c40cacef77b41f301013d00005f01" + ], + [ + 161, + "000200000000003800dd01" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "10a4400000000110001501" + ], + [ + 161, + "10a300000000001000a901" + ], + [ + 162, + "01803c404a0c93c93ff301013d00005f01" + ], + [ + 161, + "000200000000003800dd01" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "10a4000000000010007901" + ], + [ + 161, + "0002000000000052006001" + ], + [ + 161, + "015f40000000011000c301" + ], + [ + 161, + "110700000000000000ce01" + ] + ], + "srt_home_z_on_sensor": [ + [ + 161, + "214a000000000000002c02" + ], + [ + 161, + "016000000000001000a902" + ], + [ + 161, + "20a07ae147aeff10002b02" + ], + [ + 161, + "20ad488000000c1000a002" + ], + [ + 161, + "20ae400000000310002102" + ], + [ + 161, + "20af000000000010004102" + ], + [ + 161, + "20b040000000031000cc02" + ], + [ + 161, + "20bd000000000010009302" + ], + [ + 161, + "091000000000000000ad02" + ], + [ + 161, + "20a3400000000110001202" + ], + [ + 161, + "20a400000000001000ae02" + ], + [ + 162, + "020024744b000080410ad7233e00006001" + ], + [ + 161, + "0004000000000038004e02" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "20a440000000011000c202" + ], + [ + 161, + "20a3000000000010007e02" + ], + [ + 162, + "02002474cbcdcccc3f0ad7233e00006001" + ], + [ + 161, + "0004000000000038004e02" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "20a400000000001000ae02" + ], + [ + 161, + "000400000000005200f302" + ], + [ + 161, + "016040000000011000c502" + ], + [ + 161, + "2107000000000000001902" + ] + ], + "srt_home_z_off_sensor": [ + [ + 161, + "214a000000000000002c02" + ], + [ + 161, + "016000000000001000a902" + ], + [ + 161, + "20a07ae147aeff10002b02" + ], + [ + 161, + "20ad488000000c1000a002" + ], + [ + 161, + "20ae400000000310002102" + ], + [ + 161, + "20af000000000010004102" + ], + [ + 161, + "20b040000000031000cc02" + ], + [ + 161, + "20bd000000000010009302" + ], + [ + 161, + "091000000000000000ad02" + ], + [ + 161, + "20a3400000000110001202" + ], + [ + 161, + "20a400000000001000ae02" + ], + [ + 162, + "02002474cb000080410ad7233e00006001" + ], + [ + 161, + "0004000000000038004e02" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "20a3400000000110001202" + ], + [ + 161, + "20a400000000001000ae02" + ], + [ + 162, + "020024744b000080410ad7233e00006001" + ], + [ + 161, + "0004000000000038004e02" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "20a440000000011000c202" + ], + [ + 161, + "20a3000000000010007e02" + ], + [ + 162, + "02002474cbcdcccc3f0ad7233e00006001" + ], + [ + 161, + "0004000000000038004e02" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "20a400000000001000ae02" + ], + [ + 161, + "000400000000005200f302" + ], + [ + 161, + "016040000000011000c502" + ], + [ + 161, + "2107000000000000001902" + ] + ], + "srt_home_w_on_sensor": [ + [ + 161, + "30397e9000000d1000a303" + ], + [ + 161, + "303a695000000c10001a03" + ], + [ + 161, + "3075700000000010000b03" + ], + [ + 161, + "3076900000000010007103" + ], + [ + 161, + "307c40000000fe10000f03" + ], + [ + 161, + "3077400000000310007d03" + ], + [ + 161, + "3078700000000010007703" + ], + [ + 161, + "3079900000000010008b03" + ], + [ + 161, + "307d40000000fe10004c03" + ], + [ + 161, + "307a400000000310000103" + ], + [ + 161, + "000800000000005500a203" + ], + [ + 161, + "3044000000000010006703" + ], + [ + 161, + "30d84b0000000710009503" + ], + [ + 161, + "30da40000000001000df03" + ], + [ + 161, + "30de640000000610000003" + ], + [ + 161, + "30e200000000001000ce03" + ], + [ + 161, + "301f000000000010009503" + ], + [ + 161, + "0008000000000054006603" + ], + [ + 161, + "302364000000081000af03" + ], + [ + 161, + "3004640000000610002303" + ], + [ + 161, + "300366666666fe10004403" + ], + [ + 161, + "300277777d0fff10000903" + ], + [ + 161, + "30397e9000000d1000a303" + ], + [ + 161, + "303a695000000c10001a03" + ], + [ + 161, + "3075700000000010000b03" + ], + [ + 161, + "3076900000000010007103" + ], + [ + 161, + "307c40000000fe10000f03" + ], + [ + 161, + "3077400000000310007d03" + ], + [ + 161, + "3078700000000010007703" + ], + [ + 161, + "3079900000000010008b03" + ], + [ + 161, + "307d40000000fe10004c03" + ], + [ + 161, + "307a400000000310000103" + ], + [ + 161, + "000800000000005500a203" + ], + [ + 161, + "3044000000000010006703" + ], + [ + 161, + "30d84b0000000710009503" + ], + [ + 161, + "30da40000000001000df03" + ], + [ + 161, + "30de640000000610000003" + ], + [ + 161, + "30e200000000001000ce03" + ], + [ + 161, + "301f000000000010009503" + ], + [ + 161, + "0008000000000054006603" + ], + [ + 161, + "302364000000081000af03" + ], + [ + 161, + "3004640000000610002303" + ], + [ + 161, + "300366666666fe10004403" + ], + [ + 161, + "300277777d0fff10000903" + ], + [ + 161, + "314a000000000000009603" + ], + [ + 161, + "0008000000000030000703" + ], + [ + 161, + "314a000000000000009603" + ], + [ + 161, + "016100000000001000ea03" + ], + [ + 161, + "30a040f9096b001000d503" + ], + [ + 161, + "30ad488000000c10001a03" + ], + [ + 161, + "30ae40000000041000e103" + ], + [ + 161, + "30af00000000001000fb03" + ], + [ + 161, + "30b0400000000410000c03" + ], + [ + 161, + "30bd000000000010002903" + ], + [ + 161, + "091000000000000000ad03" + ], + [ + 161, + "30a340000000011000a803" + ], + [ + 161, + "30a4000000000010001403" + ], + [ + 162, + "03e016814b295c8741c4422d3e00006101" + ], + [ + 161, + "0008000000000038007103" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "30a4400000000110007803" + ], + [ + 161, + "30a300000000001000c403" + ], + [ + 162, + "03e01681cb7593d83fc4422d3e00006101" + ], + [ + 161, + "0008000000000038007103" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "30a4000000000010001403" + ], + [ + 161, + "000800000000005200cc03" + ], + [ + 161, + "0161400000000110008603" + ], + [ + 161, + "310700000000000000a303" + ] + ], + "srt_home_w_off_sensor": [ + [ + 161, + "30397e9000000d1000a303" + ], + [ + 161, + "303a695000000c10001a03" + ], + [ + 161, + "3075700000000010000b03" + ], + [ + 161, + "3076900000000010007103" + ], + [ + 161, + "307c40000000fe10000f03" + ], + [ + 161, + "3077400000000310007d03" + ], + [ + 161, + "3078700000000010007703" + ], + [ + 161, + "3079900000000010008b03" + ], + [ + 161, + "307d40000000fe10004c03" + ], + [ + 161, + "307a400000000310000103" + ], + [ + 161, + "000800000000005500a203" + ], + [ + 161, + "3044000000000010006703" + ], + [ + 161, + "30d84b0000000710009503" + ], + [ + 161, + "30da40000000001000df03" + ], + [ + 161, + "30de640000000610000003" + ], + [ + 161, + "30e200000000001000ce03" + ], + [ + 161, + "301f000000000010009503" + ], + [ + 161, + "0008000000000054006603" + ], + [ + 161, + "302364000000081000af03" + ], + [ + 161, + "3004640000000610002303" + ], + [ + 161, + "300366666666fe10004403" + ], + [ + 161, + "300277777d0fff10000903" + ], + [ + 161, + "30397e9000000d1000a303" + ], + [ + 161, + "303a695000000c10001a03" + ], + [ + 161, + "3075700000000010000b03" + ], + [ + 161, + "3076900000000010007103" + ], + [ + 161, + "307c40000000fe10000f03" + ], + [ + 161, + "3077400000000310007d03" + ], + [ + 161, + "3078700000000010007703" + ], + [ + 161, + "3079900000000010008b03" + ], + [ + 161, + "307d40000000fe10004c03" + ], + [ + 161, + "307a400000000310000103" + ], + [ + 161, + "000800000000005500a203" + ], + [ + 161, + "3044000000000010006703" + ], + [ + 161, + "30d84b0000000710009503" + ], + [ + 161, + "30da40000000001000df03" + ], + [ + 161, + "30de640000000610000003" + ], + [ + 161, + "30e200000000001000ce03" + ], + [ + 161, + "301f000000000010009503" + ], + [ + 161, + "0008000000000054006603" + ], + [ + 161, + "302364000000081000af03" + ], + [ + 161, + "3004640000000610002303" + ], + [ + 161, + "300366666666fe10004403" + ], + [ + 161, + "300277777d0fff10000903" + ], + [ + 161, + "314a000000000000009603" + ], + [ + 161, + "0008000000000030000703" + ], + [ + 161, + "314a000000000000009603" + ], + [ + 161, + "016100000000001000ea03" + ], + [ + 161, + "30a040f9096b001000d503" + ], + [ + 161, + "30ad488000000c10001a03" + ], + [ + 161, + "30ae40000000041000e103" + ], + [ + 161, + "30af00000000001000fb03" + ], + [ + 161, + "30b0400000000410000c03" + ], + [ + 161, + "30bd000000000010002903" + ], + [ + 161, + "091000000000000000ad03" + ], + [ + 161, + "30a340000000011000a803" + ], + [ + 161, + "30a4000000000010001403" + ], + [ + 162, + "03e01681cb295c8741c4422d3e00006101" + ], + [ + 161, + "0008000000000038007103" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "30a340000000011000a803" + ], + [ + 161, + "30a4000000000010001403" + ], + [ + 162, + "03e016814b295c8741c4422d3e00006101" + ], + [ + 161, + "0008000000000038007103" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "30a4400000000110007803" + ], + [ + 161, + "30a300000000001000c403" + ], + [ + 162, + "03e01681cb7593d83fc4422d3e00006101" + ], + [ + 161, + "0008000000000038007103" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "30a4000000000010001403" + ], + [ + 161, + "000800000000005200cc03" + ], + [ + 161, + "0161400000000110008603" + ], + [ + 161, + "310700000000000000a303" + ] + ], + "srt_home_axes_order": [ + [ + 161, + "000100000000003100aa00" + ], + [ + 161, + "0002000000000031006f01" + ], + [ + 161, + "000400000000003100fc02" + ], + [ + 161, + "214a000000000000002c02" + ], + [ + 161, + "016000000000001000a902" + ], + [ + 161, + "20a07ae147aeff10002b02" + ], + [ + 161, + "20ad488000000c1000a002" + ], + [ + 161, + "20ae400000000310002102" + ], + [ + 161, + "20af000000000010004102" + ], + [ + 161, + "20b040000000031000cc02" + ], + [ + 161, + "20bd000000000010009302" + ], + [ + 161, + "091000000000000000ad02" + ], + [ + 161, + "20a3400000000110001202" + ], + [ + 161, + "20a400000000001000ae02" + ], + [ + 162, + "020024744b000080410ad7233e00006001" + ], + [ + 161, + "0004000000000038004e02" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "20a440000000011000c202" + ], + [ + 161, + "20a3000000000010007e02" + ], + [ + 162, + "02002474cbcdcccc3f0ad7233e00006001" + ], + [ + 161, + "0004000000000038004e02" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "20a400000000001000ae02" + ], + [ + 161, + "000400000000005200f302" + ], + [ + 161, + "016040000000011000c502" + ], + [ + 161, + "2107000000000000001902" + ], + [ + 161, + "30397e9000000d1000a303" + ], + [ + 161, + "303a695000000c10001a03" + ], + [ + 161, + "3075700000000010000b03" + ], + [ + 161, + "3076900000000010007103" + ], + [ + 161, + "307c40000000fe10000f03" + ], + [ + 161, + "3077400000000310007d03" + ], + [ + 161, + "3078700000000010007703" + ], + [ + 161, + "3079900000000010008b03" + ], + [ + 161, + "307d40000000fe10004c03" + ], + [ + 161, + "307a400000000310000103" + ], + [ + 161, + "000800000000005500a203" + ], + [ + 161, + "3044000000000010006703" + ], + [ + 161, + "30d84b0000000710009503" + ], + [ + 161, + "30da40000000001000df03" + ], + [ + 161, + "30de640000000610000003" + ], + [ + 161, + "30e200000000001000ce03" + ], + [ + 161, + "301f000000000010009503" + ], + [ + 161, + "0008000000000054006603" + ], + [ + 161, + "302364000000081000af03" + ], + [ + 161, + "3004640000000610002303" + ], + [ + 161, + "300366666666fe10004403" + ], + [ + 161, + "300277777d0fff10000903" + ], + [ + 161, + "30397e9000000d1000a303" + ], + [ + 161, + "303a695000000c10001a03" + ], + [ + 161, + "3075700000000010000b03" + ], + [ + 161, + "3076900000000010007103" + ], + [ + 161, + "307c40000000fe10000f03" + ], + [ + 161, + "3077400000000310007d03" + ], + [ + 161, + "3078700000000010007703" + ], + [ + 161, + "3079900000000010008b03" + ], + [ + 161, + "307d40000000fe10004c03" + ], + [ + 161, + "307a400000000310000103" + ], + [ + 161, + "000800000000005500a203" + ], + [ + 161, + "3044000000000010006703" + ], + [ + 161, + "30d84b0000000710009503" + ], + [ + 161, + "30da40000000001000df03" + ], + [ + 161, + "30de640000000610000003" + ], + [ + 161, + "30e200000000001000ce03" + ], + [ + 161, + "301f000000000010009503" + ], + [ + 161, + "0008000000000054006603" + ], + [ + 161, + "302364000000081000af03" + ], + [ + 161, + "3004640000000610002303" + ], + [ + 161, + "300366666666fe10004403" + ], + [ + 161, + "300277777d0fff10000903" + ], + [ + 161, + "314a000000000000009603" + ], + [ + 161, + "0008000000000030000703" + ], + [ + 161, + "314a000000000000009603" + ], + [ + 161, + "016100000000001000ea03" + ], + [ + 161, + "30a040f9096b001000d503" + ], + [ + 161, + "30ad488000000c10001a03" + ], + [ + 161, + "30ae40000000041000e103" + ], + [ + 161, + "30af00000000001000fb03" + ], + [ + 161, + "30b0400000000410000c03" + ], + [ + 161, + "30bd000000000010002903" + ], + [ + 161, + "091000000000000000ad03" + ], + [ + 161, + "30a340000000011000a803" + ], + [ + 161, + "30a4000000000010001403" + ], + [ + 162, + "03e016814b295c8741c4422d3e00006101" + ], + [ + 161, + "0008000000000038007103" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "30a4400000000110007803" + ], + [ + 161, + "30a300000000001000c403" + ], + [ + 162, + "03e01681cb7593d83fc4422d3e00006101" + ], + [ + 161, + "0008000000000038007103" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "30a4000000000010001403" + ], + [ + 161, + "000800000000005200cc03" + ], + [ + 161, + "0161400000000110008603" + ], + [ + 161, + "310700000000000000a303" + ], + [ + 161, + "014a000000000000004100" + ], + [ + 161, + "015e00000000001000ec00" + ], + [ + 161, + "00a060c1762bfd1000de00" + ], + [ + 161, + "00ad488000000c1000cd00" + ], + [ + 161, + "00ae400000000110000300" + ], + [ + 161, + "00af000000000010002c00" + ], + [ + 161, + "00b040000000011000ee00" + ], + [ + 161, + "00bd00000000001000fe00" + ], + [ + 161, + "091000000000000000ad00" + ], + [ + 161, + "00a3400000000110007f00" + ], + [ + 161, + "00a400000000001000c300" + ], + [ + 162, + "00803c40cacef77b41f301013d00005e01" + ], + [ + 161, + "0001000000000038001800" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "00a440000000011000af00" + ], + [ + 161, + "00a3000000000010001300" + ], + [ + 162, + "00803c404a0c93c93ff301013d00005e01" + ], + [ + 161, + "0001000000000038001800" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "00a400000000001000c300" + ], + [ + 161, + "000100000000005200a500" + ], + [ + 161, + "015e400000000110008000" + ], + [ + 161, + "0107000000000000007400" + ], + [ + 161, + "114a00000000000000fb01" + ], + [ + 161, + "015f00000000001000af01" + ], + [ + 161, + "10a060c1762bfd10006401" + ], + [ + 161, + "10ad488000000c10007701" + ], + [ + 161, + "10ae400000000210005d01" + ], + [ + 161, + "10af000000000010009601" + ], + [ + 161, + "10b040000000021000b001" + ], + [ + 161, + "10bd000000000010004401" + ], + [ + 161, + "091000000000000000ad01" + ], + [ + 161, + "10a340000000011000c501" + ], + [ + 161, + "10a4000000000010007901" + ], + [ + 162, + "01803c40cacef77b41f301013d00005f01" + ], + [ + 161, + "000200000000003800dd01" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "10a4400000000110001501" + ], + [ + 161, + "10a300000000001000a901" + ], + [ + 162, + "01803c404a0c93c93ff301013d00005f01" + ], + [ + 161, + "000200000000003800dd01" + ], + [ + 161, + "000000000000009000c300" + ], + [ + 161, + "10a4000000000010007901" + ], + [ + 161, + "0002000000000052006001" + ], + [ + 161, + "015f40000000011000c301" + ], + [ + 161, + "110700000000000000ce01" + ] + ] +} \ No newline at end of file From 9c70b539265611987458c457b9601aa0a23d4f21 Mon Sep 17 00:00:00 2001 From: kelsorj Date: Fri, 21 Aug 2026 10:59:35 -0700 Subject: [PATCH 5/9] Add the Darwin-generation Bravo controller Darwin axes are not initialised by a single firmware call. Each is driven step by step through motor-state writes and polled reads, implemented as per-axis state machines, which is what makes retry-on-regression possible. Polling runs at 5 Hz with an explicit sleep between reads. The W axis is millimetre-native on this generation and microlitre-native on the Agile family, so ul_to_mm is declared on the controller interface and overridden here. --- pylabrobot/agilent/bravo/darwin/__init__.py | 19 + pylabrobot/agilent/bravo/darwin/axis.py | 427 +++++ .../agilent/bravo/darwin/calibration.py | 168 ++ pylabrobot/agilent/bravo/darwin/controller.py | 1480 +++++++++++++++++ .../agilent/bravo/darwin/controller_tests.py | 40 + .../bravo/darwin/darwin_golden_frame_tests.py | 690 ++++++++ pylabrobot/agilent/bravo/darwin/motion.py | 694 ++++++++ pylabrobot/agilent/bravo/darwin/params.py | 178 ++ pylabrobot/agilent/bravo/darwin/sequences.py | 502 ++++++ .../darwin/testdata/darwin_golden_frames.json | 1226 ++++++++++++++ .../agilent/bravo/darwin/timing_tests.py | 240 +++ pylabrobot/agilent/bravo/darwin/topology.py | 107 ++ .../agilent/bravo/darwin/waxis_config.py | 182 ++ .../agilent/bravo/darwin/waxis_params.py | 329 ++++ 14 files changed, 6282 insertions(+) create mode 100644 pylabrobot/agilent/bravo/darwin/__init__.py create mode 100644 pylabrobot/agilent/bravo/darwin/axis.py create mode 100644 pylabrobot/agilent/bravo/darwin/calibration.py create mode 100644 pylabrobot/agilent/bravo/darwin/controller.py create mode 100644 pylabrobot/agilent/bravo/darwin/controller_tests.py create mode 100644 pylabrobot/agilent/bravo/darwin/darwin_golden_frame_tests.py create mode 100644 pylabrobot/agilent/bravo/darwin/motion.py create mode 100644 pylabrobot/agilent/bravo/darwin/params.py create mode 100644 pylabrobot/agilent/bravo/darwin/sequences.py create mode 100644 pylabrobot/agilent/bravo/darwin/testdata/darwin_golden_frames.json create mode 100644 pylabrobot/agilent/bravo/darwin/timing_tests.py create mode 100644 pylabrobot/agilent/bravo/darwin/topology.py create mode 100644 pylabrobot/agilent/bravo/darwin/waxis_config.py create mode 100644 pylabrobot/agilent/bravo/darwin/waxis_params.py diff --git a/pylabrobot/agilent/bravo/darwin/__init__.py b/pylabrobot/agilent/bravo/darwin/__init__.py new file mode 100644 index 00000000000..01c645124d9 --- /dev/null +++ b/pylabrobot/agilent/bravo/darwin/__init__.py @@ -0,0 +1,19 @@ +"""Pure-Python controller stack for Darwin-generation Bravo instruments. + +Submodules: + topology -- node-tree layout (axis <-> InstructionAddress) + axis -- per-axis state machines (commutate/home/initialize) + params -- pointer-cached parameter database access + waxis_params -- W-axis per-head-type PID/motion table + calibration -- hardware ranges and mm/normalized conversion + waxis_config -- per-head-type W-axis calibration and unit conversion + motion -- instruction load, trigger, and settle polling + sequences -- composite procedures (grip, open_gripper, jog) + controller -- DarwinController(BravoController) facade +""" + +from __future__ import annotations + +from .controller import DarwinController + +__all__ = ["DarwinController"] diff --git a/pylabrobot/agilent/bravo/darwin/axis.py b/pylabrobot/agilent/bravo/darwin/axis.py new file mode 100644 index 00000000000..b6f01ce869b --- /dev/null +++ b/pylabrobot/agilent/bravo/darwin/axis.py @@ -0,0 +1,427 @@ +"""Per-axis state machines -- commutate, home, initialize. + +Commutation, homing, and initialization are driven directly through +``MOTOR_STATE`` writes and polled reads rather than through a single +firmware initialize call. Driving them step by step is what makes the +retry-on-regression semantics and the timing-sensitive behavior possible. + +Public entry points: + :func:`read_motor_state`, :func:`set_motor_state` + :func:`commutate`, :func:`home`, :func:`initialize` + :func:`enable`, :func:`disable`, :func:`is_enabled`, :func:`reset_faults` +""" + +from __future__ import annotations + +from dataclasses import dataclass +from time import monotonic, sleep +from typing import Callable, Dict, Optional + +from ..errors import BravoError, ErrorType +from ..protocol.gemini.engine import GeminiEngine +from ..protocol.gemini.enums import GeminiSubCommands, MotorState +from ..protocol.gemini.packet import InstructionAddress +from ..types import Axis + +# Default polling, timeout, and retry values. +_STATE_POLL = 0.2 +_DEFAULT_COMMUTATE_TIMEOUT = 15.0 +_DEFAULT_HOME_TIMEOUT = 20.0 +_COMMUTATE_RETRIES = 2 +_HOMING_RETRIES = 3 + + +@dataclass(frozen=True) +class AxisTimeouts: + """Per-axis timing overrides. + + Attributes: + commutate: Commutation timeout override, in seconds. ``None`` means use + the default. + home: Homing timeout override, in seconds. ``None`` means use the + default. + """ + + commutate: Optional[float] = None + home: Optional[float] = None + + +# G axis has an extended commutate timeout (30s). +# W axis has an extended home timeout (40s). +_AXIS_TIMEOUTS: Dict[Axis, AxisTimeouts] = { + "g": AxisTimeouts(commutate=30.0), + "w": AxisTimeouts(home=40.0), +} + + +def timeouts_for(axis: Axis) -> AxisTimeouts: + """Return the timing overrides for an axis. + + Args: + axis: The axis to look up. + + Returns: + The axis's timing overrides, or a default (all-``None``) record if it + has none. + """ + return _AXIS_TIMEOUTS.get(axis, AxisTimeouts()) + + +# --- Primitive state read/write --------------------------------------------- + + +def read_motor_state( + engine: GeminiEngine, address: InstructionAddress, timeout: float = 5.0 +) -> MotorState: + """Read an axis device's current motor-lifecycle state. + + Args: + engine: The Gemini engine to read through. + address: The axis device's controller-tree address. + timeout: Maximum time to wait for the wire exchange, in seconds. + + Returns: + The decoded state, or :attr:`~.enums.MotorState.INITIAL` if the device + reported a value with no matching :class:`~.enums.MotorState` member. + """ + raw = engine.get_value(address, GeminiSubCommands.MOTOR_STATE, timeout) + try: + return MotorState(raw) + except ValueError: + return MotorState.INITIAL + + +def set_motor_state( + engine: GeminiEngine, + address: InstructionAddress, + state: MotorState, + timeout: float = 5.0, +) -> None: + """Write an axis device's motor-lifecycle state. + + Args: + engine: The Gemini engine to write through. + address: The axis device's controller-tree address. + state: The state to request. + timeout: Maximum time to wait for the wire exchange, in seconds. + """ + engine.set_uint(address, GeminiSubCommands.MOTOR_STATE, int(state), timeout) + + +# --- Commutate ---------------------------------------------------------------- + + +def commutate( + engine: GeminiEngine, + address: InstructionAddress, + axis_name: str, + *, + timeout: Optional[float] = None, + poll: float = _STATE_POLL, + get_estop_engaged: Callable[[], bool] = lambda: False, +) -> None: + """Commutate the axis: set state to Commutate, wait for Commutated. + + Retries up to :data:`_COMMUTATE_RETRIES` times if the state regresses to + ``INITIAL``. + + Args: + engine: The Gemini engine to drive the axis through. + address: The axis device's controller-tree address. + axis_name: The axis's display name, used in error messages. + timeout: Overall commutation timeout, in seconds; defaults to + :data:`_DEFAULT_COMMUTATE_TIMEOUT`. + poll: Delay between state polls, in seconds. + get_estop_engaged: Returns True if E-stop is engaged; checked once at + entry. + + Raises: + BravoError: If E-stop is engaged at entry, the timeout elapses before + the axis reaches ``COMMUTATED``, or the axis regresses to + ``INITIAL`` more than :data:`_COMMUTATE_RETRIES` times. + """ + if get_estop_engaged(): + raise BravoError(ErrorType.ROBOT_DISABLE) + + deadline = timeout or _DEFAULT_COMMUTATE_TIMEOUT + retries = 0 + + set_motor_state(engine, address, MotorState.COMMUTATE) + start = monotonic() + state = read_motor_state(engine, address) + while state != MotorState.COMMUTATED: + sleep(poll) + elapsed = monotonic() - start + if elapsed > deadline: + raise BravoError( + ErrorType.COULD_NOT_ALIGN, + custom_text=f"Axis commutation timeout [{axis_name}]", + ) + state = read_motor_state(engine, address) + if state == MotorState.INITIAL: + retries += 1 + if retries > _COMMUTATE_RETRIES: + raise BravoError( + ErrorType.COULD_NOT_ALIGN, + custom_text=(f"Axis commutation failed after {_COMMUTATE_RETRIES} retries [{axis_name}]"), + ) + set_motor_state(engine, address, MotorState.COMMUTATE) + state = read_motor_state(engine, address) + + +# --- Home ----------------------------------------------------------------------- + + +def home( + engine: GeminiEngine, + address: InstructionAddress, + axis_name: str, + *, + timeout: Optional[float] = None, + poll: float = _STATE_POLL, + commutate_timeout: Optional[float] = None, + get_estop_engaged: Callable[[], bool] = lambda: False, +) -> None: + """Home the axis: reset the homing index, set state to Home, wait for Ready. + + If the post-Home state does not climb above ``HOME`` (indicating the + homing sequence did not start cleanly), the sequence retries up to + :data:`_HOMING_RETRIES` times, re-commutating between attempts. + + Requires the axis to be at least ``COMMUTATED`` before starting. + + Args: + engine: The Gemini engine to drive the axis through. + address: The axis device's controller-tree address. + axis_name: The axis's display name, used in error messages. + timeout: Homing timeout for each attempt, in seconds; defaults to + :data:`_DEFAULT_HOME_TIMEOUT`. + poll: Delay between state polls, in seconds. + commutate_timeout: Commutation timeout to use on a retry, in seconds. + get_estop_engaged: Returns True if E-stop is engaged; forwarded to + :func:`commutate` on a retry. + + Raises: + BravoError: If the axis is not commutated, cannot be disabled while + homed, the timeout elapses before reaching ``READY``, or homing + retries are exhausted. + """ + deadline = timeout or _DEFAULT_HOME_TIMEOUT + + for _attempt in range(_HOMING_RETRIES): + state = read_motor_state(engine, address) + if int(state) < int(MotorState.COMMUTATED): + raise BravoError( + ErrorType.NOT_HOMED, + custom_text=f"Axis not commutated [{axis_name}]", + ) + if state == MotorState.DISABLED: + raise BravoError( + ErrorType.COULD_NOT_HOME, + custom_text=f"Motor cannot be disabled when homed [{axis_name}]", + ) + + engine.set_uint(address, GeminiSubCommands.HIDX_REC_DIST, 0) + set_motor_state(engine, address, MotorState.HOME) + + start = monotonic() + state = read_motor_state(engine, address) + while int(state) >= int(MotorState.HOME) and int(state) < int(MotorState.READY): + sleep(poll) + elapsed = monotonic() - start + if elapsed > deadline: + raise BravoError( + ErrorType.COULD_NOT_HOME, + custom_text=f"Axis homing timeout [{axis_name}]", + ) + state = read_motor_state(engine, address) + + if int(state) < int(MotorState.HOME): + # State regressed below HOME (e.g. back to COMMUTATED) -- re-commutate + # and retry. + commutate( + engine, + address, + axis_name, + timeout=commutate_timeout, + poll=poll, + get_estop_engaged=get_estop_engaged, + ) + continue + return + + raise BravoError( + ErrorType.COULD_NOT_HOME, + custom_text=f"Axis homing retries exceeded [{axis_name}]", + ) + + +# --- Initialize (commutate + home) ----------------------------------------------- + + +def is_initialized(engine: GeminiEngine, address: InstructionAddress, timeout: float = 5.0) -> bool: + """Return whether the axis has been commutated and homed. + + Re-homing an already-initialized axis requires disabling it first, or the + controller NAKs with ``MOVE_IN_PROGRESS``. + + Args: + engine: The Gemini engine to read through. + address: The axis device's controller-tree address. + timeout: Maximum time to wait for the wire exchange, in seconds. + + Returns: + True if the axis's motor state is at or beyond ``READY``. + """ + state = read_motor_state(engine, address, timeout) + return int(state) >= int(MotorState.READY) + + +def initialize( + engine: GeminiEngine, + address: InstructionAddress, + axis_name: str, + *, + commutate_timeout: Optional[float] = None, + home_timeout: Optional[float] = None, + force: bool = False, + get_estop_engaged: Callable[[], bool] = lambda: False, +) -> None: + """Commutate and home the axis. Skips both if already initialized. + + An axis that reports itself initialized is left alone. Pass ``force=True`` + to home even an already-homed axis (requires disabling first). + + Args: + engine: The Gemini engine to drive the axis through. + address: The axis device's controller-tree address. + axis_name: The axis's display name, used in error messages. + commutate_timeout: Commutation timeout, in seconds. + home_timeout: Homing timeout, in seconds. + force: Re-run commutation and homing even if the axis already reports + itself initialized. + get_estop_engaged: Returns True if E-stop is engaged; forwarded to + :func:`commutate`. + """ + if not force and is_initialized(engine, address): + return + if force: + disable(engine, address, axis_name) + # Give the controller a moment to honor the disable. + sleep(0.05) + commutate( + engine, + address, + axis_name, + timeout=commutate_timeout, + get_estop_engaged=get_estop_engaged, + ) + home( + engine, + address, + axis_name, + timeout=home_timeout, + commutate_timeout=commutate_timeout, + get_estop_engaged=get_estop_engaged, + ) + + +# --- Enable / disable --------------------------------------------------------- + + +def is_enabled(engine: GeminiEngine, address: InstructionAddress, timeout: float = 5.0) -> bool: + """Return whether the axis's motor is currently enabled. + + Args: + engine: The Gemini engine to read through. + address: The axis device's controller-tree address. + timeout: Maximum time to wait for the wire exchange, in seconds. + + Returns: + True unless the axis's motor state is ``DISABLED``. + """ + state = read_motor_state(engine, address, timeout) + return state != MotorState.DISABLED + + +def enable( + engine: GeminiEngine, + address: InstructionAddress, + axis_name: str, + timeout: float = 5.0, +) -> None: + """Transition the axis from DISABLED to READY. A no-op if already non-disabled. + + Args: + engine: The Gemini engine to drive the axis through. + address: The axis device's controller-tree address. + axis_name: The axis's display name, used in error messages. + timeout: Maximum time to wait for the transition, in seconds; also used + as the per-poll wire-exchange timeout. + + Raises: + BravoError: If the axis does not leave the disable family of states + within ``timeout``. + """ + state = read_motor_state(engine, address, timeout) + if state != MotorState.DISABLED: + return + set_motor_state(engine, address, MotorState.ENABLE, timeout) + start = monotonic() + while True: + sleep(_STATE_POLL) + state = read_motor_state(engine, address, timeout) + if state not in (MotorState.DISABLED, MotorState.DISABLE, MotorState.ENABLE): + return + if monotonic() - start > timeout: + raise BravoError( + ErrorType.COULD_NOT_ENABLE_MOTOR, + custom_text=f"Motor enable timeout [{axis_name}]", + ) + + +def disable( + engine: GeminiEngine, + address: InstructionAddress, + axis_name: str, # noqa: ARG001 - kept for a uniform axis-operation signature + timeout: float = 5.0, +) -> None: + """Transition the axis to DISABLED. Fire-and-forget; does not wait. + + Args: + engine: The Gemini engine to drive the axis through. + address: The axis device's controller-tree address. + axis_name: The axis's display name; unused, kept so every axis + operation shares the same signature shape. + timeout: Maximum time to wait for the wire exchange, in seconds. + """ + set_motor_state(engine, address, MotorState.DISABLE, timeout) + + +def reset_faults(engine: GeminiEngine, address: InstructionAddress, timeout: float = 5.0) -> None: + """Clear axis fault state. + + A no-op on Darwin. Kept so callers do not have to special-case it. + + Args: + engine: The Gemini engine (unused). + address: The axis device's controller-tree address (unused). + timeout: Unused. + """ + del engine, address, timeout + + +__all__ = [ + "AxisTimeouts", + "commutate", + "disable", + "enable", + "home", + "initialize", + "is_enabled", + "is_initialized", + "read_motor_state", + "reset_faults", + "set_motor_state", + "timeouts_for", +] diff --git a/pylabrobot/agilent/bravo/darwin/calibration.py b/pylabrobot/agilent/bravo/darwin/calibration.py new file mode 100644 index 00000000000..dbadee2ec39 --- /dev/null +++ b/pylabrobot/agilent/bravo/darwin/calibration.py @@ -0,0 +1,168 @@ +"""Per-axis calibration constants for Darwin. + +Hardware envelopes are hard-coded per axis below. Velocity and acceleration +limits are computed at runtime by reading ``ParamDBs.SPEED`` and +``ParamDBs.ACCELERATION`` from each device (see :func:`read_motion_limits`). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Optional + +from ..protocol.gemini.enums import ParamDBs +from ..types import Axis +from .params import ParameterAccess + + +@dataclass(frozen=True) +class AxisCalibration: + """Hardware envelope, software limits, and calibration offset for one axis. + + Positions on the wire are normalized 0-1 against the hardware range:: + + normalized = (position - calibration_offset - hardware_min) / hardware_range + + Software limits (:attr:`software_min`/:attr:`software_max`) are enforced + before any move command is sent. Targeting a position outside + ``[software_min, software_max]`` is rejected with a ``ValueError`` -- this + prevents accidents like driving G to its hardware minimum, which can walk + the gripper fingers off their rail. + + Software limits default to 0.07 mm inside the hardware range. + + Attributes: + hardware_min: The axis's hardware travel minimum, in mm (or uL for W). + hardware_max: The axis's hardware travel maximum. + park_position: The position the axis reports once homing completes. + calibration_offset: Offset applied between normalized and physical + units, set from the instrument profile at runtime. + software_min: The enforced move-target minimum, or ``None`` to default + to ``hardware_min + 0.07``. + software_max: The enforced move-target maximum, or ``None`` to default + to ``hardware_max - 0.07``. + """ + + hardware_min: float + hardware_max: float + park_position: float = 0.0 + calibration_offset: float = 0.0 + software_min: Optional[float] = None + software_max: Optional[float] = None + + @property + def hardware_range(self) -> float: + """The axis's total hardware travel span.""" + return self.hardware_max - self.hardware_min + + @property + def effective_software_min(self) -> float: + """The enforced move-target minimum, falling back to ``hardware_min + 0.07``.""" + return self.software_min if self.software_min is not None else self.hardware_min + 0.07 + + @property + def effective_software_max(self) -> float: + """The enforced move-target maximum, falling back to ``hardware_max - 0.07``.""" + return self.software_max if self.software_max is not None else self.hardware_max - 0.07 + + def to_normalized(self, position: float) -> float: + """Convert a physical position to the wire's normalized 0-1 units. + + Args: + position: The physical position, in mm (or uL for W). + + Returns: + The normalized position. + """ + return (position - self.calibration_offset - self.hardware_min) / self.hardware_range + + def from_normalized(self, normalized: float) -> float: + """Convert a normalized 0-1 wire value back to physical units. + + Args: + normalized: The normalized position, as read from the wire. + + Returns: + The physical position, in mm (or uL for W). + """ + return normalized * self.hardware_range + self.calibration_offset + self.hardware_min + + def validate_target(self, position_mm: float, axis_name: str) -> None: + """Raise if a move target falls outside the software limits. + + Args: + position_mm: The proposed target position, in mm (or uL for W). + axis_name: The axis's display name, used in the error message. + + Raises: + ValueError: If ``position_mm`` is outside + ``[effective_software_min, effective_software_max]``. + """ + lo = self.effective_software_min + hi = self.effective_software_max + if not lo <= position_mm <= hi: + raise ValueError( + f"Move target {position_mm:.4f} mm on axis {axis_name} is " + f"outside software limits [{lo:.4f}, {hi:.4f}]. " + f"Pass a value inside this range." + ) + + +# W axis is handled separately because its limits vary by head type. +# Software limits for the G axis are TIGHTER than the default hw+-0.07 margin +# would produce. Driving G too close to hardware_min walks the gripper +# fingers off their rail; driving too close to hardware_max can jam them +# closed. The instrument's own G software floors are [-7.513, 13.513]; the +# values below add extra safety margin on the minimum side. +DEFAULT_CALIBRATION: Dict[Axis, AxisCalibration] = { + "y": AxisCalibration(hardware_min=-43.4, hardware_max=274.1, park_position=115.443), + "x": AxisCalibration(hardware_min=-118.375, hardware_max=516.625, park_position=193.04), + "z": AxisCalibration(hardware_min=-50.0, hardware_max=200.0, park_position=0.0), + "g": AxisCalibration( + hardware_min=-7.583, + hardware_max=13.583, + park_position=0.0, + software_min=-7.0, # Conservative: full open without rail walk-off. + software_max=13.0, + ), + "zg": AxisCalibration(hardware_min=-74.5, hardware_max=179.5, park_position=0.0), +} + + +@dataclass(frozen=True) +class MotionLimits: + """Derived velocity and acceleration ceilings, read from device parameters. + + Attributes: + velocity: Velocity ceiling, in engineering units per second (mm/s for + linear axes, uL/s for W). + acceleration: Acceleration ceiling, in engineering units per second + squared. + """ + + velocity: float + acceleration: float + + +def read_motion_limits(params: ParameterAccess, calibration: AxisCalibration) -> MotionLimits: + """Read SPEED/ACCELERATION parameters and scale by the axis's hardware range. + + The device stores SPEED/ACCELERATION as fractions of full travel:: + + velocity_limit = param(SPEED) * hardware_range + acceleration_limit = param(ACCELERATION) * hardware_range + + Args: + params: The parameter accessor for the axis's device. + calibration: The axis's calibration, for its hardware range. + + Returns: + The axis's velocity and acceleration ceilings in engineering units. + """ + speed_frac = params.read_float(int(ParamDBs.SPEED)) + accel_frac = params.read_float(int(ParamDBs.ACCELERATION)) + rng = calibration.hardware_range + return MotionLimits( + velocity=speed_frac * rng, + acceleration=accel_frac * rng, + ) diff --git a/pylabrobot/agilent/bravo/darwin/controller.py b/pylabrobot/agilent/bravo/darwin/controller.py new file mode 100644 index 00000000000..5ae3ca7577e --- /dev/null +++ b/pylabrobot/agilent/bravo/darwin/controller.py @@ -0,0 +1,1480 @@ +"""Pure-Python controller for Darwin-generation Bravo instruments. + +Implements :class:`~..controllers.base.BravoController` on top of +:class:`~..protocol.gemini.engine.GeminiEngine` and the ``darwin.*`` +modules: the controller speaks the Gemini wire protocol directly over an +already-connected transport, with no external helper process. + +Scope: + - initialize / deinitialize / ping / is_connected + - firmware version read + - enable / disable motors (per axis) + - home_axes (commutate + home each axis) + - move (single- and multi-axis, mm/s units on input) + - query_state (E-stop + go-button) + - clear_go_button + - get_position / is_axis_homed / get_park_position + - set_light / clear_lights + - detect_smart_head / read_smart_head_type / read_head_adc + - grip / open_gripper / jog (composite sequences) + - detect_gripper / is_plate_in_gripper / read_plate_sensor + - scan_stack_with_gripper / send_command / reset_faults +""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass, replace +from typing import Any, Dict, List, Optional + +from ..axis_config import AxisConfig +from ..controllers.base import AxisMoveInfo, BravoController, FirmwareVersion, JogParams +from ..errors import BravoError, ErrorType +from ..protocol.commands import CommandID, LightCommandData +from ..protocol.gemini.engine import GeminiEngine +from ..protocol.gemini.enums import ( + AxisDirection, + CommandNAKTypes, + CommonSubCommands, + DarwinMasterNodeSubCommands, + GeminiSubCommands, + InstructionTypes, + MotorState, + ParamDBs, +) +from ..protocol.gemini.errors import NAKError +from ..protocol.gemini.packet import MASTER_ADDRESS, InstructionAddress +from ..transport import Transport +from ..transport.serial import SerialTransport +from ..types import ( + GRIP_POSITION_TOLERANCE, + OPEN_GRIPPER_POSITION, + TICKS_PER_MM, + Axis, + DeviceStateFlag, + GripperDetectionState, + HeadType, + SpeedLevel, + axis_display_name, + safe_home_order, +) +from . import axis as axis_module +from . import motion, sequences +from .calibration import DEFAULT_CALIBRATION, AxisCalibration, MotionLimits, read_motion_limits +from .params import ParameterAccess +from .topology import all_axes, axis_address +from .waxis_config import config_for_head, ul_to_mm +from .waxis_params import apply_waxis_parameters + +logger = logging.getLogger(__name__) + + +# Axes for which the device's I2T_PEAK_CURRENT is cached on connect so +# grip/jog/force-moves can scale from the original max -- i.e. whatever +# value the firmware booted with, read once per session. +_PEAK_CURRENT_AXES: tuple = ("g", "z", "zg", "w") + +# SpeedLevel -> grip velocity, mm/s. Levels with no explicit entry use the +# 500.0 mm/s default. +_GRIP_SPEED_MM: Dict[str, float] = {"fast": 1000.0, "slow": 1.0} + + +def _motion_timeout( + distance_mm: float, + velocity_mm_per_s: float, + min_s: float = 6.0, + margin_s: float = 5.0, +) -> float: + """Compute a safe move timeout from travel distance and speed. + + The timeout is travel time plus ``margin_s``, floored at ``min_s``. The + minimum speed clamp (0.1 mm/s) prevents a divide-by-zero for a no-op + move. + + Args: + distance_mm: The travel distance, in mm. + velocity_mm_per_s: The move velocity, in mm/s. + min_s: The minimum timeout to return, in seconds. + margin_s: Extra time added on top of the computed travel time, in + seconds. + + Returns: + The computed timeout, in seconds. + """ + speed = max(abs(velocity_mm_per_s), 0.1) + travel_s = abs(distance_mm) / speed + return max(min_s, travel_s + margin_s) + + +@dataclass +class _AxisState: + """Per-axis runtime state the controller keeps between calls. + + Attributes: + calibration: The axis's normalized-position calibration. + limits: The axis's velocity/acceleration ceilings, lazily read from + the device and cached. + params: The parameter accessor for the axis's device, created once + the controller is initialized. + last_command: A diagnostic record of the axis's most recent move + request. + peak_current_max: The device's ``I2T_PEAK_CURRENT`` value cached at + connect time, used as the reference max for force-move sequences. + """ + + calibration: AxisCalibration + limits: Optional[MotionLimits] = None + params: Optional[ParameterAccess] = None + last_command: Optional[Dict[str, Any]] = None + peak_current_max: Optional[float] = None + + +class DarwinController(BravoController): + """Darwin-generation Bravo controller over the pure-Python Gemini protocol. + + Constructed around an already-connected + :class:`~..transport.base.Transport`; call :meth:`initialize` once the + transport is set up, and :meth:`deinitialize` to release the engine's + receive thread before the transport is torn down. + """ + + has_gripper = True + model_name = "Bravo Darwin" + + def __init__( + self, + transport: Transport, + plate_sensor_transient: float = 0.3, + axis_config: Optional[Dict[Axis, AxisConfig]] = None, + ): + """Bind this controller to a transport, performing no I/O. + + Args: + transport: The already-connected transport this controller + communicates over. Must not be a :class:`~..transport.serial.SerialTransport` + -- Darwin's controller tree is reachable only over TCP. + plate_sensor_transient: How long to allow a plate-sensor reading to + settle before treating it as final, in seconds. Used as the + default for :meth:`is_plate_in_gripper`. + axis_config: Per-axis travel-range overrides, keyed by axis. Only + :attr:`~..axis_config.AxisConfig.range` is used, to override the + corresponding axis's hardware travel limits in + :data:`~.calibration.DEFAULT_CALIBRATION`; an axis with no entry + keeps its default calibration. + + Raises: + BravoError: If ``transport`` is a :class:`~..transport.serial.SerialTransport`. + """ + if isinstance(transport, SerialTransport): + raise BravoError( + ErrorType.NODEZERO_NO_SERIAL_COMM, + custom_text="Darwin does not support serial transport; use a TCP-based transport.", + ) + super().__init__(transport) + self._engine = GeminiEngine(transport) + self._plate_sensor_transient = plate_sensor_transient + self._axis_config_overrides: Dict[Axis, AxisConfig] = axis_config or {} + self._connected = False + self._last_error: Optional[BravoError] = None + self._head_type: HeadType = "unknown" + self._waxis_applied_head: Optional[HeadType] = None + self._axes: Dict[Axis, _AxisState] = {} + # State-snapshot cache -- shape is fixed by what higher layers expect + # from get_state_snapshot(). + self._last_snapshot: Optional[Dict[str, Any]] = None + self._last_snapshot_at: float = 0.0 + self._init_axis_state() + + def _init_axis_state(self) -> None: + """Build per-axis scaffolding from :data:`~.calibration.DEFAULT_CALIBRATION`. + + The W axis has no single calibration in + :data:`~.calibration.DEFAULT_CALIBRATION`: it gets a placeholder here, + and its real limits are loaded once W-axis parameters are applied for + the current head type (see :meth:`set_head_type`). + """ + for a, cal in DEFAULT_CALIBRATION.items(): + self._axes[a] = _AxisState(calibration=self._override_calibration(a, cal)) + self._axes["w"] = _AxisState( + calibration=self._override_calibration( + "w", + AxisCalibration( + hardware_min=-16.48, + hardware_max=63.52, # 8_d_lt defaults; re-applied per head. + ), + ) + ) + + def _override_calibration(self, axis: Axis, cal: AxisCalibration) -> AxisCalibration: + """Apply this controller's axis-config travel-range override, if any. + + Args: + axis: The axis to look up an override for. + cal: The axis's default calibration. + + Returns: + ``cal`` unchanged, or a copy with its hardware range replaced from + the matching :class:`~..axis_config.AxisConfig` entry. + """ + cfg = self._axis_config_overrides.get(axis) + if cfg is None: + return cal + return replace(cal, hardware_min=cfg.range.min_pos, hardware_max=cfg.range.max_pos) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def initialize(self) -> None: + """Start the engine's receive thread and bring every axis's bookkeeping up. + + Starts the Gemini engine's background receive thread, then for every + axis: creates its :class:`~.params.ParameterAccess`, clears its + instruction table (the controller preserves instruction-table state + and event bindings across TCP sessions, so residual bindings from a + prior session could otherwise interfere with this session's + START_EVT/SEND_EVT values on event 1), and, for axes whose force-move + sequences scale off it, caches the device's current + ``I2T_PEAK_CURRENT``. + """ + self._engine.start_receiving() + self._connected = True + for a, state in self._axes.items(): + if state.params is None: + state.params = ParameterAccess(self._engine, axis_address(a)) + try: + self._engine.set_uint(axis_address(a), GeminiSubCommands.INSTR_CLEAR, 0, 2.0) + except Exception as exc: + logger.warning("INSTR_CLEAR on %s failed (non-fatal): %s", axis_display_name(a), exc) + if a in _PEAK_CURRENT_AXES: + try: + state.peak_current_max = state.params.read_float(int(ParamDBs.I2T_PEAK_CURRENT), 2.0) + logger.info( + "Cached %s I2T_PEAK_CURRENT = %.6f (reference for force-scaling)", + axis_display_name(a), + state.peak_current_max, + ) + except Exception as exc: + logger.warning("Could not read %s I2T_PEAK_CURRENT: %s", axis_display_name(a), exc) + state.peak_current_max = None + + def deinitialize(self) -> None: + """Stop the engine's receive thread. Does not touch the transport.""" + self._connected = False + self._engine.stop_receiving() + + def ping(self) -> bool: + """Read the master's safety status as a liveness probe.""" + try: + self._engine.master_get_uint(DarwinMasterNodeSubCommands.SAFETY_STATUS, 2.0) + return True + except Exception as exc: + logger.debug("Darwin ping failed: %s", exc) + return False + + @property + def is_connected(self) -> bool: + """Whether the controller is initialized and the engine's transport is connected.""" + return self._connected and self._engine.is_connected + + # ------------------------------------------------------------------ + # Firmware + # ------------------------------------------------------------------ + + def get_firmware_version(self) -> FirmwareVersion: + """Read the firmware version from the master and each controller node.""" + + def _read_version(addr: InstructionAddress) -> str: + """Read and format one node's packed firmware-version word. + + Args: + addr: The node's controller-tree address. + + Returns: + The version as ``"major.minor.patch"``, or the empty string if the + read failed. + """ + try: + packed = self._engine.get_value(addr, CommonSubCommands.FW_VERSION, 5.0) + except Exception: + return "" + major = (packed >> 24) & 0xFF + minor = (packed >> 16) & 0xFF + patch = packed & 0xFFFF + return f"{major}.{minor}.{patch}" + + master = _read_version(MASTER_ADDRESS) + xy = _read_version(InstructionAddress(4)) + zw = _read_version(InstructionAddress(5)) + gzg = _read_version(InstructionAddress(6)) + return FirmwareVersion(master=master, sub1=f"YX={xy} ZW={zw}", sub2=f"GZg={gzg}") + + # ------------------------------------------------------------------ + # Motion limits cache (lazy-populated per axis) + # ------------------------------------------------------------------ + + def _limits(self, axis: Axis) -> MotionLimits: + """Return an axis's velocity/acceleration ceilings, reading them once. + + Args: + axis: The axis to look up. + + Returns: + The axis's cached (or freshly read) motion limits. + + Raises: + BravoError: If the axis has no parameter accessor yet (the + controller has not been initialized). + """ + state = self._axes[axis] + if state.limits is None: + if state.params is None: + raise BravoError(ErrorType.COULD_NOT_CONNECT) + state.limits = read_motion_limits(state.params, state.calibration) + return state.limits + + def invalidate_limits(self) -> None: + """Force every axis's motion limits to be re-read on next use.""" + for state in self._axes.values(): + state.limits = None + + # ------------------------------------------------------------------ + # Per-axis helpers + # ------------------------------------------------------------------ + + def _ensure_axis_enabled(self, axis: Axis) -> None: + """Enable an axis's motor if it is currently disabled. + + Args: + axis: The axis to ensure is enabled. + """ + addr = axis_address(axis) + if not axis_module.is_enabled(self._engine, addr): + axis_module.enable(self._engine, addr, axis_display_name(axis)) + + def _ensure_waxis_params(self) -> None: + """Write W-axis parameters if the head type changed since the last apply.""" + if self._head_type == "unknown" or self._head_type == self._waxis_applied_head: + return + w_params = self._axes["w"].params + if w_params is None: + return + applied = apply_waxis_parameters(w_params, self._head_type) + if applied: + self._waxis_applied_head = self._head_type + self.invalidate_limits() + + # ------------------------------------------------------------------ + # Motion -- move, home + # ------------------------------------------------------------------ + + def move(self, moves: List[AxisMoveInfo], wait: bool = True, timeout: float = 30.0) -> None: + """Execute a coordinated multi-axis move. + + Args: + moves: The per-axis targets to move to together. + wait: Whether to block until the move finishes. + timeout: Maximum time to wait for the move to finish, in seconds. + + Raises: + ValueError: If an absolute target falls outside an axis's software + limits. + BravoError: If a target axis has not completed commutation and + homing. + """ + if not moves: + return + # Pre-flight: validate every absolute target against software limits + # before enabling motors or sending any packets, even if the + # controller isn't connected -- this prevents driving an axis past + # safe bounds regardless of hardware state. + for m in moves: + state = self._axes[m.axis] + if m.absolute: + state.calibration.validate_target(m.position, axis_display_name(m.axis)) + if any(m.axis == "w" for m in moves): + self._ensure_waxis_params() + # Enable all target axes AND verify each is past commutate+home. An + # uninitialized axis (motor state below READY) can neither accept a + # move instruction nor echo SEND_EVT, so without this check the move + # would silently block for the full timeout -- which masks the real + # cause (motion requested before homing that axis). Fail fast instead. + for m in moves: + self._ensure_axis_enabled(m.axis) + motor_state = axis_module.read_motor_state(self._engine, axis_address(m.axis), 2.0) + if int(motor_state) < int(MotorState.READY): + raise BravoError( + ErrorType.COULD_NOT_MOVE_TO_POSITION, + custom_text=( + f"{axis_display_name(m.axis)} axis not initialized (motor state " + f"{motor_state.name}); home the axis before issuing a move." + ), + ) + + requests: List[motion.MoveRequest] = [] + for m in moves: + state = self._axes[m.axis] + limits = self._limits(m.axis) + velocity_pct = ( + 100.0 + if (m.velocity <= 0 or limits.velocity <= 0) + else min(100.0, m.velocity * 100.0 / limits.velocity) + ) + accel_pct = ( + 100.0 + if (m.acceleration <= 0 or limits.acceleration <= 0) + else min(100.0, m.acceleration * 100.0 / limits.acceleration) + ) + + # Every move is normalized to MOVE_TO (absolute) semantics, computing + # an absolute target and direction-from-current: collapsing both move + # flavors onto the same MOVE_TO wire shape avoids a direction-encoding + # class of bug that a MOVE_BY path is prone to. + if m.absolute: + target_mm = m.position + else: + current = self.get_position(m.axis) + target_mm = current + m.position + state.calibration.validate_target(target_mm, axis_display_name(m.axis)) + normalized = state.calibration.to_normalized(target_mm) + + current_normalized = state.calibration.to_normalized(self.get_position(m.axis)) + direction = ( + AxisDirection.NEGATIVE if normalized < current_normalized else AxisDirection.POSITIVE + ) + requests.append( + motion.MoveRequest( + address=axis_address(m.axis), + axis_name=axis_display_name(m.axis), + target_normalized=normalized, + velocity_percent=velocity_pct, + acceleration_percent=accel_pct, + instr_type=InstructionTypes.MOVE_TO, + direction=direction, + ) + ) + state.last_command = { + "mode": "absolute" if m.absolute else "relative", + "position": m.position, + "velocity_mm": m.velocity, + "velocity_pct": velocity_pct, + "acceleration_mm": m.acceleration, + "acceleration_pct": accel_pct, + } + + motion.move_multi(self._engine, requests, wait=wait, timeout=timeout) + + def home_axes(self, axes: List[Axis], *, force: bool = False) -> None: + """Commutate and home each axis, in safe order. + + Without ``force`` an axis that already reports itself initialized is + left alone -- that is what makes start-up cheap when the instrument is + already up. An explicit operator home must pass ``force=True``. + + Args: + axes: The axes to home. + force: Re-run commutation and homing even for an axis that already + reports itself initialized. + """ + for a in safe_home_order(axes): + if a == "w": + self._ensure_waxis_params() + addr = axis_address(a) + t = axis_module.timeouts_for(a) + try: + axis_module.initialize( + self._engine, + addr, + axis_display_name(a), + commutate_timeout=t.commutate, + home_timeout=t.home, + force=force, + get_estop_engaged=self._is_estop_engaged, + ) + except BravoError as exc: + self._set_error(exc) + raise + + def get_position(self, axis: Axis) -> float: + """Return an axis's current position, in mm (or uL for W).""" + addr = axis_address(axis) + normalized = self._engine.get_float(addr, GeminiSubCommands.POSITION) + return self._axes[axis].calibration.from_normalized(normalized) + + def is_axis_homed(self, axis: Axis) -> bool: + """Return whether an axis has completed commutation and homing. + + This is the axis's initialized state (motor state at or beyond READY), + not the raw home-flag sensor reading -- the sensor reads True any time + the axis happens to sit near its flag, including on a cold start, + which would make a caller skip homing an axis that is merely parked + near its sensor. + """ + try: + return axis_module.is_initialized(self._engine, axis_address(axis)) + except Exception: + return False + + def get_park_position(self, axis: Axis) -> float: + """Return an axis's configured park position, in mm.""" + return self._axes[axis].calibration.park_position + + # ------------------------------------------------------------------ + # Motor control + # ------------------------------------------------------------------ + + def enable_motor(self, axis: Axis) -> None: + """Enable an axis's motor drive.""" + self._ensure_axis_enabled(axis) + + def disable_motor(self, axis: Axis) -> None: + """Disable an axis's motor drive.""" + axis_module.disable(self._engine, axis_address(axis), axis_display_name(axis)) + + def reset_faults(self, axes: List[Axis]) -> None: + """Clear latched fault state on the given axes (a no-op on Darwin).""" + for a in axes: + axis_module.reset_faults(self._engine, axis_address(a)) + + def is_motor_enabled(self, axis: Axis) -> bool: + """Return whether an axis's motor is currently enabled.""" + return axis_module.is_enabled(self._engine, axis_address(axis)) + + # ------------------------------------------------------------------ + # Device state + # ------------------------------------------------------------------ + + def _is_estop_engaged(self) -> bool: + """Return whether the master's safety status reports E-stop engaged.""" + try: + status = self._engine.master_get_uint(DarwinMasterNodeSubCommands.SAFETY_STATUS, 2.0) + except Exception: + return False + return bool(status & 0x01) + + def query_state(self) -> DeviceStateFlag: + """Return the device's current state flags (currently just E-stop).""" + flags = DeviceStateFlag(0) + try: + status = self._engine.master_get_uint(DarwinMasterNodeSubCommands.SAFETY_STATUS, 2.0) + except Exception: + return flags + if status & 0x01: + flags |= DeviceStateFlag.ROBOT_DISABLE + return flags + + def is_go_button_pressed(self) -> bool: + """Return whether the Go button flag is set in :meth:`query_state`.""" + state = self.query_state() + return bool(state & DeviceStateFlag.GO_BUTTON) + + def clear_go_button(self) -> None: + """Clear the latched Go-button-pressed state.""" + self._engine.master_set_uint(DarwinMasterNodeSubCommands.CLEAR_GO_BTN_LATCH, 1, 2.0) + + # ------------------------------------------------------------------ + # Safety / recovery + # ------------------------------------------------------------------ + + def recover(self, axes: Optional[List[Axis]] = None) -> Dict[Axis, str]: + """Recover from a safety-trip event. + + Confirms safety status is clear, then re-enables any axis whose motor + state is DISABLED -- the state Darwin transitions axes to after a + safety event. + + Args: + axes: The axes to attempt recovery on. Defaults to every axis. + + Returns: + A per-axis dict describing what action was taken: ``"enabled"`` (the + axis was disabled, now enabled), ``"ok"`` (already enabled), or + ``"skipped"``/``"failed: ..."`` for a transient read or enable + failure. + + Raises: + BravoError: If the safety interlock is still active. + """ + if axes is None: + axes = list(all_axes()) + + if self._is_estop_engaged(): + raise BravoError( + ErrorType.ROBOT_DISABLE, + custom_text=( + "Cannot recover: safety interlock still active " + "(SAFETY_STATUS bit 0 set). Clear the light curtain / " + "release E-stop, then retry." + ), + ) + + result: Dict[Axis, str] = {} + for a in axes: + addr = axis_address(a) + try: + state = axis_module.read_motor_state(self._engine, addr) + except Exception as exc: + logger.warning("recover: read state on %s failed: %s", axis_display_name(a), exc) + result[a] = "skipped" + continue + + if state == MotorState.DISABLED: + try: + axis_module.enable(self._engine, addr, axis_display_name(a)) + result[a] = "enabled" + except Exception as exc: + logger.warning("recover: enable %s failed: %s", axis_display_name(a), exc) + result[a] = f"failed: {exc}" + else: + result[a] = "ok" + return result + + # ------------------------------------------------------------------ + # Lights + # ------------------------------------------------------------------ + + def set_light(self, command: LightCommandData) -> None: + """Set the indicator light to the given color, blink period, and duty cycle.""" + encoded = _encode_light_value(command) + self._engine.master_set_uint(DarwinMasterNodeSubCommands.STATUS_LIGHTS, encoded, 2.0) + + def clear_lights(self) -> None: + """Turn the indicator light off.""" + self._engine.master_set_uint(DarwinMasterNodeSubCommands.STATUS_LIGHTS, 0, 2.0) + + # ------------------------------------------------------------------ + # Head / gripper detection + # ------------------------------------------------------------------ + + def read_head_adc(self) -> int: + """Read the ADC-based head-count register. + + For resistor-based heads this value identifies the head. For smart + heads the value is still readable but the smart-head EEPROM is + authoritative. + """ + return self._engine.master_get_uint(DarwinMasterNodeSubCommands.STUPID_HEAD_COUNTS, 2.0) + + def detect_smart_head(self) -> bool: + """Return whether a smart head (with onboard PIC/EEPROM) is attached. + + Sends a smart-init request to the master. Success means a smart head + responded; an ``UNSUCCESSFUL_OPERATION`` NAK means no smart head is + present. + """ + try: + self._engine.master_set_uint(DarwinMasterNodeSubCommands.SMART_INIT, 0, 2.0) + return True + except NAKError as exc: + if exc.nak == CommandNAKTypes.UNSUCCESSFUL_OPERATION: + return False + raise + + def read_smart_head_type(self) -> int: + """Read the head-type byte from smart-head EEPROM offset 1. + + Call :meth:`detect_smart_head` first -- this raises if no smart head is + present. + """ + self._engine.master_set_uint(DarwinMasterNodeSubCommands.SMART_RD_EEPROM, (1 << 8) | 1, 2.0) + value = self._engine.master_get_uint(DarwinMasterNodeSubCommands.SMART_RD_EEPROM_VAL, 2.0) + return value & 0xFF + + def detect_head_type(self) -> HeadType: + """Return ``"unknown"``: the firmware's head-type byte has no verified mapping. + + The EEPROM byte read from a smart head is a firmware-side encoding + distinct from this driver's :data:`~..types.HeadType` values, and no + verified mapping between the two exists. Returning a value derived + directly from the byte would produce a confident but incorrect answer. + Use :meth:`read_head_identification` for the raw byte instead. + + Returns: + Always ``"unknown"``. + """ + return "unknown" + + def read_head_identification(self) -> Dict[str, Any]: + """Read raw head-identification data without interpreting it. + + Returns: + A dict with ``"eeprom_byte"`` (the smart-head EEPROM byte, or + ``None`` if no smart head responded), ``"adc_counts"`` (the + resistor-based head-count register), and ``"has_smart_head"``. + """ + has_smart = self.detect_smart_head() + eeprom_byte = self.read_smart_head_type() if has_smart else None + adc_counts = self.read_head_adc() + return { + "eeprom_byte": eeprom_byte, + "adc_counts": adc_counts, + "has_smart_head": has_smart, + } + + def detect_gripper(self) -> GripperDetectionState: + """Return whether the gripper accessory is currently detected. + + Presence is proven by reading the firmware version from the + controller-tree sub-node that owns the Zg device: a successful read is + sufficient liveness proof that the gripper sub-node is on the bus. + """ + try: + packed = self._engine.get_value(InstructionAddress(6), CommonSubCommands.FW_VERSION, 2.0) + except Exception as exc: + logger.debug("detect_gripper: FW_VERSION read failed: %s", exc) + return GripperDetectionState.NOT_DETECTED + return GripperDetectionState.DETECTED if packed else GripperDetectionState.NOT_DETECTED + + def grip(self, speed: SpeedLevel, position: float, grip_lid: bool = False) -> None: + """Close the gripper jaws to the given position.""" + g_addr = axis_address("g") + g_state = self._axes["g"] + # Validate the target before any connection work so pre-flight checks + # catch unsafe values. + g_state.calibration.validate_target(position, "G") + if g_state.params is None: + raise BravoError(ErrorType.COULD_NOT_CONNECT) + self._ensure_axis_enabled("g") + g_limits = self._limits("g") + cal = g_state.calibration + + velocity_mm = _GRIP_SPEED_MM.get(speed, 500.0) + # Grip current in amps: 0.3A for lids, 0.2A for plates. These feed the + # instruction-word force_percent; I2T_PEAK_CURRENT is not written (see + # sequences.grip -- the axis runs with firmware defaults). + grip_current_amps = 0.3 if grip_lid else 0.2 + overshoot_normalized = 4.0 / cal.hardware_range + sequences.grip( + self._engine, + g_addr, + g_state.params, + sequences.GripParams( + target_position=cal.to_normalized(position), + velocity_limit=g_limits.velocity, + acceleration_limit=g_limits.acceleration, + grip_current_amps=grip_current_amps, + overshoot_normalized=overshoot_normalized, + velocity_mm=velocity_mm, + acceleration_mm=500.0, + ), + ) + + def open_gripper(self, position: Optional[float] = None) -> None: + """Open the gripper jaws.""" + g_addr = axis_address("g") + g_state = self._axes["g"] + if g_state.params is None: + raise BravoError(ErrorType.COULD_NOT_CONNECT) + self._ensure_axis_enabled("g") + cal = g_state.calibration + limits = self._limits("g") + target_mm = OPEN_GRIPPER_POSITION if position is None else position + cal.validate_target(target_mm, "G") + current_mm = self.get_position("g") + if g_state.peak_current_max is None: + raise BravoError( + ErrorType.COULD_NOT_CONNECT, + custom_text="G axis peak-current reference not cached; reconnect", + ) + sequences.open_gripper( + self._engine, + g_addr, + g_state.params, + sequences.OpenGripperParams( + target_position=cal.to_normalized(target_mm), + current_position=cal.to_normalized(current_mm), + velocity_limit=limits.velocity, + acceleration_limit=limits.acceleration, + peak_current_amps=g_state.peak_current_max, + ), + ) + + def is_plate_in_gripper(self) -> bool: + """Report whether the plate-presence sensor detects a plate. + + Primary path reads the plate sensor with this controller's configured + settle time; if that fails, falls back to an "is G away from the open + position" heuristic so the caller always gets a bool. + """ + try: + return self.read_plate_sensor(transient=self._plate_sensor_transient) + except BravoError: + try: + pos_mm = self.get_position("g") + tol_mm = GRIP_POSITION_TOLERANCE / TICKS_PER_MM.get("g", 944.88) + return abs(pos_mm - OPEN_GRIPPER_POSITION) > tol_mm + except BravoError: + return False + + def jog(self, params: JogParams) -> float: + """Execute a force-controlled jog on the Z or G axis.""" + axis = params.axis + if axis not in ("z", "g"): + raise BravoError( + ErrorType.DARWIN_GENERIC, + custom_text=f"jog only supported on Z and G, got {axis_display_name(axis)}", + ) + addr = axis_address(axis) + state = self._axes[axis] + if state.params is None: + raise BravoError(ErrorType.COULD_NOT_CONNECT) + if state.peak_current_max is None: + raise BravoError( + ErrorType.COULD_NOT_CONNECT, + custom_text=f"{axis_display_name(axis)} axis peak-current reference not cached; reconnect", + ) + self._ensure_axis_enabled(axis) + cal = state.calibration + limits = self._limits(axis) + + def read_pos_normalized(engine: GeminiEngine, a: InstructionAddress) -> float: + """Return the axis's current position, already normalized on the wire.""" + return engine.get_float(a, GeminiSubCommands.POSITION) + + target_normalized = cal.to_normalized(params.max_position) + tolerance_normalized = params.tolerance / cal.hardware_range + + final_normalized = sequences.jog( + self._engine, + addr, + state.params, + sequences.JogParams( + axis_name=axis_display_name(axis), + target_position=target_normalized, + tolerance=tolerance_normalized, + peak_current_amps=params.peak_current, + velocity_mm=params.velocity, + acceleration_mm=params.acceleration, + velocity_limit=limits.velocity, + acceleration_limit=limits.acceleration, + # The "exceeded destination" check uses a 0.05 mm epsilon near the + # farthest point; convert to normalized axis units. + exceed_epsilon=0.05 / cal.hardware_range, + ), + read_position=read_pos_normalized, + ) + return cal.from_normalized(final_normalized) + + # ------------------------------------------------------------------ + # Plate sensor + stack scanning + # ------------------------------------------------------------------ + # + # Wire details: + # * The target device for the plate-present subcommand is the first + # device on the DarwinGZg node -- i.e. the G axis address (node=6, + # dev=0). + # * Enable: SET val=2 + # Disable: SET val=0 + # Read: GET -> uint; bit 0 = plate present + # * There is also a master-node "enable plate-presence sensor" + # property, but it has no effect on this firmware, so it is not + # used: only the G-device SET has actual wire effect. + + def _plate_sensor_enable(self, enabled: bool) -> bool: + """Enable or disable the plate-presence sensor on the G device. + + Args: + enabled: True to enable, False to disable. + + Returns: + True if the write succeeded, False otherwise. + """ + try: + self._engine.set_uint( + axis_address("g"), GeminiSubCommands.PLATE_PRESENT, 2 if enabled else 0, 5.0 + ) + return True + except Exception as exc: + logger.debug("plate-sensor enable=%s failed: %s", enabled, exc) + return False + + def _read_plate_sensor_state( + self, + *, + max_attempts: int = 1, + retry_delay: float = 0.0, + retry_until_present: bool = False, + ) -> Dict[str, Any]: + """Read the plate-sensor state, retrying per the given policy. + + Args: + max_attempts: Maximum number of read attempts. + retry_delay: Delay between attempts, in seconds. + retry_until_present: Whether to keep retrying once a read succeeds + but reports no plate present. + + Returns: + A dict with ``"read"`` (whether any attempt succeeded), ``"present"`` + (only meaningful when ``"read"`` is True), and ``"errors"`` (a list + of per-attempt failure descriptions). + """ + errors: List[str] = [] + read = False + present = False + attempts = max(1, max_attempts) + addr = axis_address("g") + for i in range(attempts): + value: Optional[int] = None + try: + value = self._engine.get_value(addr, GeminiSubCommands.PLATE_PRESENT, 5.0) + except Exception as exc: + errors.append(f"gripper_sensor_read={exc}") + if value is not None: + present = bool(value & 1) + read = True + if present or not retry_until_present: + break + if retry_delay > 0 and i < attempts - 1: + time.sleep(retry_delay) + return {"read": read, "present": present, "errors": errors} + + def read_plate_sensor(self, transient: float = 0.0) -> bool: + """Enable the plate sensor, wait ``transient``, read, disable. + + Args: + transient: How long to allow a transient sensor reading to settle, + in seconds, before treating it as final. + + Returns: + True if a plate is detected. + + Raises: + BravoError: If every attempt to read the sensor failed -- an + unreadable sensor must never be reported as "no plate". + """ + self._plate_sensor_enable(True) + try: + if transient > 0: + time.sleep(transient) + result = self._read_plate_sensor_state( + max_attempts=3, retry_delay=0.1, retry_until_present=True + ) + finally: + self._plate_sensor_enable(False) + if not result["read"]: + detail = ("; errors=" + " | ".join(result["errors"])) if result["errors"] else "" + raise BravoError( + ErrorType.COULD_NOT_QUERY_STATE, + custom_text=f"Could not read plate sensor state from G axis{detail}", + ) + return bool(result["present"]) + + def scan_stack_with_gripper( + self, + *, + start_zg: float, + end_zg: float, + speed: SpeedLevel, + transient: float = 0.0, + ) -> Dict[str, Any]: + """Scan the Zg axis between two heights until the plate sensor detects a stack top. + + Behavior: + + 1. Move Zg to ``start_zg`` (absolute). + 2. Enable the plate sensor, optionally sleep ``transient``. + 3. Initial read with 3 attempts / 100 ms delay / retry-until-present -- + if nothing reads, raise. + 4. If a plate is already detected at the start, back off upward in + 10 mm steps until the sensor clears (or Zg reaches -20 mm). + 5. Descend stepwise toward ``end_zg``; after each step, poll the + sensor with 3 attempts / 10 ms delay / retry-until-present. The + first "present" hit terminates with ``detected=True``. Reaching + ``end_zg`` without a hit returns ``detected=False``. + 6. Always disable the plate sensor in a ``finally`` block. + + Speed-dependent step size: fast=1.0, slow=0.25, else 0.5 mm. Velocity: + fast=20, slow=2, else 5 mm/s. Acceleration: min(80, axis acceleration + limit) mm/s^2. + + Args: + start_zg: Zg position to start the scan from, in mm. + end_zg: Zg position to stop the scan at if nothing is detected, in + mm. + speed: The speed profile to scan at. + transient: How long to allow a transient sensor reading to settle, + in seconds, before treating it as final. + + Returns: + A dict with ``"detected"`` (bool), ``"scan_mode"`` (str), + ``"elapsed_ms"``, ``"poll_count"``, ``"sensor_reads"``, + ``"sensor_read_failures"`` (ints), ``"positions"`` (per-axis mm), and + ``"telemetry"`` (per-axis diagnostics). + + Raises: + BravoError: If the plate sensor could never be read. + """ + self._ensure_axis_enabled("zg") + + if speed == "fast": + velocity_mm = 20.0 + step_mm = 1.0 + elif speed == "slow": + velocity_mm = 2.0 + step_mm = 0.25 + else: + velocity_mm = 5.0 + step_mm = 0.5 + + zg_limits = self._limits("zg") + accel_mm = min(80.0, zg_limits.acceleration if zg_limits.acceleration > 0 else 40.0) + if accel_mm <= 0.0: + accel_mm = 40.0 + + sensor_read_count = 0 + sensor_read_failures = 0 + sensor_read_errors: List[str] = [] + poll_count = 0 + detected = False + scan_started_at = time.monotonic() + + def _zg_move(target: float) -> None: + """Move Zg to ``target`` absolute, with a distance-scaled timeout.""" + distance = abs(target - self.get_position("zg")) + move_timeout = _motion_timeout(distance, velocity_mm, min_s=4.0, margin_s=1.0) + self.move( + [ + AxisMoveInfo( + axis="zg", position=target, velocity=velocity_mm, acceleration=accel_mm, absolute=True + ) + ], + wait=True, + timeout=move_timeout, + ) + + # Step 1: seek to start_zg. + start_distance = abs(start_zg - self.get_position("zg")) + start_timeout = _motion_timeout(start_distance, velocity_mm, min_s=6.0, margin_s=2.0) + self.move( + [ + AxisMoveInfo( + axis="zg", position=start_zg, velocity=velocity_mm, acceleration=accel_mm, absolute=True + ) + ], + wait=True, + timeout=start_timeout, + ) + + # Step 2+: enable sensor, then scan. + self._plate_sensor_enable(True) + try: + if transient > 0: + time.sleep(transient) + + initial = self._read_plate_sensor_state( + max_attempts=3, retry_delay=0.1, retry_until_present=True + ) + if not initial["read"]: + detail = "; errors=" + " | ".join(initial["errors"]) if initial["errors"] else "" + raise BravoError( + ErrorType.COULD_NOT_QUERY_STATE, + custom_text=f"Could not read plate sensor state from Darwin during scan{detail}", + ) + sensor_read_count += 1 + present = bool(initial["present"]) + + # Step 4: already on a plate? Back off upward in 10 mm chunks. + while present and self.get_position("zg") > -20.0: + target = max(-20.0, self.get_position("zg") - 10.0) + _zg_move(target) + back = self._read_plate_sensor_state(max_attempts=3, retry_delay=0.01) + if back["read"]: + sensor_read_count += 1 + present = bool(back["present"]) + else: + sensor_read_failures += 1 + for err in back["errors"]: + if len(sensor_read_errors) < 6: + sensor_read_errors.append(err) + if not present: + break + if target <= -20.0: + break + + # Step 5: descend stepwise toward end_zg, polling at each step. + while self.get_position("zg") < end_zg: + target = min(end_zg, self.get_position("zg") + step_mm) + _zg_move(target) + poll_count += 1 + step_read = self._read_plate_sensor_state( + max_attempts=3, retry_delay=0.01, retry_until_present=True + ) + if step_read["read"]: + sensor_read_count += 1 + if step_read["present"]: + detected = True + break + else: + sensor_read_failures += 1 + for err in step_read["errors"]: + if len(sensor_read_errors) < 6: + sensor_read_errors.append(err) + if target >= end_zg: + break + finally: + self._plate_sensor_enable(False) + + if sensor_read_count <= 0: + detail = "; errors=" + " | ".join(sensor_read_errors) if sensor_read_errors else "" + raise BravoError( + ErrorType.COULD_NOT_QUERY_STATE, + custom_text=f"Could not read plate sensor state from Darwin master during scan{detail}", + ) + + elapsed_ms = int((time.monotonic() - scan_started_at) * 1000) + self._last_snapshot = None # Positions changed. + return { + "detected": bool(detected), + "scan_mode": "stepwise_hot_sensor", + "elapsed_ms": elapsed_ms, + "poll_count": poll_count, + "sensor_reads": sensor_read_count, + "sensor_read_failures": sensor_read_failures, + "positions": self.get_all_positions(), + "telemetry": self._axis_telemetry(), + } + + # ------------------------------------------------------------------ + # Bulk position + state snapshot + # ------------------------------------------------------------------ + + def get_all_positions(self) -> Dict[str, float]: + """Return the current position of every axis, in mm. + + A naive per-axis loop -- the Gemini protocol has no multipacket read + for position queries. + """ + out: Dict[str, float] = {} + for a in ("x", "y", "z", "w", "g", "zg"): + try: + out[axis_display_name(a)] = float(self.get_position(a)) + except Exception as exc: + logger.debug("get_all_positions: %s read failed: %s", axis_display_name(a), exc) + return out + + def _motor_states(self) -> Dict[str, bool]: + """Return each axis's enabled flag, keyed by display name.""" + out: Dict[str, bool] = {} + for a in ("x", "y", "z", "w", "g", "zg"): + try: + out[axis_display_name(a)] = bool(self.is_motor_enabled(a)) + except Exception: + out[axis_display_name(a)] = False + return out + + def _state_flags(self) -> int: + """Return a state bitfield: 0x01=E-stop, 0x02=motor power (any axis enabled). + + The Go-button bit stays 0: there is no verified on-wire subcommand + mapping for a live Go-button read, and the Go button is an operator- + advance input rather than a motion input. + """ + flags = 0 + if self._is_estop_engaged(): + flags |= 0x01 + if any(self._motor_states().values()): + flags |= 0x02 + return flags + + def _axis_telemetry(self) -> Dict[str, Dict[str, Any]]: + """Return per-axis diagnostics: position, enabled, limits, calibration, last command. + + Fields not cheaply observable without an extra round trip (measured + current, peak current beyond the cached reference, position error) are + omitted rather than faked. + """ + telem: Dict[str, Dict[str, Any]] = {} + for a in ("x", "y", "z", "w", "g", "zg"): + state = self._axes.get(a) + if state is None: + continue + cal = state.calibration + entry: Dict[str, Any] = { + "hardware_minimum": cal.hardware_min, + "hardware_maximum": cal.hardware_max, + "software_minimum": cal.effective_software_min, + "software_maximum": cal.effective_software_max, + } + try: + entry["position"] = float(self.get_position(a)) + except Exception: + pass + try: + entry["enabled"] = bool(self.is_motor_enabled(a)) + except Exception: + pass + if state.limits is not None: + entry["velocity_limit"] = state.limits.velocity + entry["acceleration_limit"] = state.limits.acceleration + if state.peak_current_max is not None: + entry["peak_current"] = state.peak_current_max + if state.last_command is not None: + entry["last_command"] = dict(state.last_command) + telem[axis_display_name(a)] = entry + return telem + + def get_state_snapshot(self, max_age_s: float = 0.15) -> Dict[str, Any]: + """Return a composite snapshot: positions, motor states, flags, head/gripper, telemetry. + + Cached for ``max_age_s`` seconds so rapid callers do not hammer the + wire. + + Args: + max_age_s: How long a cached snapshot remains valid, in seconds. + + Returns: + A dict with ``"positions"``, ``"motors_enabled"``, + ``"head_attached"``, ``"gripper_present"``, ``"go_button_pressed"``, + ``"robot_disabled"``, and ``"telemetry"``. + """ + now = time.monotonic() + if self._last_snapshot is not None and (now - self._last_snapshot_at) <= max_age_s: + return dict(self._last_snapshot) + + positions = self.get_all_positions() + motors = self._motor_states() + flags = self._state_flags() + + head_attached = False + try: + head_attached = bool(self.detect_smart_head()) + except Exception: + head_attached = self._head_type != "unknown" + gripper_present = False + try: + gripper_present = self.detect_gripper() == GripperDetectionState.DETECTED + except Exception: + gripper_present = False + + snapshot = { + "positions": positions, + "motors_enabled": motors, + "head_attached": head_attached, + "gripper_present": gripper_present, + "go_button_pressed": bool(flags & int(DeviceStateFlag.GO_BUTTON)), + "robot_disabled": bool(flags & int(DeviceStateFlag.ROBOT_DISABLE)), + "telemetry": self._axis_telemetry(), + } + self._last_snapshot = snapshot + self._last_snapshot_at = now + return dict(snapshot) + + # ------------------------------------------------------------------ + # Send command (generic dispatch) + # ------------------------------------------------------------------ + + def send_command(self, command_id: int, data: bytes = b"", timeout: float = 2.0) -> bytes: + """Map a legacy command ID to its native Darwin equivalent, where one exists. + + Darwin has no generic command dispatch -- the few legacy command IDs + that higher-level code still issues are mapped to either a no-op or an + equivalent native method. + + Args: + command_id: The legacy command ID. + data: The command payload; unused. + timeout: Unused; kept for a uniform interface signature. + + Returns: + An empty payload for every handled command. + + Raises: + BravoError: If ``command_id`` has no Darwin equivalent. + """ + del data, timeout + if command_id == CommandID.CLEAR_MOTOR_POWER_FAULT: + # Darwin firmware has no motor-power-fault concept reachable over + # Gemini, so there is nothing to clear. + logger.debug("Darwin: CLEAR_MOTOR_POWER_FAULT is a no-op") + return b"" + if command_id == CommandID.CLEAR_GO_BUTTON: + self.clear_go_button() + return b"" + if command_id == CommandID.CLEAR_LIGHTS: + self.clear_lights() + return b"" + raise BravoError( + ErrorType.DARWIN_SOFTWARE_INTERNAL, + custom_text=f"Darwin command passthrough is not implemented for 0x{int(command_id):02X}.", + ) + + # ------------------------------------------------------------------ + # Error tracking + # ------------------------------------------------------------------ + + def _set_error(self, error: BravoError) -> None: + """Record the most recent error and log it. + + Args: + error: The error to record. + """ + self._last_error = error + logger.error("Darwin error: %s", error) + + @property + def last_error(self) -> Optional[BravoError]: + """The most recent error this controller recorded, if any.""" + return self._last_error + + # ------------------------------------------------------------------ + # Head-type management + # ------------------------------------------------------------------ + + def set_head_type(self, head_type: HeadType) -> None: + """Declare the currently-attached pipette head. + + Updates the W-axis hardware range and uL-to-mm factor, and marks the + 57-parameter W-axis table for re-apply on the next W move. Must be + called before any aspirate/dispense so the plunger positions are + interpreted correctly. + + Args: + head_type: The head type now installed. + """ + self._head_type = head_type + self._waxis_applied_head = None # Force a param re-apply on the next W move. + cfg = config_for_head(head_type) + if cfg is not None: + self._axes["w"].calibration = cfg.calibration() + self._axes["w"].limits = None # Hardware range changed; cached limits are stale. + + def get_head_type(self) -> HeadType: + """Return the head type most recently set with :meth:`set_head_type`.""" + return self._head_type + + def ul_to_mm(self, volume_ul: float) -> float: + """Convert a pipette volume in microliters to W-axis mm for the current head.""" + return ul_to_mm(volume_ul, self._head_type) + + # ------------------------------------------------------------------ + # W-axis pipetting (aspirate / dispense) -- convenience wrappers on move() + # ------------------------------------------------------------------ + + def aspirate( + self, + volume_ul: float, + *, + velocity_mm: float = 50.0, + acceleration_mm: float = 500.0, + timeout: float = 15.0, + ) -> None: + """Draw liquid by extending the plunger ``volume_ul`` above park. + + Positions the W axis at ``+volume_ul * factor`` mm from park. Requires + :meth:`set_head_type` to have been called so the uL-to-mm factor is + known. + + Args: + volume_ul: The volume to draw, in microliters. + velocity_mm: Move velocity, in mm/s. + acceleration_mm: Move acceleration, in mm/s^2. + timeout: Maximum time to wait for the move to finish, in seconds. + + Raises: + BravoError: If no head type has been set. + """ + if self._head_type == "unknown": + raise BravoError( + ErrorType.DARWIN_GENERIC, + custom_text="aspirate requires set_head_type() first", + ) + target_mm = self.ul_to_mm(volume_ul) + self.move( + [ + AxisMoveInfo( + axis="w", + position=target_mm, + velocity=velocity_mm, + acceleration=acceleration_mm, + absolute=True, + ) + ], + wait=True, + timeout=timeout, + ) + + def dispense( + self, + volume_ul: float, + *, + velocity_mm: float = 50.0, + acceleration_mm: float = 500.0, + timeout: float = 15.0, + ) -> None: + """Expel liquid by driving the plunger toward its ``volume_ul`` position. + + Moves W from its current position toward the position corresponding to + ``volume_ul`` (0 for park). To dispense a specific volume, call + :meth:`aspirate` first to set the starting position, then + :meth:`dispense` with 0 or a smaller volume to leave residual. + + Args: + volume_ul: The plunger target, in microliters. + velocity_mm: Move velocity, in mm/s. + acceleration_mm: Move acceleration, in mm/s^2. + timeout: Maximum time to wait for the move to finish, in seconds. + """ + target_mm = self.ul_to_mm(volume_ul) + self.move( + [ + AxisMoveInfo( + axis="w", + position=target_mm, + velocity=velocity_mm, + acceleration=acceleration_mm, + absolute=True, + ) + ], + wait=True, + timeout=timeout, + ) + + +# ---------------------------------------------------------------------------- +# Light-encoding helper +# ---------------------------------------------------------------------------- + + +def _encode_light_value(command: LightCommandData) -> int: + """Encode a light command into the native Darwin status-light word. + + Args: + command: The light command to encode. + + Returns: + The packed 32-bit status-light value. + """ + colors = int(command.light) + blue = 100 if (colors & 0x08) else 0 + red = 0 + green = 0 + low_bits = colors & 0x07 + if low_bits == 1: + red = 100 + elif low_bits == 2: + red = 25 + green = 100 + elif low_bits == 3: + red = 100 + green = 65 + elif low_bits == 4: + green = 100 + elif low_bits == 5: + red = 100 + green = 100 + elif low_bits == 6: + red = 20 + green = 100 + elif low_bits == 7: + red = 80 + green = 100 + period = int(command.period_ms or 0) + duty = float(command.duty_cycle or 0.0) + if duty == 1.0 or period > 2000: + blink_rate = 0 + elif 0.7 < duty < 0.9: + blink_rate = int(period / 20) | 0x80 + else: + blink_rate = int((period + 20) / 40) & 0x7F + return ((red & 0xFF) << 24) | ((green & 0xFF) << 16) | ((blue & 0xFF) << 8) | (blink_rate & 0xFF) diff --git a/pylabrobot/agilent/bravo/darwin/controller_tests.py b/pylabrobot/agilent/bravo/darwin/controller_tests.py new file mode 100644 index 00000000000..97c124bbe69 --- /dev/null +++ b/pylabrobot/agilent/bravo/darwin/controller_tests.py @@ -0,0 +1,40 @@ +"""Unit tests for :class:`DarwinController` behaviour that does not touch the wire. + +Wire-level behaviour (commutation, homing, moves, W-axis parameter apply) +is covered by :mod:`.darwin_golden_frame_tests` and :mod:`.timing_tests` +instead; this module is for state that :meth:`DarwinController.set_head_type` +and :meth:`DarwinController.get_head_type` manage purely in Python. +""" + +from __future__ import annotations + +import unittest + +from .controller import DarwinController +from .darwin_golden_frame_tests import FakeGeminiTransport + + +class HeadTypeTrackingTests(unittest.TestCase): + def test_get_head_type_defaults_to_unknown(self): + controller = DarwinController(FakeGeminiTransport()) + self.assertEqual(controller.get_head_type(), "unknown") + + def test_get_head_type_reflects_the_most_recent_set_head_type(self): + controller = DarwinController(FakeGeminiTransport()) + controller.set_head_type("96_d_200") + self.assertEqual(controller.get_head_type(), "96_d_200") + + def test_ul_to_mm_uses_the_currently_set_head_type(self): + # 96_d_70 and 96_f_50 have distinct W-axis calibration configs (see + # HEAD_CONFIGS in waxis_config.py), so the same volume converts to a + # different mm figure once the head type changes. + controller = DarwinController(FakeGeminiTransport()) + controller.set_head_type("96_d_70") + at_96_d_70 = controller.ul_to_mm(50.0) + controller.set_head_type("96_f_50") + at_96_f_50 = controller.ul_to_mm(50.0) + self.assertNotEqual(at_96_d_70, at_96_f_50) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/darwin/darwin_golden_frame_tests.py b/pylabrobot/agilent/bravo/darwin/darwin_golden_frame_tests.py new file mode 100644 index 00000000000..8ec41df34f0 --- /dev/null +++ b/pylabrobot/agilent/bravo/darwin/darwin_golden_frame_tests.py @@ -0,0 +1,690 @@ +"""Golden-frame tests: byte-for-byte wire output against a checked-in fixture. + +``testdata/darwin_golden_frames.json`` holds ordered +``[node_id, dev_id, cmd_type, sub_command, cmd_val]`` packet sequences: the +expected byte-level output for each scenario, captured from a reference +implementation driving ``darwin.axis``/``darwin.motion``/``darwin.sequences``/ +``darwin.controller`` through a recording fake Gemini device. Every test here +drives the equivalent call through an equivalent recording fake and asserts +the captured sequence matches the fixture exactly. The fixture is checked in +so a change in packet content, field order, or phase sequencing fails +immediately. + +This is what actually exercises the commutation and homing state machines +(including retry-on-regression), coordinated multi-axis moves, jog, and the +W-axis parameter table end to end -- unit tests on individual helper +functions do not catch a wrong byte inside a multi-step homing or parameter- +apply sequence the way a full recorded comparison does. +""" + +from __future__ import annotations + +import json +import threading +import time +import unittest +from pathlib import Path +from typing import Callable, Dict, List, Optional, Tuple + +from ..errors import BravoError +from ..protocol.gemini.engine import GeminiEngine +from ..protocol.gemini.enums import ( + FRAME_HEADER_SIZE, + MSG_SYNC, + NODE_BROADCAST, + PROTOCOL_VERSION, + CommandTypes, + CommonSubCommands, + GeminiSubCommands, + MotorState, + TCPMessageType, +) +from ..protocol.gemini.framing import ( + FrameHeader, + MultipacketResponse, + pack_packet_frame, + unpack_multipacket_batch, +) +from ..protocol.gemini.instruction import pack_float32 +from ..protocol.gemini.packet import BROADCAST_ADDRESS, InstructionAddress, Packet +from ..transport.base import Transport +from . import axis as axis_module +from . import motion, sequences +from .controller import DarwinController +from .params import ParameterAccess +from .topology import axis_address +from .waxis_params import apply_waxis_parameters + +_GOLDEN_PATH = Path(__file__).parent / "testdata" / "darwin_golden_frames.json" +with open(_GOLDEN_PATH) as _f: + GOLDEN: dict = json.load(_f) + +PacketHandler = Callable[[Packet], Optional[Packet]] +BroadcastListener = Callable[[Packet], None] + + +class FakeGeminiTransport(Transport): + """An in-memory fake Darwin controller, speaking framed Gemini over no socket. + + Mirrors the shape of a real device closely enough to drive + :class:`~..protocol.gemini.engine.GeminiEngine` end to end: per-``(node, + dev, sub_command)`` GET/SET handlers, broadcast listeners for trigger + events, and a decoded log of every packet sent (including each sub-packet + of a multipacket batch), in send order. + """ + + def __init__(self) -> None: + """Create an empty fake device with no registered handlers.""" + self._cond = threading.Condition() + self._buffer = bytearray() + self._connected = True + self.sent_packets: List[Packet] = [] + self._get_handlers: Dict[Tuple[int, int, int], PacketHandler] = {} + self._set_handlers: Dict[Tuple[int, int, int], PacketHandler] = {} + self._broadcast_listeners: List[BroadcastListener] = [] + + # --- Handler registration ------------------------------------------------ + + def on_get(self, addr: InstructionAddress, sub_command: int, handler: PacketHandler) -> None: + """Register a handler for GETs to ``(addr, sub_command)``.""" + self._get_handlers[(addr.node_id, addr.dev_id, sub_command)] = handler + + def on_set(self, addr: InstructionAddress, sub_command: int, handler: PacketHandler) -> None: + """Register a handler for SETs to ``(addr, sub_command)``.""" + self._set_handlers[(addr.node_id, addr.dev_id, sub_command)] = handler + + def on_broadcast(self, listener: BroadcastListener) -> None: + """Register a callback fired for every broadcast SET packet.""" + self._broadcast_listeners.append(listener) + + # --- Transport interface --------------------------------------------------- + + def push_frame(self, data: bytes) -> None: + """Append a fully-framed response directly to the receive buffer. + + Used by broadcast listeners to push an asynchronous echo (e.g. a + move-complete SEND_EVT) outside the normal send/response cycle. + + Args: + data: The complete framed bytes to make available to the next read. + """ + with self._cond: + self._buffer.extend(data) + self._cond.notify_all() + + def send(self, data: bytes) -> None: + """Decode a sent frame, record its packets, and enqueue any reply. + + Args: + data: The complete framed bytes sent by the engine. + """ + header = FrameHeader.from_bytes(data[:FRAME_HEADER_SIZE]) + payload = data[FRAME_HEADER_SIZE : FRAME_HEADER_SIZE + header.payload_size] + if header.payload_type == TCPMessageType.PACKET: + pkt = Packet.from_bytes(payload) + self.sent_packets.append(pkt) + resp = self._handle_packet(pkt) + if resp is not None: + self.push_frame(pack_packet_frame(resp)) + elif header.payload_type == TCPMessageType.MULTIPACKET: + packets = unpack_multipacket_batch(payload) + self.sent_packets.extend(packets) + for p in packets: + self._handle_packet(p) + mp_resp = MultipacketResponse( + num_exchanges=len(packets), error_code=0, error_device_addr=0, device_error_nak=0 + ) + resp_bytes = mp_resp.to_bytes() + resp_header = FrameHeader( + msg_sync=MSG_SYNC, + protocol_version=PROTOCOL_VERSION, + payload_type=TCPMessageType.MULTIPACKET, + payload_size=len(resp_bytes), + ) + self.push_frame(resp_header.to_bytes() + resp_bytes) + + def _handle_packet(self, pkt: Packet) -> Optional[Packet]: + """Dispatch one decoded packet to its registered handler. + + Args: + pkt: The received packet. + + Returns: + The response packet, or ``None`` for a broadcast (which gets no + reply) or an unhandled command type. + """ + if pkt.dest.node_id == NODE_BROADCAST: + for listener in self._broadcast_listeners: + listener(pkt) + return None + key = (pkt.dest.node_id, pkt.dest.dev_id, pkt.sub_command) + if pkt.cmd_type == CommandTypes.GETCMD: + handler = self._get_handlers.get(key) + if handler is not None: + custom = handler(pkt) + if custom is not None: + return custom + return Packet( + src=pkt.dest, + dest=pkt.src, + cmd_type=CommandTypes.GETCMD_RESP, + sub_command=pkt.sub_command, + cmd_val=0, + ) + if pkt.cmd_type == CommandTypes.SETCMD: + handler = self._set_handlers.get(key) + if handler is not None: + custom = handler(pkt) + if custom is not None: + return custom + return Packet( + src=pkt.dest, + dest=pkt.src, + cmd_type=CommandTypes.SETCMD_RESP, + sub_command=pkt.sub_command, + cmd_val=0, + ) + return None + + def receive(self, timeout: float = 2.0) -> bytes: + """Return whatever is currently buffered, waiting up to ``timeout`` for data.""" + with self._cond: + if not self._buffer: + self._cond.wait(timeout) + data = bytes(self._buffer) + self._buffer.clear() + return data + + def receive_exact(self, num_bytes: int, timeout: float = 2.0) -> bytes: + """Block until exactly ``num_bytes`` are available, or raise on timeout.""" + deadline = time.monotonic() + timeout + with self._cond: + while len(self._buffer) < num_bytes: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError(f"FakeGeminiTransport timed out waiting for {num_bytes} bytes") + self._cond.wait(remaining) + chunk = bytes(self._buffer[:num_bytes]) + del self._buffer[:num_bytes] + return chunk + + @property + def is_connected(self) -> bool: + """Always True: this fake has no real connection to lose.""" + return self._connected + + +def _capture(fake: FakeGeminiTransport) -> List[list]: + """Encode every packet the fake recorded as a JSON-comparable list. + + Args: + fake: The fake transport to read from. + + Returns: + A list of ``[node_id, dev_id, cmd_type, sub_command, cmd_val]`` entries, + one per recorded packet, in send order. + """ + return [ + [p.dest.node_id, p.dest.dev_id, int(p.cmd_type), int(p.sub_command), int(p.cmd_val)] + for p in fake.sent_packets + ] + + +class _StateSim: + """Motor-state simulator: reports "still pending" for a fixed number of + reads, then the target state -- deterministic by call count, not by + wall-clock time, so the captured packet sequence never depends on + scheduling jitter. + """ + + def __init__(self, settle_after: int = 3): + self._lock = threading.Lock() + self._state = MotorState.INITIAL + self._settle_after = settle_after + self._pending: Optional[MotorState] = None + self._reads_since_pending = 0 + + def current_state(self) -> MotorState: + with self._lock: + if self._pending is not None: + self._reads_since_pending += 1 + if self._reads_since_pending >= self._settle_after: + self._state = self._pending + self._pending = None + return self._state + + def set_state(self, requested: MotorState) -> None: + with self._lock: + if requested == MotorState.COMMUTATE: + self._state = MotorState.COMMUTATE + self._pending = MotorState.COMMUTATED + self._reads_since_pending = 0 + elif requested == MotorState.HOME: + self._state = MotorState.HOME + self._pending = MotorState.READY + self._reads_since_pending = 0 + elif requested == MotorState.DISABLE: + self._state = MotorState.DISABLED + self._pending = None + elif requested == MotorState.ENABLE: + self._state = MotorState.READY + self._pending = None + else: + self._state = requested + + def force(self, state: MotorState) -> None: + with self._lock: + self._state = state + self._pending = None + + +def _install_state_sim(fake: FakeGeminiTransport, addr: InstructionAddress, sim: _StateSim) -> None: + """Wire a :class:`_StateSim` to a fake device's MOTOR_STATE GET/SET.""" + + def get_handler(pkt: Packet) -> Packet: + return Packet( + src=pkt.dest, + dest=pkt.src, + cmd_type=CommandTypes.GETCMD_RESP, + sub_command=pkt.sub_command, + cmd_val=int(sim.current_state()), + ) + + def set_handler(pkt: Packet) -> Packet: + try: + requested = MotorState(pkt.cmd_val) + except ValueError: + requested = MotorState.INITIAL + sim.set_state(requested) + return Packet( + src=pkt.dest, + dest=pkt.src, + cmd_type=CommandTypes.SETCMD_RESP, + sub_command=pkt.sub_command, + cmd_val=0, + ) + + fake.on_get(addr, GeminiSubCommands.MOTOR_STATE, get_handler) + fake.on_set(addr, GeminiSubCommands.MOTOR_STATE, set_handler) + + +class _MotionSim: + """Simulated axis that echoes SEND_EVT after a delay, mirroring real hardware.""" + + def __init__(self, address: InstructionAddress, complete_s: float = 0.005): + self.address = address + self.state = MotorState.READY + self.complete_s = complete_s + self.start_event: Optional[int] = None + self.send_event: Optional[int] = None + self.position = 0.0 + self._fake: Optional[FakeGeminiTransport] = None + self._lock = threading.Lock() + + def install(self, fake: FakeGeminiTransport) -> None: + self._fake = fake + fake.on_get(self.address, GeminiSubCommands.MOTOR_STATE, self._get_state) + fake.on_set(self.address, GeminiSubCommands.MOTOR_STATE, self._set_motor_state) + fake.on_set(self.address, GeminiSubCommands.START_EVT, self._set_start) + fake.on_set(self.address, GeminiSubCommands.SEND_EVT, self._set_send) + fake.on_get(self.address, GeminiSubCommands.POSITION, self._get_position) + fake.on_broadcast(self._broadcast) + + def _set_send(self, pkt: Packet) -> Packet: + with self._lock: + self.send_event = pkt.cmd_val + return Packet( + src=pkt.dest, + dest=pkt.src, + cmd_type=CommandTypes.SETCMD_RESP, + sub_command=pkt.sub_command, + cmd_val=0, + ) + + def _set_motor_state(self, pkt: Packet) -> Packet: + with self._lock: + try: + self.state = MotorState(pkt.cmd_val) + except ValueError: + pass + return Packet( + src=pkt.dest, + dest=pkt.src, + cmd_type=CommandTypes.SETCMD_RESP, + sub_command=pkt.sub_command, + cmd_val=0, + ) + + def _get_state(self, pkt: Packet) -> Packet: + with self._lock: + return Packet( + src=pkt.dest, + dest=pkt.src, + cmd_type=CommandTypes.GETCMD_RESP, + sub_command=pkt.sub_command, + cmd_val=int(self.state), + ) + + def _get_position(self, pkt: Packet) -> Packet: + with self._lock: + return Packet( + src=pkt.dest, + dest=pkt.src, + cmd_type=CommandTypes.GETCMD_RESP, + sub_command=pkt.sub_command, + cmd_val=pack_float32(self.position), + ) + + def _set_start(self, pkt: Packet) -> Packet: + with self._lock: + self.start_event = pkt.cmd_val + self.state = MotorState.BUSY + return Packet( + src=pkt.dest, + dest=pkt.src, + cmd_type=CommandTypes.SETCMD_RESP, + sub_command=pkt.sub_command, + cmd_val=0, + ) + + def _broadcast(self, pkt: Packet) -> None: + if pkt.sub_command != CommonSubCommands.TRIGGER: + return + with self._lock: + if self.start_event is None or pkt.cmd_val != self.start_event: + return + complete_at = time.monotonic() + self.complete_s + send_event = self.send_event + fake = self._fake + + def completer() -> None: + while time.monotonic() < complete_at: + time.sleep(0.001) + with self._lock: + self.state = MotorState.READY + self.position = 0.6 # Settle position read back by jog's validation. + if send_event is not None and fake is not None: + echo = Packet( + src=self.address, + dest=BROADCAST_ADDRESS, + cmd_type=CommandTypes.SETCMD, + sub_command=CommonSubCommands.TRIGGER, + cmd_val=send_event, + ) + fake.push_frame(pack_packet_frame(echo)) + + threading.Thread(target=completer, daemon=True).start() + + +class GoldenFrameTestCase(unittest.TestCase): + """Base class for Darwin golden-frame comparisons.""" + + def assert_matches_golden(self, scenario: str, calls: List[list]) -> None: + expected = GOLDEN[scenario] + self.assertEqual(calls, expected, f"{scenario}: captured frames diverge from golden") + + +class AxisGoldenTests(GoldenFrameTestCase): + """Commutation, homing (with retry-on-regression), and initialize.""" + + def test_commutate_normal(self): + fake = FakeGeminiTransport() + engine = GeminiEngine(fake) + engine.start_receiving() + try: + addr = axis_address("x") + sim = _StateSim(settle_after=3) + _install_state_sim(fake, addr, sim) + axis_module.commutate(engine, addr, "X", poll=0.002, timeout=2.0) + finally: + engine.stop_receiving() + self.assert_matches_golden("axis_commutate_normal", _capture(fake)) + + def test_commutate_retry_on_regression(self): + fake = FakeGeminiTransport() + engine = GeminiEngine(fake) + engine.start_receiving() + try: + addr = axis_address("x") + sim = _StateSim(settle_after=3) + counts = {"n": 0} + + def set_handler(pkt: Packet) -> Packet: + try: + requested = MotorState(pkt.cmd_val) + except ValueError: + requested = MotorState.INITIAL + if requested == MotorState.COMMUTATE: + counts["n"] += 1 + if counts["n"] == 1: + sim.set_state(MotorState.COMMUTATE) + sim.force(MotorState.INITIAL) + else: + sim.set_state(MotorState.COMMUTATE) + else: + sim.set_state(requested) + return Packet( + src=pkt.dest, + dest=pkt.src, + cmd_type=CommandTypes.SETCMD_RESP, + sub_command=pkt.sub_command, + cmd_val=0, + ) + + def get_handler(pkt: Packet) -> Packet: + return Packet( + src=pkt.dest, + dest=pkt.src, + cmd_type=CommandTypes.GETCMD_RESP, + sub_command=pkt.sub_command, + cmd_val=int(sim.current_state()), + ) + + fake.on_set(addr, GeminiSubCommands.MOTOR_STATE, set_handler) + fake.on_get(addr, GeminiSubCommands.MOTOR_STATE, get_handler) + axis_module.commutate(engine, addr, "X", poll=0.005, timeout=2.0) + finally: + engine.stop_receiving() + self.assert_matches_golden("axis_commutate_retry_on_regression", _capture(fake)) + + def test_home_normal(self): + fake = FakeGeminiTransport() + engine = GeminiEngine(fake) + engine.start_receiving() + try: + addr = axis_address("y") + sim = _StateSim(settle_after=3) + sim.force(MotorState.COMMUTATED) + _install_state_sim(fake, addr, sim) + axis_module.home(engine, addr, "Y", poll=0.002, timeout=2.0) + finally: + engine.stop_receiving() + self.assert_matches_golden("axis_home_normal", _capture(fake)) + + def test_home_retry_on_regression(self): + fake = FakeGeminiTransport() + engine = GeminiEngine(fake) + engine.start_receiving() + try: + addr = axis_address("y") + sim = _StateSim(settle_after=3) + sim.force(MotorState.COMMUTATED) + home_sets = {"n": 0} + + def set_handler(pkt: Packet) -> Packet: + try: + requested = MotorState(pkt.cmd_val) + except ValueError: + requested = MotorState.INITIAL + if requested == MotorState.HOME: + home_sets["n"] += 1 + if home_sets["n"] == 1: + sim.force(MotorState.HOME) + sim.force(MotorState.COMMUTATED) + else: + sim.set_state(MotorState.HOME) + elif requested == MotorState.COMMUTATE: + sim.set_state(MotorState.COMMUTATE) + else: + sim.set_state(requested) + return Packet( + src=pkt.dest, + dest=pkt.src, + cmd_type=CommandTypes.SETCMD_RESP, + sub_command=pkt.sub_command, + cmd_val=0, + ) + + def get_handler(pkt: Packet) -> Packet: + return Packet( + src=pkt.dest, + dest=pkt.src, + cmd_type=CommandTypes.GETCMD_RESP, + sub_command=pkt.sub_command, + cmd_val=int(sim.current_state()), + ) + + fake.on_set(addr, GeminiSubCommands.MOTOR_STATE, set_handler) + fake.on_get(addr, GeminiSubCommands.MOTOR_STATE, get_handler) + axis_module.home(engine, addr, "Y", poll=0.002, timeout=2.0) + finally: + engine.stop_receiving() + self.assert_matches_golden("axis_home_retry_on_regression", _capture(fake)) + + def test_initialize(self): + fake = FakeGeminiTransport() + engine = GeminiEngine(fake) + engine.start_receiving() + try: + addr = axis_address("x") + sim = _StateSim(settle_after=3) + _install_state_sim(fake, addr, sim) + axis_module.initialize(engine, addr, "X", commutate_timeout=2.0, home_timeout=2.0) + finally: + engine.stop_receiving() + self.assert_matches_golden("axis_initialize", _capture(fake)) + + +class MotionGoldenTests(GoldenFrameTestCase): + """Single- and multi-axis coordinated moves.""" + + def test_move_single_axis(self): + fake = FakeGeminiTransport() + engine = GeminiEngine(fake) + engine.start_receiving() + try: + addr = axis_address("x") + sim = _MotionSim(addr, complete_s=0.005) + sim.install(fake) + motion.move_absolute( + engine, addr, "X", 0.6, velocity_percent=80.0, acceleration_percent=90.0, timeout=2.0 + ) + finally: + engine.stop_receiving() + self.assert_matches_golden("move_single_axis", _capture(fake)) + + def test_move_multi_axis(self): + fake = FakeGeminiTransport() + engine = GeminiEngine(fake) + engine.start_receiving() + try: + x_addr = axis_address("x") + y_addr = axis_address("y") + x_sim = _MotionSim(x_addr, complete_s=0.005) + y_sim = _MotionSim(y_addr, complete_s=0.008) + x_sim.install(fake) + y_sim.install(fake) + reqs = [ + motion.MoveRequest( + address=x_addr, + axis_name="X", + target_normalized=0.5, + velocity_percent=100.0, + acceleration_percent=100.0, + ), + motion.MoveRequest( + address=y_addr, + axis_name="Y", + target_normalized=0.4, + velocity_percent=100.0, + acceleration_percent=100.0, + ), + ] + motion.move_multi(engine, reqs, timeout=2.0) + finally: + engine.stop_receiving() + self.assert_matches_golden("move_multi_axis", _capture(fake)) + + +class SequencesGoldenTests(GoldenFrameTestCase): + """Force-controlled jog.""" + + def test_jog_z_axis(self): + fake = FakeGeminiTransport() + engine = GeminiEngine(fake) + engine.start_receiving() + try: + addr = axis_address("z") + sim = _MotionSim(addr, complete_s=0.005) + sim.install(fake) + + def read_pos(engine: GeminiEngine, a: InstructionAddress) -> float: + return engine.get_float(a, GeminiSubCommands.POSITION) + + sequences.jog( + engine, + addr, + None, # type: ignore[arg-type] + sequences.JogParams( + axis_name="Z", + target_position=0.5, + tolerance=0.2, + peak_current_amps=0.3, + velocity_mm=50.0, + acceleration_mm=500.0, + velocity_limit=150.0, + acceleration_limit=1500.0, + exceed_epsilon=0.05, + ), + read_position=read_pos, + timeout=2.0, + settle=0.001, + ) + finally: + engine.stop_receiving() + self.assert_matches_golden("jog_z_axis", _capture(fake)) + + +class WaxisParamsGoldenTests(GoldenFrameTestCase): + """The 57-entry W-axis parameter apply.""" + + def test_apply_waxis_parameters_96_d_70(self): + fake = FakeGeminiTransport() + engine = GeminiEngine(fake) + engine.start_receiving() + try: + addr = axis_address("w") + params = ParameterAccess(engine, addr) + apply_waxis_parameters(params, "96_d_70") + finally: + engine.stop_receiving() + self.assert_matches_golden("waxis_param_apply_96_d_70", _capture(fake)) + + +class ControllerGoldenTests(GoldenFrameTestCase): + """DarwinController.initialize().""" + + def test_controller_initialize(self): + fake = FakeGeminiTransport() + ctrl = DarwinController(fake) + try: + ctrl.initialize() + except BravoError: + pass + finally: + ctrl.deinitialize() + self.assert_matches_golden("controller_initialize", _capture(fake)) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/darwin/motion.py b/pylabrobot/agilent/bravo/darwin/motion.py new file mode 100644 index 00000000000..e0add2ba66f --- /dev/null +++ b/pylabrobot/agilent/bravo/darwin/motion.py @@ -0,0 +1,694 @@ +"""Motion primitives -- instruction loading and execution. + +Each move on the Gemini controller is a 4-word :class:`~..protocol.gemini.instruction.Instruction` +loaded into the device's instruction table, armed with start/send event +numbers, and triggered by a broadcast ``TRIGGER`` with the start event. The +device writes back a ``TRIGGER`` with the send event when the move completes. + +This polls ``MOTOR_STATE`` for BUSY -> READY transitions rather than wiring +event callbacks for that part. + +Public API: + :func:`build_load_packets` -- construct the multipacket batch for one instruction + :func:`load_instruction` -- send the multipacket batch for one axis + :func:`trigger_event` -- broadcast TRIGGER with an event number + :func:`wait_for_ready` -- poll MOTOR_STATE until READY (or timeout) + :func:`move_absolute` -- single-axis absolute move, wait for completion + :func:`move_multi` -- multi-axis coordinated move with settle polling +""" + +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass +from typing import Dict, List, Optional, Set + +from ..errors import BravoError, ErrorType +from ..protocol.gemini.engine import GeminiEngine +from ..protocol.gemini.enums import ( + AxisDirection, + CommandTypes, + CommonSubCommands, + GeminiSubCommands, + InstructionTypes, + MotorState, + ReservedEvent, +) +from ..protocol.gemini.instruction import Instruction +from ..protocol.gemini.packet import BROADCAST_ADDRESS, HOST_ADDRESS, InstructionAddress, Packet +from .axis import read_motor_state + +_DEFAULT_MOVE_TIMEOUT = 30.0 +_DEFAULT_SETTLE_POLL = 0.01 +# How long to insist on seeing BUSY before accepting READY as "move complete". +# On real hardware, the axis transitions to BUSY some time after the trigger +# broadcast arrives -- polling before then would see the pre-move READY and +# falsely declare the move done. BUSY must appear at least once within this +# window (fails with MOVE_TIMEOUT otherwise). +_BUSY_CONFIRM = 0.5 + + +@dataclass +class LoadedMove: + """One instruction queued on a specific axis. + + Attributes: + address: The axis device's controller-tree address. + instruction: The instruction loaded on that device. + start_event: The event number that starts the instruction. + send_event: The event number the device echoes on completion. + """ + + address: InstructionAddress + instruction: Instruction + start_event: int + send_event: int + + +# --- Packet-list builders ----------------------------------------------------- + + +def build_load_packets( + address: InstructionAddress, + instruction: Instruction, + start_event: int, + send_event: int, +) -> List[Packet]: + """Return the SET packets that load one instruction on one axis. + + Sequence: ``INSTR_NEW_INSTR(1)`` -> 4x ``INSTR_TBL_VAL`` -> ``START_EVT`` + -> ``SEND_EVT`` -- the 7-packet pattern the firmware expects. The + controller keeps its own instruction-slot state across moves, so + ``INSTR_NEW_INSTR(1)`` alone is sufficient; an initial clear is not + needed and breaks event binding. + + Args: + address: The axis device's controller-tree address. + instruction: The instruction to load. + start_event: The event number that starts the instruction. + send_event: The event number the device echoes on completion. + + Returns: + The packets to send, in order. + """ + w0, w1, w2, w3 = instruction.to_words() + return [ + Packet(HOST_ADDRESS, address, CommandTypes.SETCMD, GeminiSubCommands.INSTR_NEW_INSTR, 1), + Packet(HOST_ADDRESS, address, CommandTypes.SETCMD, GeminiSubCommands.INSTR_TBL_VAL, w0), + Packet(HOST_ADDRESS, address, CommandTypes.SETCMD, GeminiSubCommands.INSTR_TBL_VAL, w1), + Packet(HOST_ADDRESS, address, CommandTypes.SETCMD, GeminiSubCommands.INSTR_TBL_VAL, w2), + Packet(HOST_ADDRESS, address, CommandTypes.SETCMD, GeminiSubCommands.INSTR_TBL_VAL, w3), + Packet(HOST_ADDRESS, address, CommandTypes.SETCMD, GeminiSubCommands.START_EVT, start_event), + Packet(HOST_ADDRESS, address, CommandTypes.SETCMD, GeminiSubCommands.SEND_EVT, send_event), + ] + + +def load_instruction( + engine: GeminiEngine, + address: InstructionAddress, + instruction: Instruction, + start_event: int, + send_event: Optional[int] = None, + timeout: float = 10.0, +) -> None: + """Load one instruction onto one axis as a single multipacket. + + Args: + engine: The Gemini engine to send through. + address: The axis device's controller-tree address. + instruction: The instruction to load. + start_event: The event number that starts the instruction. + send_event: The event number the device echoes on completion. If + ``None``, uses the standard composite encoding from + :func:`_compose_send_event`. + timeout: Maximum time to wait for the multipacket response, in seconds. + """ + if send_event is None: + send_event = _compose_send_event(start_event) + packets = build_load_packets(address, instruction, start_event, send_event) + engine.send_multipacket(packets, timeout) + + +def load_instructions( + engine: GeminiEngine, + moves: List[LoadedMove], + timeout: float = 10.0, +) -> None: + """Batch-load N instructions (one per axis) as a single multipacket. + + The engine chunks into multiple multipackets if the total exceeds 64 + packets (each axis contributes 7 packets, so this would only trigger at + 10 axes -- never reached in practice, but handled safely). + + Args: + engine: The Gemini engine to send through. + moves: The per-axis instructions to load. + timeout: Maximum time to wait for each chunk's response, in seconds. + """ + packets: List[Packet] = [] + for m in moves: + packets.extend(build_load_packets(m.address, m.instruction, m.start_event, m.send_event)) + engine.send_multipacket(packets, timeout) + + +# --- Triggering ----------------------------------------------------------------- + + +def trigger_event(engine: GeminiEngine, event_number: int, timeout: float = 5.0) -> None: + """Broadcast ``TRIGGER`` with an event number. + + Any axis whose ``START_EVT`` equals ``event_number`` begins executing its + loaded instruction. Broadcasts do not wait for a response -- the engine + returns after its broadcast wait interval. + + Args: + engine: The Gemini engine to send through. + event_number: The event number to broadcast. + timeout: Ignored for a broadcast send; kept for a uniform signature with + other wire operations. + """ + engine.set_uint(BROADCAST_ADDRESS, CommonSubCommands.TRIGGER, event_number, timeout) + + +# --- Polling for completion ------------------------------------------------------- + + +def wait_for_ready( + engine: GeminiEngine, + address: InstructionAddress, + axis_name: str, + *, + timeout: float = _DEFAULT_MOVE_TIMEOUT, + poll: float = _DEFAULT_SETTLE_POLL, + busy_confirm: float = _BUSY_CONFIRM, +) -> MotorState: + """Poll ``MOTOR_STATE`` until it returns to READY (or an error state). + + Must observe at least one ``BUSY`` reading before accepting ``READY`` as + "move complete" -- otherwise this would race and return immediately on + the pre-move READY state before the controller has transitioned. If BUSY + is never observed within ``busy_confirm``, raises ``MOVE_TIMEOUT``. + + Args: + engine: The Gemini engine to poll through. + address: The axis device's controller-tree address. + axis_name: The axis's display name, used in error messages. + timeout: Overall timeout for the move to complete, in seconds. + poll: Delay between state polls, in seconds. + busy_confirm: Window within which BUSY must first appear, in seconds. + + Returns: + The axis's final motor state (``READY``). + + Raises: + BravoError: If the axis is disabled during the move, never enters + BUSY within ``busy_confirm``, or does not reach READY within + ``timeout``. + """ + start = time.monotonic() + saw_busy = False + while True: + state = read_motor_state(engine, address) + if state in (MotorState.BUSY, MotorState.MOVE_TO_FLAG, MotorState.MOVE_TO_INDEX): + saw_busy = True + elif state == MotorState.READY and saw_busy: + return state + elif state in (MotorState.DISABLED, MotorState.DISABLE): + raise BravoError( + ErrorType.MOTOR_POWER, + custom_text=f"Axis disabled during move [{axis_name}]", + ) + + elapsed = time.monotonic() - start + if not saw_busy and elapsed > busy_confirm: + raise BravoError( + ErrorType.MOVE_TIMEOUT, + custom_text=( + f"Axis never entered BUSY within {busy_confirm}s " + f"[{axis_name}] -- trigger may not have been received" + ), + ) + if elapsed > timeout: + raise BravoError( + ErrorType.MOVE_TIMEOUT, + custom_text=f"Move timeout waiting for READY [{axis_name}]", + ) + time.sleep(poll) + + +def wait_for_all_ready( + engine: GeminiEngine, + moves: List[LoadedMove], + axis_names: Dict[int, str], + *, + timeout: float = _DEFAULT_MOVE_TIMEOUT, + poll: float = _DEFAULT_SETTLE_POLL, + busy_confirm: float = _BUSY_CONFIRM, +) -> None: + """Poll all loaded axes until every one is READY. + + Each axis must be observed in BUSY at least once before its READY state + counts as "move complete" -- see :func:`wait_for_ready` for the rationale. + + Args: + engine: The Gemini engine to poll through. + moves: The loaded moves whose axes to wait on. + axis_names: Display names for each axis, keyed by address byte, used in + error messages. + timeout: Overall timeout for every axis to complete, in seconds. + poll: Delay between polling rounds, in seconds. + busy_confirm: Window within which every axis must first appear BUSY, in + seconds. + + Raises: + BravoError: If any axis is disabled during the move, any axis never + enters BUSY within ``busy_confirm``, or not every axis reaches READY + within ``timeout``. + """ + start = time.monotonic() + remaining = {m.address.byte: m.address for m in moves} + saw_busy: Set[int] = set() + while remaining: + for addr_byte, addr in list(remaining.items()): + state = read_motor_state(engine, addr) + name = axis_names.get(addr_byte, str(addr)) + if state in (MotorState.BUSY, MotorState.MOVE_TO_FLAG, MotorState.MOVE_TO_INDEX): + saw_busy.add(addr_byte) + elif state == MotorState.READY and addr_byte in saw_busy: + del remaining[addr_byte] + elif state in (MotorState.DISABLED, MotorState.DISABLE): + raise BravoError( + ErrorType.MOTOR_POWER, + custom_text=f"Axis disabled during move [{name}]", + ) + + elapsed = time.monotonic() - start + + if remaining and elapsed > busy_confirm: + missing = [axis_names.get(b, str(a)) for b, a in remaining.items() if b not in saw_busy] + if missing: + raise BravoError( + ErrorType.MOVE_TIMEOUT, + custom_text=(f"Axes never entered BUSY within {busy_confirm}s: {', '.join(missing)}"), + ) + + if remaining and elapsed > timeout: + names = ", ".join(axis_names.get(a.byte, str(a)) for a in remaining.values()) + raise BravoError( + ErrorType.MOVE_TIMEOUT, + custom_text=f"Multi-axis move timeout; still busy: {names}", + ) + if remaining: + time.sleep(poll) + + +# --- High-level entry points --------------------------------------------------- + + +def _make_move_instruction( + target_normalized: float, + *, + instr_type: InstructionTypes = InstructionTypes.MOVE_TO, + velocity_percent: float = 100.0, + acceleration_percent: float = 100.0, + jerk_percent: float = 100.0, + force_percent: float = 0.0, + direction: AxisDirection = AxisDirection.POSITIVE, + trig_at_normalized: Optional[float] = None, +) -> Instruction: + """Build a MOVE_TO/MOVE_BY instruction targeting a normalized position. + + Args: + target_normalized: The target position or volume, in normalized [0, 1] + axis units. + instr_type: The instruction type. + velocity_percent: Move velocity, 0-100% of axis max. + acceleration_percent: Move acceleration, 0-100% of axis max. + jerk_percent: Move jerk, 0-100% of axis max. + force_percent: Force limit, 0-100%. + direction: Move direction. + trig_at_normalized: The trigger position, in normalized units. Defaults + to ``target_normalized`` -- a real MoveAbsolute instruction sets word3 + equal to word2, and firing the SEND event depends on the axis + reaching this trigger position. + + Returns: + The built instruction. + """ + inst = Instruction( + instr_type=instr_type, + velocity_percent=velocity_percent, + acceleration_percent=acceleration_percent, + jerk_percent=jerk_percent, + force_percent=force_percent, + direction=direction, + ) + inst.volume = target_normalized + inst.trig_at_float = trig_at_normalized if trig_at_normalized is not None else target_normalized + return inst + + +def _compose_send_event(start_event: int) -> int: + """Encode the SEND_EVT value used in real instructions. + + SEND_EVT is always a composite instruction event with mask=1 and + event_no=start_event+1, encoded as:: + + evt = (mask << 8) | 0x80 | (event_no & 0x7F) + + Args: + start_event: The instruction's start event number. + + Returns: + The composite send-event value. + """ + event_no = (start_event + 1) & 0x7F + return (1 << 8) | 0x80 | event_no + + +class _MoveWaiter: + """Context manager to wait for SEND_EVT echoes or a RESERVED error. + + The firmware signals move completion by broadcasting the SEND_EVT value + (e.g. 0x182) from EACH axis as it finishes -- one echo per axis. For a + multi-axis coordinated move, all axes share the same send_event but + complete at different times, so a correct completion condition is "an + echo has been seen from EVERY expected source, not just the first one". + Stopping at the first echo would let the caller advance to the next step + while slower axes are still in motion. + + Construction modes: + + - ``expected_src`` (single): wait for exactly one echo from that + address. Used by every single-axis primitive (:func:`move_absolute`, + :func:`move_relative`, ``force_move``, ``grip``). + - ``expected_srcs`` (set): wait for one echo from EACH address in the + set. Used by :func:`move_multi` -- the set contains every axis in the + coordinated move. + - Neither provided: accept any source (legacy fallback; any single + matching echo resolves the wait). + + Exactly one of ``expected_src``/``expected_srcs`` should be provided. + """ + + def __init__( + self, + engine: GeminiEngine, + send_event: int, + label: str, + expected_src: Optional[InstructionAddress] = None, + expected_srcs: Optional[Set[InstructionAddress]] = None, + ): + """Set up a waiter for one or more SEND_EVT echoes. + + Args: + engine: The Gemini engine whose trigger/reserved-event callbacks to + subscribe to. + send_event: The composite event value to wait for. + label: A description of the move, used in timeout error messages. + expected_src: The single source address to wait on. + expected_srcs: The set of source addresses to wait on, one echo each. + + Raises: + ValueError: If both ``expected_src`` and ``expected_srcs`` are given. + """ + if expected_src is not None and expected_srcs is not None: + raise ValueError("Pass only one of expected_src / expected_srcs to _MoveWaiter.") + self._engine = engine + self._send_event = send_event + self._label = label + self._lock = threading.Lock() + self._pending: Optional[Set[InstructionAddress]] + if expected_src is not None: + self._pending = {expected_src} + elif expected_srcs is not None: + self._pending = set(expected_srcs) + else: + self._pending = None + self._done = threading.Event() + self._reserved: Optional[ReservedEvent] = None + self._reserved_src: Optional[tuple] = None + + def __enter__(self) -> "_MoveWaiter": + """Subscribe this waiter's callbacks and return it.""" + self._engine.on_trigger(self._on_trigger) + self._engine.on_reserved_event(self._on_reserved) + return self + + def __exit__(self, exc_type, exc, tb) -> None: + """Unsubscribe the trigger callback. + + The reserved-event callback is left registered: the engine has no + ``remove_reserved_event`` hook, and the callback only sets this + instance's own event, which a later waiter on a different instance + never observes -- harmless. + """ + self._engine.remove_trigger(self._on_trigger) + + def _on_trigger(self, pkt: Packet) -> None: + """Resolve the wait when a matching SEND_EVT echo arrives. + + Args: + pkt: The received trigger packet. + """ + if pkt.cmd_val != self._send_event: + return + with self._lock: + if self._pending is None: + self._done.set() + return + if pkt.src not in self._pending: + # Ignore echoes from axes not being tracked (e.g. stale broadcasts + # from a previously-completed move elsewhere in the controller + # tree). + return + self._pending.discard(pkt.src) + if not self._pending: + self._done.set() + + def _on_reserved(self, reserved: ReservedEvent, pkt: Packet) -> None: + """Resolve the wait with a recorded error when a RESERVED event arrives. + + Args: + reserved: The decoded reserved event. + pkt: The packet the event arrived in. + """ + self._reserved = reserved + self._reserved_src = (pkt.src.node_id, pkt.src.dev_id) + self._done.set() + + def wait(self, timeout: float) -> None: + """Block until every expected echo (or a RESERVED event) arrives. + + Args: + timeout: Maximum time to wait, in seconds. + + Raises: + BravoError: If no matching echo arrives within ``timeout``, or a + RESERVED event aborted the move. + """ + if not self._done.wait(timeout): + raise BravoError( + ErrorType.MOVE_TIMEOUT, + custom_text=( + f"Move timeout [{self._label}]: no SEND_EVT echo " + f"(0x{self._send_event:x}) within {timeout}s" + ), + ) + if self._reserved is not None: + src = self._reserved_src or (0, 0) + err_map = { + ReservedEvent.STOP: ErrorType.STOP_COMMAND, + ReservedEvent.ERROR: ErrorType.CONTROLLER_INTERNAL, + ReservedEvent.FAULT: ErrorType.CONTROLLER_FATAL, + ReservedEvent.STOP_DISABLE: ErrorType.ROBOT_DISABLE, + ReservedEvent.SAFETY_NOTICE: ErrorType.ROBOT_DISABLE, + } + err_type = err_map.get(self._reserved, ErrorType.DARWIN_GENERIC) + raise BravoError( + err_type, + custom_text=( + f"Move aborted [{self._label}]: controller broadcast " + f"RESERVED event {self._reserved.name} from node {src[0]}.{src[1]}" + ), + ) + + +def move_absolute( + engine: GeminiEngine, + address: InstructionAddress, + axis_name: str, + target_normalized: float, + *, + velocity_percent: float = 100.0, + acceleration_percent: float = 100.0, + wait: bool = True, + start_event: int = 1, + timeout: float = _DEFAULT_MOVE_TIMEOUT, +) -> None: + """Move to an absolute target, in normalized axis units. + + Normalized units are the float-in-word-2 form the controller expects -- + the caller is responsible for converting mm or uL to normalized. + + Args: + engine: The Gemini engine to drive the move through. + address: The axis device's controller-tree address. + axis_name: The axis's display name, used in error messages. + target_normalized: The absolute target, in normalized [0, 1] axis + units. + velocity_percent: Move velocity, 0-100% of axis max. + acceleration_percent: Move acceleration, 0-100% of axis max. + wait: Whether to block until the move finishes. + start_event: The event number to start the instruction with. + timeout: Maximum time to wait for completion, in seconds. + """ + inst = _make_move_instruction( + target_normalized, + instr_type=InstructionTypes.MOVE_TO, + velocity_percent=velocity_percent, + acceleration_percent=acceleration_percent, + ) + send_event = _compose_send_event(start_event) + if wait: + # Wait for either the SEND_EVT echo or a RESERVED event (which signals + # the move was aborted by an error/safety condition). + with _MoveWaiter(engine, send_event, axis_name, expected_src=address) as waiter: + load_instruction(engine, address, inst, start_event, send_event) + trigger_event(engine, start_event) + waiter.wait(timeout) + else: + load_instruction(engine, address, inst, start_event, send_event) + trigger_event(engine, start_event) + + +def move_relative( + engine: GeminiEngine, + address: InstructionAddress, + axis_name: str, + delta_normalized: float, + *, + direction: AxisDirection = AxisDirection.POSITIVE, + velocity_percent: float = 100.0, + acceleration_percent: float = 100.0, + wait: bool = True, + start_event: int = 1, + timeout: float = _DEFAULT_MOVE_TIMEOUT, +) -> None: + """Move by ``delta_normalized`` in the given direction. + + Args: + engine: The Gemini engine to drive the move through. + address: The axis device's controller-tree address. + axis_name: The axis's display name, used in error messages. + delta_normalized: The move distance, in normalized axis units + (magnitude only -- sign comes from ``direction``). + direction: Move direction. + velocity_percent: Move velocity, 0-100% of axis max. + acceleration_percent: Move acceleration, 0-100% of axis max. + wait: Whether to block until the move finishes. + start_event: The event number to start the instruction with. + timeout: Maximum time to wait for completion, in seconds. + """ + inst = _make_move_instruction( + abs(delta_normalized), + instr_type=InstructionTypes.MOVE_BY, + velocity_percent=velocity_percent, + acceleration_percent=acceleration_percent, + direction=direction, + ) + send_event = _compose_send_event(start_event) + if wait: + with _MoveWaiter(engine, send_event, axis_name, expected_src=address) as waiter: + load_instruction(engine, address, inst, start_event, send_event) + trigger_event(engine, start_event) + waiter.wait(timeout) + else: + load_instruction(engine, address, inst, start_event, send_event) + trigger_event(engine, start_event) + + +@dataclass +class MoveRequest: + """One axis's contribution to a coordinated multi-axis move. + + Attributes: + address: The axis device's controller-tree address. + axis_name: The axis's display name, used in error messages. + target_normalized: The absolute target, in normalized [0, 1] axis + units. + velocity_percent: Move velocity, 0-100% of axis max. + acceleration_percent: Move acceleration, 0-100% of axis max. + instr_type: The instruction type. + direction: Move direction. + """ + + address: InstructionAddress + axis_name: str + target_normalized: float + velocity_percent: float = 100.0 + acceleration_percent: float = 100.0 + instr_type: InstructionTypes = InstructionTypes.MOVE_TO + direction: AxisDirection = AxisDirection.POSITIVE + + +def move_multi( + engine: GeminiEngine, + requests: List[MoveRequest], + *, + wait: bool = True, + start_event: int = 1, + timeout: float = _DEFAULT_MOVE_TIMEOUT, +) -> None: + """Coordinated multi-axis move -- all axes triggered by the same start event. + + With a coordinated move all axes share the same SEND_EVT, so a single + echo broadcast signals completion for all of them. + + Args: + engine: The Gemini engine to drive the move through. + requests: The per-axis targets to move to together. + wait: Whether to block until every axis's move finishes. + start_event: The event number to start every instruction with. + timeout: Maximum time to wait for completion, in seconds. + """ + if not requests: + return + send_event = _compose_send_event(start_event) + moves: List[LoadedMove] = [] + axis_names: Dict[int, str] = {} + for req in requests: + inst = _make_move_instruction( + req.target_normalized, + instr_type=req.instr_type, + velocity_percent=req.velocity_percent, + acceleration_percent=req.acceleration_percent, + direction=req.direction, + ) + moves.append( + LoadedMove( + address=req.address, + instruction=inst, + start_event=start_event, + send_event=send_event, + ) + ) + axis_names[req.address.byte] = req.axis_name + + if wait: + label = ", ".join(axis_names.values()) + # Wait for an echo from EVERY axis in the coordinated move, not just the + # first. Each axis broadcasts SEND_EVT independently when it reaches its + # target; resolving on the first echo would let the caller advance to + # the next step while slower axes are still in motion, causing a + # subsequent move (e.g. a grip) to fire into a machine that has not + # finished the current one. + expected_srcs = {req.address for req in requests} + with _MoveWaiter(engine, send_event, label, expected_srcs=expected_srcs) as waiter: + load_instructions(engine, moves, timeout) + trigger_event(engine, start_event) + waiter.wait(timeout) + else: + load_instructions(engine, moves, timeout) + trigger_event(engine, start_event) diff --git a/pylabrobot/agilent/bravo/darwin/params.py b/pylabrobot/agilent/bravo/darwin/params.py new file mode 100644 index 00000000000..cf8adc56a01 --- /dev/null +++ b/pylabrobot/agilent/bravo/darwin/params.py @@ -0,0 +1,178 @@ +"""Per-device parameter database access. + +Access to the firmware's parameter database. Reads and writes are +pointer-based -- to access parameter N you first point ``PARAM_DB_RD_PTR`` +or ``PARAM_DB_WR_PTR`` at N, then read/write ``PARAM_DB_VALUE``. If the next +access is to N+1, the pointer auto-increments on the controller side so the +pointer SET can be skipped -- roughly a 2x speedup on sweeps like the W-axis +57-parameter apply. +""" + +from __future__ import annotations + +import threading + +from ..protocol.gemini.engine import GeminiEngine +from ..protocol.gemini.enums import CommonSubCommands +from ..protocol.gemini.packet import InstructionAddress + + +class ParameterAccess: + """Pointer-cached parameter read/write for a single device address. + + Each device (or the master) has its own :class:`ParameterAccess` instance; + the pointer cache is per-device because the pointer is a device-side + register. + """ + + _UNSET = -1 # Sentinel for "no prior pointer". + + def __init__(self, engine: GeminiEngine, address: InstructionAddress): + """Bind this parameter accessor to one device address. + + Args: + engine: The Gemini engine to issue reads and writes through. + address: The controller-tree address of the device to access. + """ + self._engine = engine + self._address = address + self._last_read_ptr: int = self._UNSET + self._last_write_ptr: int = self._UNSET + self._lock = threading.Lock() + + # --- Single-parameter read/write ---------------------------------------- + + def read_uint(self, param_id: int, timeout: float = 5.0) -> int: + """Read one parameter as a raw uint32. + + Args: + param_id: The parameter-database index to read. + timeout: Maximum time to wait for each wire exchange, in seconds. + + Returns: + The parameter's raw value. + """ + with self._lock: + self._ensure_read_ptr(param_id, timeout) + value = self._engine.get_value(self._address, CommonSubCommands.PARAM_DB_VALUE, timeout) + self._last_read_ptr = param_id + return value + + def read_float(self, param_id: int, timeout: float = 5.0) -> float: + """Read one parameter as an IEEE 754 float. + + Args: + param_id: The parameter-database index to read. + timeout: Maximum time to wait for each wire exchange, in seconds. + + Returns: + The parameter's decoded float value. + """ + with self._lock: + self._ensure_read_ptr(param_id, timeout) + value = self._engine.get_float(self._address, CommonSubCommands.PARAM_DB_VALUE, timeout) + self._last_read_ptr = param_id + return value + + def write_uint(self, param_id: int, value: int, timeout: float = 5.0) -> None: + """Write one parameter as a raw uint32. + + Args: + param_id: The parameter-database index to write. + value: The value to write. + timeout: Maximum time to wait for each wire exchange, in seconds. + """ + with self._lock: + self._ensure_write_ptr(param_id, timeout) + self._engine.set_uint(self._address, CommonSubCommands.PARAM_DB_VALUE, value, timeout) + self._last_write_ptr = param_id + + def write_float(self, param_id: int, value: float, timeout: float = 5.0) -> None: + """Write one parameter as an IEEE 754 float. + + Args: + param_id: The parameter-database index to write. + value: The value to write. + timeout: Maximum time to wait for each wire exchange, in seconds. + """ + with self._lock: + self._ensure_write_ptr(param_id, timeout) + self._engine.set_float(self._address, CommonSubCommands.PARAM_DB_VALUE, value, timeout) + self._last_write_ptr = param_id + + # --- Database-wide operations ------------------------------------------- + + def apply(self, timeout: float = 10.0) -> None: + """Commit staged parameter writes. + + Args: + timeout: Maximum time to wait for the wire exchange, in seconds. + """ + self._engine.set_uint(self._address, CommonSubCommands.PARAM_DB_APPLY, 1, timeout) + + def reset(self, timeout: float = 10.0) -> None: + """Reset parameters to their firmware defaults. + + Args: + timeout: Maximum time to wait for the wire exchange, in seconds. + """ + self._engine.set_uint(self._address, CommonSubCommands.PARAM_DB_RESET, 1, timeout) + + def save(self, timeout: float = 10.0) -> None: + """Save parameters to flash. + + Args: + timeout: Maximum time to wait for the wire exchange, in seconds. + """ + self._engine.set_uint(self._address, CommonSubCommands.PARAM_DB_SAVE, 1, timeout) + + def load(self, timeout: float = 10.0) -> None: + """Load parameters from flash. + + Args: + timeout: Maximum time to wait for the wire exchange, in seconds. + """ + self._engine.set_uint(self._address, CommonSubCommands.PARAM_DB_LOAD, 1, timeout) + + def count(self, timeout: float = 5.0) -> int: + """Return the controller's count of parameters in its database. + + Args: + timeout: Maximum time to wait for the wire exchange, in seconds. + + Returns: + The parameter count reported by the device. + """ + return self._engine.get_value(self._address, CommonSubCommands.PARAM_DB_COUNT, timeout) + + def invalidate_cache(self) -> None: + """Forget cached read/write pointers. + + Call this after a reboot or reset operation, since the device-side + pointer registers no longer match what this accessor last set them to. + """ + with self._lock: + self._last_read_ptr = self._UNSET + self._last_write_ptr = self._UNSET + + # --- Internals ------------------------------------------------------------ + + def _ensure_read_ptr(self, param_id: int, timeout: float) -> None: + """Point the read pointer at ``param_id`` unless it is already there. + + Args: + param_id: The parameter-database index the next read targets. + timeout: Maximum time to wait for the wire exchange, in seconds. + """ + if self._last_read_ptr + 1 != param_id: + self._engine.set_uint(self._address, CommonSubCommands.PARAM_DB_RD_PTR, param_id, timeout) + + def _ensure_write_ptr(self, param_id: int, timeout: float) -> None: + """Point the write pointer at ``param_id`` unless it is already there. + + Args: + param_id: The parameter-database index the next write targets. + timeout: Maximum time to wait for the wire exchange, in seconds. + """ + if self._last_write_ptr + 1 != param_id: + self._engine.set_uint(self._address, CommonSubCommands.PARAM_DB_WR_PTR, param_id, timeout) diff --git a/pylabrobot/agilent/bravo/darwin/sequences.py b/pylabrobot/agilent/bravo/darwin/sequences.py new file mode 100644 index 00000000000..6c0d054bc7f --- /dev/null +++ b/pylabrobot/agilent/bravo/darwin/sequences.py @@ -0,0 +1,502 @@ +"""Composite motion sequences: grip, open_gripper, jog. + +These are multi-step procedures that combine parameter-database writes +(peak current / position-error-max), force-mode instructions, and post-move +validation. + +Notes on simplifications: + +- :func:`jog`/:func:`grip` do not need to save and restore the axis's peak- + current parameter around the force move; force scaling is driven entirely + through the instruction-word ``force_percent`` byte from a caller-supplied + peak-current value. +- :func:`jog` validates its final position against a tolerance window, + reporting "exceeded destination" and "unable to reach destination" + separately. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Callable, Optional + +from ..errors import BravoError, ErrorType +from ..protocol.gemini.engine import GeminiEngine +from ..protocol.gemini.enums import AxisDirection, InstructionTypes, ParamDBs +from ..protocol.gemini.instruction import Instruction +from ..protocol.gemini.packet import InstructionAddress +from . import axis as axis_module +from .motion import _compose_send_event, _MoveWaiter, build_load_packets, trigger_event +from .params import ParameterAccess + +# --- Shared helpers ------------------------------------------------------------- + + +def _convert_mm_to_percent(value_mm: float, limit_mm: float) -> float: + """Convert an absolute limit (mm/s or mm/s^2) to a 0-100 percent of axis max. + + Args: + value_mm: The desired absolute value. + limit_mm: The axis's maximum value for the same quantity. + + Returns: + The equivalent percentage, clamped to 100.0. Returns 100.0 if either + input is unknown or non-positive. + """ + if limit_mm <= 0.0 or value_mm <= 0.0: + return 100.0 + return min(100.0, value_mm * 100.0 / limit_mm) + + +def _g_axis_force_percent(grip_current_amps: float) -> float: + """Return the force-percent to use for a G-axis grip given a grip current. + + A linear ramp in amps, normalized against the 0.5A G-axis reference and + scaled by 80/30. The input is the axis-side peak current in amps, not a + 0-1 fraction. + + Args: + grip_current_amps: The target grip current, in amps. + + Returns: + The instruction-word force percent, 0-100. + """ + if grip_current_amps < 0.0: + grip_current_amps = 0.0 + g_reference_amps = 0.5 # The G-axis reference (maximum) peak current. + if abs(grip_current_amps - g_reference_amps) < 1e-3: + return 0.0 # 0 when the caller is already at the reference max. + force = (grip_current_amps / g_reference_amps) * 100.0 * (80.0 / 30.0) + return max(0.0, min(100.0, force)) + + +def _z_axis_force_percent(peak_current_amps: float) -> float: + """Return the force-percent for a Z-axis jog given the peak current in amps. + + Piecewise-linear curve, hand-tuned against measured tip-press currents to + anchor force against real tip presses (0.04A single-tip -> 2%, 0.80A full + 384 -> 90%). + + Args: + peak_current_amps: The target peak current, in amps. + + Returns: + The instruction-word force percent, 0-100. + """ + a = max(0.0, peak_current_amps) + anchors = ( + (0.04, 2.0), + (0.07, 9.0), + (0.10, 11.0), + (0.16, 20.0), + (0.30, 38.0), + (0.60, 67.0), + (0.80, 90.0), + ) + if a <= anchors[0][0]: + return anchors[0][1] + for (a0, f0), (a1, f1) in zip(anchors, anchors[1:]): + if a <= a1: + return f0 + ((a - a0) / (a1 - a0)) * (f1 - f0) + # Beyond the top anchor, extrapolate linearly from 0.60->0.80 then clamp + # at 100%. + a0, f0 = anchors[-2] + a1, f1 = anchors[-1] + extrap = f0 + ((a - a0) / (a1 - a0)) * (f1 - f0) + return min(100.0, extrap) + + +_G_AXIS_REFERENCE_AMPS = 0.5 # The G-axis reference (maximum) peak current. + + +def set_peak_current_amps(params: ParameterAccess, peak_current_amps: float) -> None: + """Write ``I2T_PEAK_CURRENT`` and apply. + + The value is written to the firmware's ``I2T_PEAK_CURRENT`` parameter in + amps with no scaling. The parameter type is Float32. + + Args: + params: The parameter accessor for the target axis's device. + peak_current_amps: The peak current to write, in amps. + """ + params.write_float(int(ParamDBs.I2T_PEAK_CURRENT), max(0.0, peak_current_amps)) + params.apply() + + +def set_position_error_max(params: ParameterAccess, value: float) -> Optional[float]: + """Write ``POS_ERR_LIMIT`` and return its previous value. + + Args: + params: The parameter accessor for the target axis's device. + value: The new position-error limit to write. + + Returns: + The previous value, for restoration, or ``None`` if it could not be + read. + """ + try: + previous: Optional[float] = params.read_float(int(ParamDBs.POS_ERR_LIMIT)) + except Exception: + previous = None + params.write_float(int(ParamDBs.POS_ERR_LIMIT), value) + params.apply() + return previous + + +# --- Force-move primitive ----------------------------------------------------- + + +def force_move( + engine: GeminiEngine, + address: InstructionAddress, + axis_name: str, + target_normalized: float, + *, + direction: AxisDirection, + velocity_percent: float, + acceleration_percent: float, + force_percent: float, + jerk_percent: float = 100.0, + start_event: int = 1, + timeout: float = 10.0, +) -> None: + """Execute a force-controlled instruction: stops on force threshold. + + Builds a ``MOVE_TO`` instruction with non-zero ``force_percent`` and the + caller's direction, loads it onto the axis, triggers it, and waits for + the SEND_EVT echo. + + Always sets ``reset_pos_after_stop`` whenever ``force_percent > 0``: the + firmware's commanded-position counter stays at the full + ``target_normalized`` even when the motor stopped early on a force + threshold hit. Without this, the next move sees a large commanded-vs- + actual residual and trips ``POS_ERR_LIMIT`` (a RESERVED ERROR event, + category 5 specific 3) before a single mm of travel. + + Args: + engine: The Gemini engine to drive the move through. + address: The axis device's controller-tree address. + axis_name: The axis's display name, used in error messages. + target_normalized: The farthest target position, in normalized [0, 1] + axis units. + direction: Move direction. + velocity_percent: Move velocity, 0-100% of axis max. + acceleration_percent: Move acceleration, 0-100% of axis max. + force_percent: Force limit, 0-100%; the move stops when this + threshold is reached. + jerk_percent: Move jerk, 0-100% of axis max. + start_event: The event number to start the instruction with. + timeout: Maximum time to wait for the move to finish, in seconds. + """ + send_event = _compose_send_event(start_event) + inst = Instruction( + instr_type=InstructionTypes.MOVE_TO, + velocity_percent=velocity_percent, + acceleration_percent=acceleration_percent, + jerk_percent=jerk_percent, + force_percent=force_percent, + direction=direction, + reset_pos_after_stop=(force_percent != 0.0), + ) + inst.volume = target_normalized + # Match the MoveAbsolute convention: trig_at = target position. + inst.trig_at_float = target_normalized + + packets = build_load_packets(address, inst, start_event, send_event) + with _MoveWaiter(engine, send_event, axis_name, expected_src=address) as waiter: + engine.send_multipacket(packets, timeout) + trigger_event(engine, start_event) + waiter.wait(timeout) + + +# --- Grip (G axis: close gripper with force) -------------------------------- + + +@dataclass +class GripParams: + """Parameters for a force-controlled grip move. + + Attributes: + target_position: Destination, in normalized axis units. + velocity_limit: The G axis's velocity ceiling, in native units. + acceleration_limit: The G axis's acceleration ceiling. + grip_current_amps: Grip current in amps, fed to + :func:`_g_axis_force_percent`. + overshoot_normalized: Extra distance past the target, in normalized + units (the caller converts, e.g. 4mm / hardware_range). + velocity_mm: Desired velocity in mm/s (converted to a percentage). + acceleration_mm: Desired acceleration in mm/s^2. + """ + + target_position: float + velocity_limit: float + acceleration_limit: float + grip_current_amps: float + overshoot_normalized: float + velocity_mm: float = 500.0 + acceleration_mm: float = 500.0 + + +def grip( + engine: GeminiEngine, + g_axis_address: InstructionAddress, + g_axis_params: ParameterAccess, + p: GripParams, + *, + timeout: float = 8.0, +) -> None: + """Close the gripper with configured force. Disables the motor when done. + + The axis runs with the firmware-default ``I2T_PEAK_CURRENT``, and force + scaling is done entirely via the instruction-word ``force_percent`` byte. + ``I2T_PEAK_CURRENT`` is therefore not written here: writing an alternative + peak can fail with ``OUT_OF_RANGE`` on the G axis in some firmware + states, and a ``finally``-block restore to a cached original could then + itself NAK and mask the real error. + + ``overshoot_normalized`` is already divided by ``hardware_range`` by the + caller, so ``farthest = target + overshoot`` stays in the normalized + [0, 1] axis frame. ``farthest`` is additionally clamped to 1.0 -- a value + past that would exceed ``hardware_max`` and is guaranteed to be rejected + by the firmware as ``OUT_OF_RANGE`` on the move instruction. + + Args: + engine: The Gemini engine to drive the grip through. + g_axis_address: The G axis device's controller-tree address. + g_axis_params: The parameter accessor for the G axis device. + p: The grip parameters. + timeout: Maximum time to wait for the move to finish, in seconds. + """ + del g_axis_params # Not written to; see the docstring above. + velocity_pct = _convert_mm_to_percent(p.velocity_mm, p.velocity_limit) + acceleration_pct = _convert_mm_to_percent(p.acceleration_mm, p.acceleration_limit) + force_pct = _g_axis_force_percent(p.grip_current_amps) + farthest = min(1.0, p.target_position + p.overshoot_normalized) + + try: + force_move( + engine, + g_axis_address, + "G", + farthest, + direction=AxisDirection.POSITIVE, + velocity_percent=velocity_pct, + acceleration_percent=acceleration_pct, + force_percent=force_pct, + timeout=timeout, + ) + finally: + try: + axis_module.disable(engine, g_axis_address, "G") + except BravoError: + pass # Non-fatal. + + +# --- Open gripper (G axis: move to position) -------------------------------- + + +@dataclass +class OpenGripperParams: + """Parameters for an open-gripper move. + + Attributes: + target_position: Destination, in normalized axis units. + current_position: Current position, in normalized axis units, used to + determine move direction. + velocity_limit: The G axis's velocity ceiling, in native units. + acceleration_limit: The G axis's acceleration ceiling. + peak_current_amps: I2T peak current to set before the move, in amps. + velocity_mm: Desired velocity in mm/s (converted to a percentage). + acceleration_mm: Desired acceleration in mm/s^2. + """ + + target_position: float + current_position: float + velocity_limit: float + acceleration_limit: float + peak_current_amps: float + velocity_mm: float = 60.0 + acceleration_mm: float = 600.0 + + +def open_gripper( + engine: GeminiEngine, + g_axis_address: InstructionAddress, + g_axis_params: ParameterAccess, + p: OpenGripperParams, + *, + timeout: float = 6.0, +) -> None: + """Open the gripper to ``target_position``. Disables the motor when done. + + Args: + engine: The Gemini engine to drive the move through. + g_axis_address: The G axis device's controller-tree address. + g_axis_params: The parameter accessor for the G axis device. + p: The open-gripper parameters. + timeout: Maximum time to wait for the move to finish, in seconds. + """ + set_peak_current_amps(g_axis_params, p.peak_current_amps) + direction = ( + AxisDirection.NEGATIVE if p.target_position < p.current_position else AxisDirection.POSITIVE + ) + velocity_pct = _convert_mm_to_percent(p.velocity_mm, p.velocity_limit) + acceleration_pct = _convert_mm_to_percent(p.acceleration_mm, p.acceleration_limit) + + inst = Instruction( + instr_type=InstructionTypes.MOVE_TO, + velocity_percent=velocity_pct, + acceleration_percent=acceleration_pct, + # jerk_percent=0.0 historically meant "default", which clamps 0 to 100. + # Use 100 directly so the wire byte is 0xFF. + jerk_percent=100.0, + force_percent=0.0, + direction=direction, + ) + inst.volume = p.target_position + inst.trig_at_float = p.target_position + + send_event = _compose_send_event(1) + packets = build_load_packets(g_axis_address, inst, start_event=1, send_event=send_event) + with _MoveWaiter(engine, send_event, "G", expected_src=g_axis_address) as waiter: + engine.send_multipacket(packets, timeout) + trigger_event(engine, 1) + waiter.wait(timeout) + + try: + axis_module.disable(engine, g_axis_address, "G") + except BravoError: + pass + + +# --- Jog (Z or G axis: force move with validation) --------------------------- + + +@dataclass +class JogParams: + """Parameters for a force-controlled jog with post-move validation. + + Attributes: + axis_name: ``"Z"`` or ``"G"``. + target_position: Normalized [0, 1] axis target. + tolerance: Normalized tolerance window for validation. + peak_current_amps: Current used to derive the instruction's force + percent, in amps. + velocity_mm: Desired velocity in mm/s; ``velocity_limit`` is used + instead if this is non-positive. + acceleration_mm: Desired acceleration in mm/s^2; ``acceleration_limit`` + is used instead if this is non-positive. + velocity_limit: The axis's velocity ceiling, in native units. + acceleration_limit: The axis's acceleration ceiling. + exceed_epsilon: Epsilon on the "exceeded destination" check, in + normalized axis units. The check is defined as 0.05 mm; callers + should divide by the axis's hardware_range before passing here (e.g. + 0.05 / 250 for Z). Too large a value (e.g. the raw 0.05 literal on a + 250-mm axis) makes the check trip on roughly 12mm of headroom and + falsely flags normal near-target landings as "exceeded". + """ + + axis_name: str + target_position: float + tolerance: float + peak_current_amps: float + velocity_mm: float + acceleration_mm: float + velocity_limit: float + acceleration_limit: float + exceed_epsilon: float = 0.0002 + + +def jog( + engine: GeminiEngine, + axis_address: InstructionAddress, + axis_params: ParameterAccess, + p: JogParams, + *, + read_position: Callable[[GeminiEngine, InstructionAddress], float], + timeout: float = 30.0, + settle: float = 0.25, +) -> float: + """Force-controlled jog on Z or G. Returns the final position (normalized). + + This path emits no parameter-database writes to the axis: neither the + peak current nor the position-error-max is manipulated. The firmware + defaults for ``I2T_PEAK_CURRENT`` and ``POS_ERR_LIMIT`` remain in force, + and the jog's force control is done entirely via the ``force_percent`` + bits of the instruction word. + + Writing ``POS_ERR_LIMIT=0`` in particular is unsafe for a force move -- + any tracking error would then exceed zero, so the firmware would power + down the motor the instant commanded-vs-actual diverges, raising a + RESERVED ERROR event before tip resistance can even be sensed. + + ``peak_current_amps`` therefore only feeds ``force_percent`` (via + :func:`_z_axis_force_percent`/:func:`_g_axis_force_percent`); it is not + written to ``I2T_PEAK_CURRENT``. + + Args: + engine: The Gemini engine to drive the jog through. + axis_address: The axis device's controller-tree address. + axis_params: The parameter accessor for the axis device (unused; kept + for a uniform sequence-function signature). + p: The jog parameters. + read_position: Returns the axis's current normalized position. + timeout: Maximum time to wait for the move to finish, in seconds. + settle: Delay after a successful jog before returning, in seconds. + + Returns: + The axis's final normalized position. + + Raises: + ValueError: If ``p.axis_name`` is not ``"Z"`` or ``"G"``. + BravoError: If the final position exceeds the farthest allowed point, + or falls short of the tolerance window around the target. + """ + del axis_params # Unused; see the docstring above. + if p.axis_name not in ("Z", "G"): + raise ValueError(f"jog only supported on Z and G, got {p.axis_name}") + + velocity_mm = p.velocity_mm if p.velocity_mm > 0 else p.velocity_limit + acceleration_mm = p.acceleration_mm if p.acceleration_mm > 0 else p.acceleration_limit + velocity_pct = _convert_mm_to_percent(velocity_mm, p.velocity_limit) + acceleration_pct = _convert_mm_to_percent(acceleration_mm, p.acceleration_limit) + farthest = p.target_position + max(0.0, p.tolerance) + force_pct = ( + _z_axis_force_percent(p.peak_current_amps) + if p.axis_name == "Z" + else _g_axis_force_percent(p.peak_current_amps) + ) + + force_move( + engine, + axis_address, + p.axis_name, + farthest, + direction=AxisDirection.POSITIVE, + velocity_percent=velocity_pct, + acceleration_percent=acceleration_pct, + force_percent=force_pct, + timeout=timeout, + ) + + final_position = read_position(engine, axis_address) + if final_position > (farthest - p.exceed_epsilon): + raise BravoError( + ErrorType.EXCEEDED_DEST, + custom_text=( + f"Exceeded destination on {p.axis_name}. " + f"Target={p.target_position:.2f}, actual={final_position:.2f}, " + f"farthest={farthest:.2f}, epsilon={p.exceed_epsilon:.4f}." + ), + ) + if final_position < (p.target_position - p.tolerance): + raise BravoError( + ErrorType.UNABLE_TO_REACH_DEST, + custom_text=( + f"Unable to reach destination on {p.axis_name} within tolerance. " + f"Target={p.target_position:.2f}, actual={final_position:.2f}." + ), + ) + time.sleep(settle) + return final_position diff --git a/pylabrobot/agilent/bravo/darwin/testdata/darwin_golden_frames.json b/pylabrobot/agilent/bravo/darwin/testdata/darwin_golden_frames.json new file mode 100644 index 00000000000..1e700c0ea3c --- /dev/null +++ b/pylabrobot/agilent/bravo/darwin/testdata/darwin_golden_frames.json @@ -0,0 +1,1226 @@ +{ + "axis_commutate_normal": [ + [ + 4, + 1, + 1, + 56, + 2 + ], + [ + 4, + 1, + 3, + 56, + 0 + ], + [ + 4, + 1, + 3, + 56, + 0 + ], + [ + 4, + 1, + 3, + 56, + 0 + ] + ], + "axis_commutate_retry_on_regression": [ + [ + 4, + 1, + 1, + 56, + 2 + ], + [ + 4, + 1, + 3, + 56, + 0 + ], + [ + 4, + 1, + 3, + 56, + 0 + ], + [ + 4, + 1, + 1, + 56, + 2 + ], + [ + 4, + 1, + 3, + 56, + 0 + ], + [ + 4, + 1, + 3, + 56, + 0 + ], + [ + 4, + 1, + 3, + 56, + 0 + ] + ], + "axis_home_normal": [ + [ + 4, + 0, + 3, + 56, + 0 + ], + [ + 4, + 0, + 1, + 54, + 0 + ], + [ + 4, + 0, + 1, + 56, + 5 + ], + [ + 4, + 0, + 3, + 56, + 0 + ], + [ + 4, + 0, + 3, + 56, + 0 + ], + [ + 4, + 0, + 3, + 56, + 0 + ] + ], + "axis_home_retry_on_regression": [ + [ + 4, + 0, + 3, + 56, + 0 + ], + [ + 4, + 0, + 1, + 54, + 0 + ], + [ + 4, + 0, + 1, + 56, + 5 + ], + [ + 4, + 0, + 3, + 56, + 0 + ], + [ + 4, + 0, + 1, + 56, + 2 + ], + [ + 4, + 0, + 3, + 56, + 0 + ], + [ + 4, + 0, + 3, + 56, + 0 + ], + [ + 4, + 0, + 3, + 56, + 0 + ], + [ + 4, + 0, + 3, + 56, + 0 + ], + [ + 4, + 0, + 1, + 54, + 0 + ], + [ + 4, + 0, + 1, + 56, + 5 + ], + [ + 4, + 0, + 3, + 56, + 0 + ], + [ + 4, + 0, + 3, + 56, + 0 + ], + [ + 4, + 0, + 3, + 56, + 0 + ] + ], + "axis_initialize": [ + [ + 4, + 1, + 3, + 56, + 0 + ], + [ + 4, + 1, + 1, + 56, + 2 + ], + [ + 4, + 1, + 3, + 56, + 0 + ], + [ + 4, + 1, + 3, + 56, + 0 + ], + [ + 4, + 1, + 3, + 56, + 0 + ], + [ + 4, + 1, + 3, + 56, + 0 + ], + [ + 4, + 1, + 1, + 54, + 0 + ], + [ + 4, + 1, + 1, + 56, + 5 + ], + [ + 4, + 1, + 3, + 56, + 0 + ], + [ + 4, + 1, + 3, + 56, + 0 + ], + [ + 4, + 1, + 3, + 56, + 0 + ] + ], + "move_single_axis": [ + [ + 4, + 1, + 1, + 20, + 1 + ], + [ + 4, + 1, + 1, + 21, + 3855404032 + ], + [ + 4, + 1, + 1, + 21, + 65791 + ], + [ + 4, + 1, + 1, + 21, + 1058642330 + ], + [ + 4, + 1, + 1, + 21, + 1058642330 + ], + [ + 4, + 1, + 1, + 22, + 1 + ], + [ + 4, + 1, + 1, + 23, + 386 + ], + [ + 63, + 0, + 1, + 0, + 1 + ] + ], + "move_multi_axis": [ + [ + 4, + 1, + 1, + 20, + 1 + ], + [ + 4, + 1, + 1, + 21, + 4294967040 + ], + [ + 4, + 1, + 1, + 21, + 65791 + ], + [ + 4, + 1, + 1, + 21, + 1056964608 + ], + [ + 4, + 1, + 1, + 21, + 1056964608 + ], + [ + 4, + 1, + 1, + 22, + 1 + ], + [ + 4, + 1, + 1, + 23, + 386 + ], + [ + 4, + 0, + 1, + 20, + 1 + ], + [ + 4, + 0, + 1, + 21, + 4294967040 + ], + [ + 4, + 0, + 1, + 21, + 65791 + ], + [ + 4, + 0, + 1, + 21, + 1053609165 + ], + [ + 4, + 0, + 1, + 21, + 1053609165 + ], + [ + 4, + 0, + 1, + 22, + 1 + ], + [ + 4, + 0, + 1, + 23, + 386 + ], + [ + 63, + 0, + 1, + 0, + 1 + ] + ], + "jog_z_axis": [ + [ + 5, + 0, + 1, + 20, + 1 + ], + [ + 5, + 0, + 1, + 21, + 1431655680 + ], + [ + 5, + 0, + 1, + 21, + 352511 + ], + [ + 5, + 0, + 1, + 21, + 1060320051 + ], + [ + 5, + 0, + 1, + 21, + 1060320051 + ], + [ + 5, + 0, + 1, + 22, + 1 + ], + [ + 5, + 0, + 1, + 23, + 386 + ], + [ + 63, + 0, + 1, + 0, + 1 + ], + [ + 5, + 0, + 3, + 30, + 0 + ] + ], + "waxis_param_apply_96_d_70": [ + [ + 5, + 1, + 1, + 7, + 24 + ], + [ + 5, + 1, + 1, + 8, + 1050253722 + ], + [ + 5, + 1, + 1, + 8, + 1157636096 + ], + [ + 5, + 1, + 1, + 7, + 28 + ], + [ + 5, + 1, + 1, + 8, + 1050253722 + ], + [ + 5, + 1, + 1, + 8, + 1157636096 + ], + [ + 5, + 1, + 1, + 7, + 33 + ], + [ + 5, + 1, + 1, + 8, + 1071644672 + ], + [ + 5, + 1, + 1, + 8, + 1036831949 + ], + [ + 5, + 1, + 1, + 8, + 992204554 + ], + [ + 5, + 1, + 1, + 8, + 1064514355 + ], + [ + 5, + 1, + 1, + 7, + 49 + ], + [ + 5, + 1, + 1, + 8, + 1143603200 + ], + [ + 5, + 1, + 1, + 8, + 1077936128 + ], + [ + 5, + 1, + 1, + 8, + 981668463 + ], + [ + 5, + 1, + 1, + 7, + 112 + ], + [ + 5, + 1, + 1, + 8, + 1087048253 + ], + [ + 5, + 1, + 1, + 8, + 1151090688 + ], + [ + 5, + 1, + 1, + 8, + 1087048253 + ], + [ + 5, + 1, + 1, + 7, + 126 + ], + [ + 5, + 1, + 1, + 8, + 2000 + ], + [ + 5, + 1, + 1, + 8, + 1035660228 + ], + [ + 5, + 1, + 1, + 8, + 1045220557 + ], + [ + 5, + 1, + 1, + 7, + 119 + ], + [ + 5, + 1, + 1, + 8, + 953267991 + ], + [ + 5, + 1, + 1, + 7, + 124 + ], + [ + 5, + 1, + 1, + 8, + 1008981770 + ], + [ + 5, + 1, + 1, + 7, + 99 + ], + [ + 5, + 1, + 1, + 8, + 1025087898 + ], + [ + 5, + 1, + 1, + 7, + 105 + ], + [ + 5, + 1, + 1, + 8, + 1015580809 + ], + [ + 5, + 1, + 1, + 7, + 108 + ], + [ + 5, + 1, + 1, + 8, + 1043708091 + ], + [ + 5, + 1, + 1, + 7, + 81 + ], + [ + 5, + 1, + 1, + 8, + 1054615798 + ], + [ + 5, + 1, + 1, + 8, + 1142292480 + ], + [ + 5, + 1, + 1, + 7, + 92 + ], + [ + 5, + 1, + 1, + 8, + 1035660228 + ], + [ + 5, + 1, + 1, + 7, + 67 + ], + [ + 5, + 1, + 1, + 8, + 1051931443 + ], + [ + 5, + 1, + 1, + 8, + 0 + ], + [ + 5, + 1, + 1, + 8, + 0 + ], + [ + 5, + 1, + 1, + 8, + 0 + ], + [ + 5, + 1, + 1, + 7, + 42 + ], + [ + 5, + 1, + 1, + 8, + 1067450368 + ], + [ + 5, + 1, + 1, + 8, + 1008981770 + ], + [ + 5, + 1, + 1, + 8, + 990057071 + ], + [ + 5, + 1, + 1, + 7, + 57 + ], + [ + 5, + 1, + 1, + 8, + 1142620160 + ], + [ + 5, + 1, + 1, + 8, + 1117126656 + ], + [ + 5, + 1, + 1, + 8, + 977574822 + ], + [ + 5, + 1, + 1, + 7, + 78 + ], + [ + 5, + 1, + 1, + 8, + 0 + ], + [ + 5, + 1, + 1, + 7, + 125 + ], + [ + 5, + 1, + 1, + 8, + 1029215093 + ], + [ + 5, + 1, + 1, + 7, + 39 + ], + [ + 5, + 1, + 1, + 8, + 1068289229 + ], + [ + 5, + 1, + 1, + 8, + 1065353216 + ], + [ + 5, + 1, + 1, + 8, + 990057071 + ], + [ + 5, + 1, + 1, + 7, + 54 + ], + [ + 5, + 1, + 1, + 8, + 1144750080 + ], + [ + 5, + 1, + 1, + 8, + 1107296256 + ], + [ + 5, + 1, + 1, + 7, + 45 + ], + [ + 5, + 1, + 1, + 8, + 1065772646 + ], + [ + 5, + 1, + 1, + 8, + 0 + ], + [ + 5, + 1, + 1, + 8, + 990057071 + ], + [ + 5, + 1, + 1, + 7, + 60 + ], + [ + 5, + 1, + 1, + 8, + 1143111680 + ], + [ + 5, + 1, + 1, + 8, + 1111490560 + ], + [ + 5, + 1, + 1, + 8, + 981668463 + ], + [ + 5, + 1, + 1, + 7, + 115 + ], + [ + 5, + 1, + 1, + 8, + 1087058739 + ], + [ + 5, + 1, + 1, + 8, + 1151090688 + ], + [ + 5, + 1, + 1, + 8, + 1087058739 + ], + [ + 5, + 1, + 1, + 7, + 71 + ], + [ + 5, + 1, + 1, + 8, + 1036831949 + ], + [ + 5, + 1, + 1, + 8, + 0 + ], + [ + 5, + 1, + 1, + 8, + 0 + ], + [ + 5, + 1, + 1, + 8, + 0 + ], + [ + 5, + 1, + 1, + 7, + 120 + ], + [ + 5, + 1, + 1, + 8, + 953267991 + ], + [ + 5, + 1, + 1, + 7, + 48 + ], + [ + 5, + 1, + 1, + 8, + 1077936128 + ], + [ + 5, + 1, + 1, + 10, + 1 + ] + ], + "controller_initialize": [ + [ + 4, + 0, + 1, + 19, + 0 + ], + [ + 4, + 1, + 1, + 19, + 0 + ], + [ + 5, + 0, + 1, + 19, + 0 + ], + [ + 5, + 0, + 1, + 6, + 128 + ], + [ + 5, + 0, + 3, + 8, + 0 + ], + [ + 6, + 0, + 1, + 19, + 0 + ], + [ + 6, + 0, + 1, + 6, + 128 + ], + [ + 6, + 0, + 3, + 8, + 0 + ], + [ + 6, + 1, + 1, + 19, + 0 + ], + [ + 6, + 1, + 1, + 6, + 128 + ], + [ + 6, + 1, + 3, + 8, + 0 + ], + [ + 5, + 1, + 1, + 19, + 0 + ], + [ + 5, + 1, + 1, + 6, + 128 + ], + [ + 5, + 1, + 3, + 8, + 0 + ] + ] +} \ No newline at end of file diff --git a/pylabrobot/agilent/bravo/darwin/timing_tests.py b/pylabrobot/agilent/bravo/darwin/timing_tests.py new file mode 100644 index 00000000000..e2272751787 --- /dev/null +++ b/pylabrobot/agilent/bravo/darwin/timing_tests.py @@ -0,0 +1,240 @@ +"""Pins timing constants that a golden-frame capture cannot see. + +A golden-frame comparison asserts on *what* gets sent, not *when*. The +simulators in :mod:`.darwin_golden_frame_tests` are deliberately +deterministic by call count rather than by wall-clock time, precisely so +that scheduling jitter cannot change a captured packet sequence -- but a +direct consequence is that a regression to a poll interval or a timeout +deadline leaves no trace there at all: the same packets go out over the +wire whether a poll happens every 0.2 s or every 4 s. This module pins that +class of constant directly, either by mocking ``time.sleep``/ +``time.monotonic`` and asserting on the arguments the ported code calls +them with, or, for a default that no golden scenario ever exercises +because every golden call site overrides it explicitly, by asserting the +default value itself. +""" + +from __future__ import annotations + +import inspect +import unittest +from unittest import mock + +from ..errors import BravoError, ErrorType +from ..protocol.gemini.engine import GeminiEngine +from ..protocol.gemini.enums import MotorState +from . import axis as axis_module +from . import motion, sequences +from .darwin_golden_frame_tests import FakeGeminiTransport, _install_state_sim, _StateSim +from .topology import axis_address + + +class PollIntervalTests(unittest.TestCase): + """Pins ``_STATE_POLL`` at each of its call sites via mocked ``time.sleep``.""" + + def test_commutate_sleeps_at_the_state_poll_interval(self): + fake = FakeGeminiTransport() + engine = GeminiEngine(fake) + engine.start_receiving() + try: + addr = axis_address("x") + sim = _StateSim(settle_after=3) + _install_state_sim(fake, addr, sim) + with mock.patch("pylabrobot.agilent.bravo.darwin.axis.sleep") as mock_sleep: + axis_module.commutate(engine, addr, "X") + finally: + engine.stop_receiving() + self.assertTrue(mock_sleep.call_args_list, "commutate() never called time.sleep") + for call in mock_sleep.call_args_list: + self.assertEqual(call.args[0], axis_module._STATE_POLL) + + def test_home_sleeps_at_the_state_poll_interval(self): + fake = FakeGeminiTransport() + engine = GeminiEngine(fake) + engine.start_receiving() + try: + addr = axis_address("y") + sim = _StateSim(settle_after=3) + sim.force(MotorState.COMMUTATED) + _install_state_sim(fake, addr, sim) + with mock.patch("pylabrobot.agilent.bravo.darwin.axis.sleep") as mock_sleep: + axis_module.home(engine, addr, "Y") + finally: + engine.stop_receiving() + self.assertTrue(mock_sleep.call_args_list, "home() never called time.sleep") + for call in mock_sleep.call_args_list: + self.assertEqual(call.args[0], axis_module._STATE_POLL) + + def test_enable_sleeps_at_the_state_poll_interval(self): + """``enable()`` has no ``poll`` parameter of its own -- it uses + ``_STATE_POLL`` directly and is not exercised by any golden scenario + (every golden axis starts enabled).""" + fake = FakeGeminiTransport() + engine = GeminiEngine(fake) + engine.start_receiving() + try: + addr = axis_address("x") + sim = _StateSim(settle_after=3) + sim.force(MotorState.DISABLED) + _install_state_sim(fake, addr, sim) + with mock.patch("pylabrobot.agilent.bravo.darwin.axis.sleep") as mock_sleep: + axis_module.enable(engine, addr, "X") + finally: + engine.stop_receiving() + self.assertTrue(mock_sleep.call_args_list, "enable() never called time.sleep") + for call in mock_sleep.call_args_list: + self.assertEqual(call.args[0], axis_module._STATE_POLL) + + def test_initialize_force_grace_delay(self): + """``initialize(force=True)`` sleeps a fixed 0.05 s after disabling the + axis, before re-commutating -- a magic literal, not a named constant, + but the same "invisible to golden" shape.""" + fake = FakeGeminiTransport() + engine = GeminiEngine(fake) + engine.start_receiving() + try: + addr = axis_address("x") + sim = _StateSim(settle_after=3) + sim.force(MotorState.READY) + _install_state_sim(fake, addr, sim) + with mock.patch("pylabrobot.agilent.bravo.darwin.axis.sleep") as mock_sleep: + axis_module.initialize(engine, addr, "X", force=True) + finally: + engine.stop_receiving() + self.assertTrue(mock_sleep.call_args_list, "initialize(force=True) never called time.sleep") + self.assertEqual(mock_sleep.call_args_list[0], mock.call(0.05)) + + +class TimeoutDeadlineTests(unittest.TestCase): + """Pins the commutation and homing deadlines: the "obvious candidates". + + Each test mocks ``time.monotonic`` with an exact, finite sequence of + return values (never a formula tied to real elapsed time, which is what + made the earlier wall-clock simulators flaky) so the deadline comparison + is exercised precisely at a point just under, and just over, the actual + constant -- proving both that the constant's numeric value is what it + should be, and that it is actually wired into the timeout check. + """ + + def test_commutate_does_not_time_out_just_under_the_deadline(self): + deadline = axis_module._DEFAULT_COMMUTATE_TIMEOUT + fake = FakeGeminiTransport() + engine = GeminiEngine(fake) + engine.start_receiving() + try: + addr = axis_address("x") + sim = _StateSim(settle_after=2) # Settles on the 2nd read, right after the elapsed check. + _install_state_sim(fake, addr, sim) + with ( + mock.patch("pylabrobot.agilent.bravo.darwin.axis.sleep"), + mock.patch( + "pylabrobot.agilent.bravo.darwin.axis.monotonic", + side_effect=[0.0, deadline - 0.1], + ), + ): + axis_module.commutate(engine, addr, "X") # Must not raise. + finally: + engine.stop_receiving() + + def test_commutate_times_out_just_over_the_deadline(self): + deadline = axis_module._DEFAULT_COMMUTATE_TIMEOUT + fake = FakeGeminiTransport() + engine = GeminiEngine(fake) + engine.start_receiving() + try: + addr = axis_address("x") + sim = _StateSim(settle_after=10**9) # Never settles. + _install_state_sim(fake, addr, sim) + with ( + mock.patch("pylabrobot.agilent.bravo.darwin.axis.sleep"), + mock.patch( + "pylabrobot.agilent.bravo.darwin.axis.monotonic", + side_effect=[0.0, deadline + 0.1], + ), + ): + with self.assertRaises(BravoError) as ctx: + axis_module.commutate(engine, addr, "X") + finally: + engine.stop_receiving() + self.assertEqual(ctx.exception.error_type, ErrorType.COULD_NOT_ALIGN) + + def test_home_does_not_time_out_just_under_the_deadline(self): + deadline = axis_module._DEFAULT_HOME_TIMEOUT + fake = FakeGeminiTransport() + engine = GeminiEngine(fake) + engine.start_receiving() + try: + addr = axis_address("y") + sim = _StateSim(settle_after=2) + sim.force(MotorState.COMMUTATED) + _install_state_sim(fake, addr, sim) + with ( + mock.patch("pylabrobot.agilent.bravo.darwin.axis.sleep"), + mock.patch( + "pylabrobot.agilent.bravo.darwin.axis.monotonic", + side_effect=[0.0, deadline - 0.1], + ), + ): + axis_module.home(engine, addr, "Y") # Must not raise. + finally: + engine.stop_receiving() + + def test_home_times_out_just_over_the_deadline(self): + deadline = axis_module._DEFAULT_HOME_TIMEOUT + fake = FakeGeminiTransport() + engine = GeminiEngine(fake) + engine.start_receiving() + try: + addr = axis_address("y") + sim = _StateSim(settle_after=10**9) + sim.force(MotorState.COMMUTATED) + _install_state_sim(fake, addr, sim) + with ( + mock.patch("pylabrobot.agilent.bravo.darwin.axis.sleep"), + mock.patch( + "pylabrobot.agilent.bravo.darwin.axis.monotonic", + side_effect=[0.0, deadline + 0.1], + ), + ): + with self.assertRaises(BravoError) as ctx: + axis_module.home(engine, addr, "Y") + finally: + engine.stop_receiving() + self.assertEqual(ctx.exception.error_type, ErrorType.COULD_NOT_HOME) + + def test_default_timeout_values(self): + """Pins the exact constants, independent of the comparison logic above.""" + self.assertEqual(axis_module._STATE_POLL, 0.2) + self.assertEqual(axis_module._DEFAULT_COMMUTATE_TIMEOUT, 15.0) + self.assertEqual(axis_module._DEFAULT_HOME_TIMEOUT, 20.0) + + +class OtherInvisibleConstantsTests(unittest.TestCase): + """Direct value pins for the remaining wall-clock-only constants found in + motion.py and sequences.py. + + These share the same shape (no golden scenario exercises their default, + since every golden call site passes an explicit override) but are pinned + by value equality rather than by mocked behavior: ``_DEFAULT_SETTLE_POLL`` + and ``_BUSY_CONFIRM`` belong to ``wait_for_ready``/``wait_for_all_ready``, + which the ported ``DarwinController.move()`` does not call at all (it + uses ``_MoveWaiter`` instead) -- a full behavioral mock would be + exercising a path this port's controller never reaches, so a value pin is + the honest level of coverage for now. + """ + + def test_motion_timeout_constants(self): + self.assertEqual(motion._DEFAULT_MOVE_TIMEOUT, 30.0) + self.assertEqual(motion._DEFAULT_SETTLE_POLL, 0.01) + self.assertEqual(motion._BUSY_CONFIRM, 0.5) + + def test_sequences_default_timeouts_and_settle(self): + self.assertEqual(inspect.signature(sequences.force_move).parameters["timeout"].default, 10.0) + self.assertEqual(inspect.signature(sequences.grip).parameters["timeout"].default, 8.0) + self.assertEqual(inspect.signature(sequences.open_gripper).parameters["timeout"].default, 6.0) + self.assertEqual(inspect.signature(sequences.jog).parameters["timeout"].default, 30.0) + self.assertEqual(inspect.signature(sequences.jog).parameters["settle"].default, 0.25) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/darwin/topology.py b/pylabrobot/agilent/bravo/darwin/topology.py new file mode 100644 index 00000000000..5b02ff5d200 --- /dev/null +++ b/pylabrobot/agilent/bravo/darwin/topology.py @@ -0,0 +1,107 @@ +"""Darwin controller-node topology. + +Three Two-Axis BLDC nodes, each with two devices: + + node 4 (DarwinYX): device 0 = Y, device 1 = X + node 5 (DarwinZW): device 0 = Z, device 1 = W + node 6 (DarwinGZg): device 0 = G, device 1 = Zg + +The master node lives at ``InstructionAddress(1, 0)``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Tuple + +from ..protocol.gemini.packet import InstructionAddress +from ..types import Axis + + +@dataclass(frozen=True) +class NodeSpec: + """A Two-Axis BLDC controller node in the Darwin tree. + + Attributes: + name: The node's descriptive name, e.g. ``"DarwinYX"``. + node_id: The node's address on the controller tree. + axes: The axis driven by device 0 and the axis driven by device 1. + """ + + name: str + node_id: int + axes: Tuple[Axis, Axis] + + def device_address(self, axis: Axis) -> InstructionAddress: + """Return this node's device address for one of its two axes. + + Args: + axis: The axis to address; must be one of :attr:`axes`. + + Returns: + The controller-tree address of the device driving ``axis``. + """ + dev_id = self.axes.index(axis) + return InstructionAddress(node_id=self.node_id, dev_id=dev_id) + + @property + def address(self) -> InstructionAddress: + """Address of the node itself (device 0 -- used for node-level subcommands).""" + return InstructionAddress(node_id=self.node_id, dev_id=0) + + +DARWIN_YX = NodeSpec("DarwinYX", node_id=4, axes=("y", "x")) +DARWIN_ZW = NodeSpec("DarwinZW", node_id=5, axes=("z", "w")) +DARWIN_GZG = NodeSpec("DarwinGZg", node_id=6, axes=("g", "zg")) + +CONTROLLER_NODES: Tuple[NodeSpec, ...] = (DARWIN_YX, DARWIN_ZW, DARWIN_GZG) + + +# Axis -> NodeSpec lookup, built once at import time. +_AXIS_TO_NODE = {} +for _node in CONTROLLER_NODES: + for _axis in _node.axes: + _AXIS_TO_NODE[_axis] = _node +del _node, _axis + + +def axis_address(axis: Axis) -> InstructionAddress: + """Return the Gemini controller-tree address for the given axis's motor device. + + Args: + axis: The axis to look up. + + Returns: + The device address that owns ``axis``. + + Raises: + ValueError: If ``axis`` has no entry in the Darwin topology. + """ + try: + node = _AXIS_TO_NODE[axis] + except KeyError as exc: + raise ValueError(f"No Darwin topology entry for axis {axis!r}") from exc + return node.device_address(axis) + + +def axis_node(axis: Axis) -> NodeSpec: + """Return the node that owns the given axis. + + Args: + axis: The axis to look up. + + Returns: + The owning :class:`NodeSpec`. + + Raises: + ValueError: If ``axis`` has no entry in the Darwin topology. + """ + try: + return _AXIS_TO_NODE[axis] + except KeyError as exc: + raise ValueError(f"No Darwin topology entry for axis {axis!r}") from exc + + +def all_axes() -> Tuple[Axis, ...]: + """Return all six Darwin axes in a consistent order: X, Y, Z, W, G, Zg.""" + return ("x", "y", "z", "w", "g", "zg") diff --git a/pylabrobot/agilent/bravo/darwin/waxis_config.py b/pylabrobot/agilent/bravo/darwin/waxis_config.py new file mode 100644 index 00000000000..9d0cc4affeb --- /dev/null +++ b/pylabrobot/agilent/bravo/darwin/waxis_config.py @@ -0,0 +1,182 @@ +"""Per-head-type W-axis calibration and unit conversion. + +The W axis is the plunger; its hardware range, calibration offset, and +uL->mm factor all vary by the pipette head currently attached. + +Callers (``DarwinController.set_head_type``) should: + + 1. Look up the head-type config here. + 2. Replace the W-axis :class:`~.calibration.AxisCalibration` with these + values. + 3. Re-apply the 57-entry W-axis PID table (see :mod:`.waxis_params`). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Optional + +from ..types import HeadType +from .calibration import AxisCalibration + + +@dataclass(frozen=True) +class WAxisHeadConfig: + """W-axis settings for one head type. + + Attributes: + hardware_min: The W axis's hardware travel minimum for this head, in mm. + hardware_max: The W axis's hardware travel maximum for this head, in mm. + software_min: The enforced move-target minimum for this head, in mm. + software_max: The enforced move-target maximum for this head, in mm. + ul_to_mm_factor: Multiplier converting a pipetted volume in + microliters to W-axis travel in mm. + homing_timeout: Homing timeout for the W axis with this head + installed, in seconds. + """ + + hardware_min: float + hardware_max: float + software_min: float + software_max: float + ul_to_mm_factor: float + homing_timeout: float = 40.0 + + def calibration(self, calibration_offset: float = 0.0) -> AxisCalibration: + """Build the W-axis calibration record for this head type. + + Sets both the hardware and software limits explicitly. Omitting the + software limits here would leave :meth:`~.calibration.AxisCalibration.validate_target` + falling back to ``hardware_min + 0.07``, which is much looser than the + head's actual safe envelope and would let task-level bugs (e.g. a + profile specifying a tips-off W position below this head's real + ``software_min``) reach the wire. + + Args: + calibration_offset: Offset applied between normalized and physical + units. + + Returns: + The calibration record for this head type. + """ + return AxisCalibration( + hardware_min=self.hardware_min, + hardware_max=self.hardware_max, + software_min=self.software_min, + software_max=self.software_max, + park_position=0.0, + calibration_offset=calibration_offset, + ) + + +# Shared configs used by multiple head types. +_DTIP_STANDARD = WAxisHeadConfig( + hardware_min=-16.48, + hardware_max=63.52, + software_min=-9.1862, + software_max=56.226, + ul_to_mm_factor=448.0 / 2000.0, +) + +_ST384 = WAxisHeadConfig( + hardware_min=-14.197, + hardware_max=65.803, + software_min=-9.31446, + software_max=60.92, + ul_to_mm_factor=1692.0 / 2000.0, +) + +_ASSAYMAP = WAxisHeadConfig( + hardware_min=-19.921875, + hardware_max=80.078125, + software_min=-0.0024, + software_max=60.15865, + ul_to_mm_factor=385.0 / 1600.0, +) + +_F96_50 = WAxisHeadConfig( + hardware_min=-24.55, + hardware_max=55.45, + software_min=-0.00618, + software_max=30.90618, + ul_to_mm_factor=1236.0 / 2000.0, +) + +_F96_200 = WAxisHeadConfig( + hardware_min=-13.98, + hardware_max=61.02, + software_min=-9.1862, + software_max=56.226, + ul_to_mm_factor=487.0 / 2000.0, +) + + +HEAD_CONFIGS: Dict[HeadType, WAxisHeadConfig] = { + "96_assaymap": _ASSAYMAP, + "8_d_lt": _DTIP_STANDARD, + "96_d_70": _DTIP_STANDARD, + "96_d_70_s2": _DTIP_STANDARD, + "96_d_200": _DTIP_STANDARD, + "96_d_200_s2": _DTIP_STANDARD, + "16_d_st": _ST384, + "384_d_70": _ST384, + "384_d_70_s2": _ST384, + "384_f_50": _ST384, + "8_f_50": _ST384, + "96_f_50": _F96_50, + "96_f_200": _F96_200, +} + + +def config_for_head(head_type: HeadType) -> Optional[WAxisHeadConfig]: + """Return the W-axis config for a given head type. + + Args: + head_type: The head type to look up. + + Returns: + The head's W-axis configuration, or ``None`` if the head type has no + W-axis mapping. + """ + return HEAD_CONFIGS.get(head_type) + + +def ul_to_mm(volume_ul: float, head_type: HeadType) -> float: + """Convert a pipette volume in microliters to W-axis travel in mm. + + Args: + volume_ul: The volume to convert, in microliters. + head_type: The installed head type. + + Returns: + The equivalent W-axis travel, in mm. + + Raises: + ValueError: If ``head_type`` has no W-axis mapping. + """ + cfg = config_for_head(head_type) + if cfg is None: + raise ValueError(f"Unknown W-axis head type: {head_type!r}") + return volume_ul * cfg.ul_to_mm_factor + + +def mm_to_ul(travel_mm: float, head_type: HeadType) -> float: + """Convert W-axis travel in mm back to a volume in microliters. + + Args: + travel_mm: The W-axis travel to convert, in mm. + head_type: The installed head type. + + Returns: + The equivalent volume, in microliters. ``0.0`` if the head's + uL-to-mm factor is zero. + + Raises: + ValueError: If ``head_type`` has no W-axis mapping. + """ + cfg = config_for_head(head_type) + if cfg is None: + raise ValueError(f"Unknown W-axis head type: {head_type!r}") + if cfg.ul_to_mm_factor == 0: + return 0.0 + return travel_mm / cfg.ul_to_mm_factor diff --git a/pylabrobot/agilent/bravo/darwin/waxis_params.py b/pylabrobot/agilent/bravo/darwin/waxis_params.py new file mode 100644 index 00000000000..da907f24f40 --- /dev/null +++ b/pylabrobot/agilent/bravo/darwin/waxis_params.py @@ -0,0 +1,329 @@ +"""W-axis per-head-type PID and motion parameter sets. + +57 parameters x 5 head-type sets. Each head type resolves to a named +parameter set (``ST96``, ``ST384``, ``LT``, ``AM``, ``F96_50``), and the set +dictates the values written to the W-axis device parameter database. + +Almost every entry is encoded as Float32, matching the axis controller's +parameter declarations. The sole exception is ``I2T_TIME``, which the +firmware declares UInt32 -- it must be written as a plain uint (e.g. 5000 ms +-> 0x00001388) or the firmware replies with ``OUT_OF_RANGE`` (a +float-encoded 5000.0 lands at a huge magnitude when reinterpreted). + +Application: + + 1. :meth:`~.params.ParameterAccess.write_float`/ + :meth:`~.params.ParameterAccess.write_uint` for each entry. + Pointer-caching cuts wire packets by roughly half because the entries + are ordered by :class:`~pylabrobot.agilent.bravo.protocol.gemini.enums.ParamDBs` + index. + 2. :meth:`~.params.ParameterAccess.apply` once at the end to commit. + 3. The caller remembers the current head type so subsequent enable/move + calls can skip the re-apply unless the head changed. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Literal, Optional, Tuple + +from ..protocol.gemini.enums import ParamDBs +from ..types import HeadType +from .params import ParameterAccess + +WAxisParamSet = Literal["ST96", "ST384", "LT", "AM", "F96_50"] +"""Which calibration family a head belongs to. + +``ST96`` is short-tip 96, ``ST384`` is short-tip 384, ``LT`` is long-tip, +``AM`` is AssayMAP, and ``F96_50`` is fixed-tip 96-channel 50 uL. +""" + +HEAD_TYPE_TO_SET: Dict[HeadType, WAxisParamSet] = { + "96_assaymap": "AM", + "8_d_lt": "LT", + "96_d_200": "LT", + "96_d_200_s2": "LT", + "96_f_200": "LT", + "384_d_70": "ST384", + "384_d_70_s2": "ST384", + "384_f_50": "ST384", + "16_d_st": "ST384", + "96_d_70": "ST96", + "96_d_70_s2": "ST96", + "96_f_50": "F96_50", + "8_f_50": "F96_50", +} + + +@dataclass(frozen=True) +class WAxisParamEntry: + """One parameter id with per-head-type values. + + Attributes: + param: The parameter-database index this entry writes. + ST96: The value for the short-tip 96 parameter set. + ST384: The value for the short-tip 384 parameter set. + LT: The value for the long-tip parameter set. + AM: The value for the AssayMAP parameter set. + F96_50: The value for the fixed-tip 96-channel 50 uL parameter set. + """ + + param: ParamDBs + ST96: float + ST384: float + LT: float + AM: float + F96_50: float + + def value_for(self, param_set: WAxisParamSet) -> float: + """Return this entry's value for the given parameter set. + + Args: + param_set: Which head-type parameter family to select. + + Returns: + The value to write for ``param_set``. + """ + values: Dict[WAxisParamSet, float] = { + "ST96": self.ST96, + "ST384": self.ST384, + "LT": self.LT, + "AM": self.AM, + "F96_50": self.F96_50, + } + return float(values[param_set]) + + +# Ordered by ParamDBs index within each half of the table for pointer-caching +# efficiency. +WAXIS_PARAM_TABLE: Tuple[WAxisParamEntry, ...] = ( + WAxisParamEntry(ParamDBs.IQ_PTERM, ST96=0.3, ST384=0.35, LT=0.195, AM=0.31, F96_50=0.3), + WAxisParamEntry(ParamDBs.IQ_ITERM, ST96=2050.0, ST384=2050.0, LT=1550.0, AM=977.0, F96_50=2050.0), + WAxisParamEntry(ParamDBs.ID_PTERM, ST96=0.3, ST384=0.35, LT=0.195, AM=0.31, F96_50=0.3), + WAxisParamEntry(ParamDBs.ID_ITERM, ST96=2050.0, ST384=2050.0, LT=1550.0, AM=977.0, F96_50=2050.0), + WAxisParamEntry(ParamDBs.VEL_PTERM, ST96=1.75, ST384=1.75, LT=1.85, AM=1.75, F96_50=1.75), + WAxisParamEntry(ParamDBs.VEL_ITERM, ST96=0.1, ST384=1.25, LT=1.5, AM=5.0, F96_50=0.1), + WAxisParamEntry(ParamDBs.VEL_DTERM, ST96=0.0025, ST384=0.002, LT=0.001, AM=0.002, F96_50=0.0025), + WAxisParamEntry( + ParamDBs.VEL_CURR_OUT_SATURATION, ST96=0.95, ST384=0.95, LT=0.95, AM=0.95, F96_50=0.95 + ), + WAxisParamEntry(ParamDBs.POS_PTERM, ST96=680.0, ST384=780.0, LT=650.0, AM=450.0, F96_50=680.0), + WAxisParamEntry(ParamDBs.POS_ITERM, ST96=3.0, ST384=5.0, LT=7.5, AM=2.0, F96_50=3.0), + WAxisParamEntry( + ParamDBs.POS_DTERM, ST96=0.001, ST384=0.00075, LT=0.002, AM=0.00125, F96_50=0.001 + ), + WAxisParamEntry( + ParamDBs.ACCELERATION, ST96=6.345, ST384=6.345, LT=1.68, AM=1.44375, F96_50=4.635 + ), + WAxisParamEntry(ParamDBs.JERK, ST96=1250.0, ST384=1250.0, LT=1171.875, AM=1250.0, F96_50=1250.0), + WAxisParamEntry(ParamDBs.SPEED, ST96=6.345, ST384=6.345, LT=1.12, AM=1.44375, F96_50=4.635), + WAxisParamEntry( + ParamDBs.I2T_TIME, ST96=2000.0, ST384=5000.0, LT=2000.0, AM=2000.0, F96_50=2000.0 + ), + WAxisParamEntry( + ParamDBs.I2T_CONT_CURRENT, + ST96=0.09127, + ST384=0.09127, + LT=0.09127, + AM=0.0943, + F96_50=0.09127, + ), + WAxisParamEntry(ParamDBs.I2T_PEAK_CURRENT, ST96=0.2, ST384=0.817, LT=0.2, AM=0.15, F96_50=0.2), + WAxisParamEntry( + ParamDBs.POS_MARGIN, ST96=0.0001, ST384=0.0001, LT=0.0001, AM=0.0001, F96_50=0.0001 + ), + WAxisParamEntry(ParamDBs.POS_ERR_LIMIT, ST96=0.01, ST384=0.0125, LT=0.0125, AM=0.01, F96_50=0.01), + WAxisParamEntry( + ParamDBs.HOMING_OVERSHOOT, ST96=0.0375, ST384=0.0375, LT=0.0375, AM=0.03, F96_50=0.0187 + ), + WAxisParamEntry( + ParamDBs.HOMING_SPEED, + ST96=0.016666666666, + ST384=0.016666666666, + LT=0.025, + AM=0.016666666666, + F96_50=0.016666666666, + ), + WAxisParamEntry( + ParamDBs.HOMING_POS, + ST96=0.1774625, + ST384=0.1774625, + LT=0.206, + AM=0.19921875, + F96_50=0.306875, + ), + WAxisParamEntry(ParamDBs.ALIGN_PTERM, ST96=0.43, ST384=0.43, LT=0.43, AM=1.28, F96_50=0.43), + WAxisParamEntry(ParamDBs.ALIGN_ITERM, ST96=600.0, ST384=200.0, LT=600.0, AM=200.0, F96_50=600.0), + WAxisParamEntry( + ParamDBs.ALIGN_RAMP_CURRENT_TARGET, + ST96=0.09127, + ST384=0.09127, + LT=0.09127, + AM=0.0943, + F96_50=0.09127, + ), + WAxisParamEntry( + ParamDBs.SPEED_FEED_FWD_GAIN, ST96=0.35, ST384=0.36, LT=0.225, AM=0.55, F96_50=0.35 + ), + WAxisParamEntry( + ParamDBs.CURRENT_FEED_FWD_GAIN1, ST96=0.0, ST384=0.0, LT=0.0, AM=0.03, F96_50=0.0 + ), + WAxisParamEntry(ParamDBs.CURRENT_FEED_FWD_GAIN2, ST96=0.0, ST384=0.0, LT=0.0, AM=0.4, F96_50=0.0), + WAxisParamEntry( + ParamDBs.CURRENT_FEED_FWD_GAIN3, ST96=0.0, ST384=0.0, LT=0.0, AM=0.05, F96_50=0.0 + ), + WAxisParamEntry( + ParamDBs.STATIONARY_VEL_PTERM, ST96=1.25, ST384=1.25, LT=1.75, AM=2.0, F96_50=1.25 + ), + WAxisParamEntry( + ParamDBs.STATIONARY_VEL_ITERM, ST96=0.01, ST384=0.01, LT=0.1, AM=0.01, F96_50=0.01 + ), + WAxisParamEntry( + ParamDBs.STATIONARY_VEL_DTERM, + ST96=0.002, + ST384=0.002, + LT=0.00125, + AM=0.00175, + F96_50=0.002, + ), + WAxisParamEntry( + ParamDBs.STATIONARY_POS_PTERM, ST96=620.0, ST384=780.0, LT=650.0, AM=450.0, F96_50=620.0 + ), + WAxisParamEntry( + ParamDBs.STATIONARY_POS_ITERM, ST96=75.0, ST384=75.0, LT=75.0, AM=30.0, F96_50=75.0 + ), + WAxisParamEntry( + ParamDBs.STATIONARY_POS_DTERM, + ST96=0.00075, + ST384=0.00075, + LT=0.0025, + AM=0.001, + F96_50=0.00075, + ), + WAxisParamEntry(ParamDBs.STATIONARY_MAX_ERROR, ST96=0.0, ST384=0.0, LT=0.0, AM=0.0, F96_50=0.0), + WAxisParamEntry( + ParamDBs.SM_THRESHOLD, + ST96=0.052875, + ST384=0.0125, + LT=0.056, + AM=0.048125, + F96_50=0.052875, + ), + WAxisParamEntry(ParamDBs.SM_VEL_PTERM, ST96=1.35, ST384=2.35, LT=1.35, AM=2.75, F96_50=1.35), + WAxisParamEntry(ParamDBs.SM_VEL_ITERM, ST96=1.0, ST384=1.0, LT=0.1, AM=0.2, F96_50=1.0), + WAxisParamEntry( + ParamDBs.SM_VEL_DTERM, ST96=0.002, ST384=0.002, LT=0.0025, AM=0.002, F96_50=0.002 + ), + WAxisParamEntry(ParamDBs.SM_POS_PTERM, ST96=750.0, ST384=750.0, LT=750.0, AM=550.0, F96_50=750.0), + WAxisParamEntry(ParamDBs.SM_POS_ITERM, ST96=32.0, ST384=64.0, LT=1.5, AM=2.0, F96_50=32.0), + WAxisParamEntry( + ParamDBs.SM_STATIONARY_VEL_PTERM, ST96=1.05, ST384=2.0, LT=1.15, AM=2.15, F96_50=1.05 + ), + WAxisParamEntry( + ParamDBs.SM_STATIONARY_VEL_ITERM, ST96=0.0, ST384=0.01, LT=0.1, AM=0.01, F96_50=0.0 + ), + WAxisParamEntry( + ParamDBs.SM_STATIONARY_VEL_DTERM, + ST96=0.002, + ST384=0.0015, + LT=0.003, + AM=0.00175, + F96_50=0.002, + ), + WAxisParamEntry( + ParamDBs.SM_STATIONARY_POS_PTERM, + ST96=650.0, + ST384=780.0, + LT=1500.0, + AM=550.0, + F96_50=750.0, + ), + WAxisParamEntry( + ParamDBs.SM_STATIONARY_POS_ITERM, ST96=48.0, ST384=75.0, LT=125.0, AM=100.0, F96_50=48.0 + ), + WAxisParamEntry( + ParamDBs.SM_STATIONARY_POS_DTERM, + ST96=0.001, + ST384=0.00075, + LT=0.00115, + AM=0.001, + F96_50=0.001, + ), + WAxisParamEntry( + ParamDBs.SM_ACCELERATION, ST96=6.35, ST384=6.35, LT=1.68, AM=1.44375, F96_50=4.635 + ), + WAxisParamEntry( + ParamDBs.SM_JERK, ST96=1250.0, ST384=1250.0, LT=1171.875, AM=1250.0, F96_50=1250.0 + ), + WAxisParamEntry(ParamDBs.SM_SPEED, ST96=6.35, ST384=6.35, LT=1.12, AM=1.44375, F96_50=4.635), + WAxisParamEntry( + ParamDBs.SM_SPEED_FEED_FWD_GAIN, ST96=0.1, ST384=0.36, LT=0.225, AM=0.35, F96_50=0.275 + ), + WAxisParamEntry( + ParamDBs.SM_CURRENT_FEED_FWD_GAIN1, ST96=0.0, ST384=0.0, LT=0.0, AM=0.0, F96_50=0.0 + ), + WAxisParamEntry( + ParamDBs.SM_CURRENT_FEED_FWD_GAIN2, ST96=0.0, ST384=0.0, LT=0.0, AM=0.0, F96_50=0.0 + ), + WAxisParamEntry( + ParamDBs.SM_CURRENT_FEED_FWD_GAIN3, ST96=0.0, ST384=0.0, LT=0.0, AM=0.0, F96_50=0.0 + ), + WAxisParamEntry( + ParamDBs.SM_POS_MARGIN, ST96=0.0001, ST384=0.0001, LT=0.0001, AM=0.0001, F96_50=0.0001 + ), + WAxisParamEntry(ParamDBs.SPEED_SCALE, ST96=3.0, ST384=3.0, LT=2.0, AM=2.0, F96_50=3.0), +) + +assert len(WAXIS_PARAM_TABLE) == 57, f"expected 57 W-axis params, got {len(WAXIS_PARAM_TABLE)}" + +# Parameters the axis controller declares as UInt32. Every other entry in +# WAXIS_PARAM_TABLE is Float32. Keep this set narrow: writing a +# float32-reinterpreted value to a uint param lands in a completely +# different numeric range and the firmware will NAK it. +_UINT_PARAMS = frozenset({ParamDBs.I2T_TIME}) + + +def param_set_for_head(head_type: HeadType) -> Optional[WAxisParamSet]: + """Return the W-axis param set for a head type. + + Args: + head_type: The head type to look up. + + Returns: + The parameter set name, or ``None`` if the head type is unsupported -- + the caller should skip the apply entirely in that case. + """ + return HEAD_TYPE_TO_SET.get(head_type) + + +def apply_waxis_parameters( + params: ParameterAccess, + head_type: HeadType, + *, + per_param_timeout: float = 5.0, + apply_timeout: float = 10.0, +) -> bool: + """Write every W-axis parameter for ``head_type`` and commit. + + Args: + params: The parameter accessor for the W-axis device. + head_type: The head type to apply parameters for. + per_param_timeout: Maximum time to wait for each parameter write, in + seconds. + apply_timeout: Maximum time to wait for the commit, in seconds. + + Returns: + True if parameters were applied, False if the head type has no mapping + (in which case nothing is written). + """ + param_set = param_set_for_head(head_type) + if param_set is None: + return False + for entry in WAXIS_PARAM_TABLE: + value = entry.value_for(param_set) + if entry.param in _UINT_PARAMS: + params.write_uint(int(entry.param), int(value), timeout=per_param_timeout) + else: + params.write_float(int(entry.param), value, timeout=per_param_timeout) + params.apply(timeout=apply_timeout) + return True From fce2cce0afa7f2c9a287e14cfbbbaa03e1127897 Mon Sep 17 00:00:00 2001 From: kelsorj Date: Fri, 21 Aug 2026 10:59:48 -0700 Subject: [PATCH 6/9] Add the Bravo deck model, tip catalogue, and BravoDeck resource The instrument addresses nine deck locations in a 3x3 grid, tracking which labware sits at each, taught positions per location, and stack heights including lid and nesting geometry. BravoDeck exposes those nine locations as a PyLabRobot Deck whose site origins come from the instrument's taught X/Y/Z, so the model reflects the real machine rather than a nominal layout, and translates PyLabRobot resources into the internal labware the motion layer consumes. The well-grid translation's sign and frame convention is pinned by test but not yet confirmed against an instrument; deck/resource.py documents which sites are affected under default teachpoints. --- pylabrobot/agilent/bravo/deck/__init__.py | 6 + pylabrobot/agilent/bravo/deck/geometry.py | 194 ++++ .../agilent/bravo/deck/geometry_tests.py | 125 +++ pylabrobot/agilent/bravo/deck/labware.py | 623 ++++++++++++ .../agilent/bravo/deck/labware_tests.py | 460 +++++++++ pylabrobot/agilent/bravo/deck/layout.py | 107 ++ pylabrobot/agilent/bravo/deck/layout_tests.py | 120 +++ pylabrobot/agilent/bravo/deck/resource.py | 309 ++++++ .../agilent/bravo/deck/resource_tests.py | 190 ++++ pylabrobot/agilent/bravo/deck/teachpoints.py | 106 ++ .../agilent/bravo/deck/teachpoints_tests.py | 153 +++ .../bravo/testdata/tip_lengths_golden.json | 937 ++++++++++++++++++ pylabrobot/agilent/bravo/tip_offsets.py | 256 +++++ pylabrobot/agilent/bravo/tip_offsets_tests.py | 181 ++++ pylabrobot/agilent/bravo/tips.py | 322 ++++++ .../agilent/bravo/tips_golden_frame_tests.py | 59 ++ pylabrobot/agilent/bravo/tips_tests.py | 238 +++++ 17 files changed, 4386 insertions(+) create mode 100644 pylabrobot/agilent/bravo/deck/__init__.py create mode 100644 pylabrobot/agilent/bravo/deck/geometry.py create mode 100644 pylabrobot/agilent/bravo/deck/geometry_tests.py create mode 100644 pylabrobot/agilent/bravo/deck/labware.py create mode 100644 pylabrobot/agilent/bravo/deck/labware_tests.py create mode 100644 pylabrobot/agilent/bravo/deck/layout.py create mode 100644 pylabrobot/agilent/bravo/deck/layout_tests.py create mode 100644 pylabrobot/agilent/bravo/deck/resource.py create mode 100644 pylabrobot/agilent/bravo/deck/resource_tests.py create mode 100644 pylabrobot/agilent/bravo/deck/teachpoints.py create mode 100644 pylabrobot/agilent/bravo/deck/teachpoints_tests.py create mode 100644 pylabrobot/agilent/bravo/testdata/tip_lengths_golden.json create mode 100644 pylabrobot/agilent/bravo/tip_offsets.py create mode 100644 pylabrobot/agilent/bravo/tip_offsets_tests.py create mode 100644 pylabrobot/agilent/bravo/tips.py create mode 100644 pylabrobot/agilent/bravo/tips_golden_frame_tests.py create mode 100644 pylabrobot/agilent/bravo/tips_tests.py diff --git a/pylabrobot/agilent/bravo/deck/__init__.py b/pylabrobot/agilent/bravo/deck/__init__.py new file mode 100644 index 00000000000..dd0c0c93eca --- /dev/null +++ b/pylabrobot/agilent/bravo/deck/__init__.py @@ -0,0 +1,6 @@ +"""The Bravo instrument's deck model. + +Which labware sits at each of the nine deck locations, the taught head +positions for those locations, the 3x3 spatial layout, stack tracking, and +well geometry derived from labware metadata. +""" diff --git a/pylabrobot/agilent/bravo/deck/geometry.py b/pylabrobot/agilent/bravo/deck/geometry.py new file mode 100644 index 00000000000..969915c475b --- /dev/null +++ b/pylabrobot/agilent/bravo/deck/geometry.py @@ -0,0 +1,194 @@ +"""Well geometry derived from labware metadata. + +Converts a labware definition's rows/cols/spacing/offset metadata into a +:class:`WellGeometry`, and resolves the millimetre offset from a location's +taught teachpoint (the labware's back-left corner) to a specific well or +tipbox cell center. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional + +from ..head_mode import TipSelection + + +@dataclass(frozen=True) +class WellGeometry: + """The row/column grid and pitch/offset of a labware's wells. + + Attributes: + rows: Number of well rows. + cols: Number of well columns. + pitch_x_mm: Well-to-well spacing along columns, in millimetres. + pitch_y_mm: Well-to-well spacing along rows, in millimetres. + offset_x_mm: X offset from the labware's teachpoint to well A1's center. + offset_y_mm: Y offset from the labware's teachpoint to well A1's center. + """ + + rows: int + cols: int + pitch_x_mm: float + pitch_y_mm: float + offset_x_mm: float + offset_y_mm: float + + +def _rows_cols_from_metadata(metadata: dict[str, Any]) -> tuple[int, int]: + """Resolve a row/column count from labware metadata. + + Args: + metadata: A labware's well-dimension metadata (or its full metadata, + when ``rows``/``cols``/``wells`` live at the top level). + + Returns: + The ``(rows, cols)`` pair. If ``rows``/``cols`` are not present, they are + inferred from a ``wells`` total for the standard 96/384/1536 well plate + grids; otherwise ``(0, 0)``. + """ + rows = int(metadata.get("rows") or 0) + cols = int(metadata.get("cols") or 0) + if rows > 0 and cols > 0: + return rows, cols + wells = int(metadata.get("wells") or 0) + if wells == 96: + return 8, 12 + if wells == 384: + return 16, 24 + if wells == 1536: + return 32, 48 + return rows, cols + + +def _default_pitch_mm(count: int) -> float: + """Return the standard SBS well pitch for a given row or column count. + + Args: + count: The number of rows or columns along one axis. + + Returns: + 2.25 mm for a 1536-density axis, 4.5 mm for a 384-density axis, and 9.0 + mm otherwise (96-density and below). + """ + if count >= 32: + return 2.25 + if count >= 16: + return 4.5 + return 9.0 + + +def _default_offset_mm( + rows: int, cols: int, pitch_x_mm: float, pitch_y_mm: float +) -> tuple[float, float]: + """Return the standard SBS teachpoint-to-A1 offset for a well grid. + + Args: + rows: Number of well rows. + cols: Number of well columns. + pitch_x_mm: Well-to-well spacing along columns, in millimetres. + pitch_y_mm: Well-to-well spacing along rows, in millimetres. + + Returns: + ``(3.375, 3.375)`` for a 1536-density plate, ``(2.25, 2.25)`` for a + 384-density plate, and ``(0.0, 0.0)`` otherwise. + """ + if rows >= 32 or cols >= 48: + return 3.375, 3.375 + if rows >= 16 or cols >= 24: + return 2.25, 2.25 + return 0.0, 0.0 + + +def well_geometry_from_metadata(metadata: Optional[dict[str, Any]]) -> WellGeometry: + """Build a :class:`WellGeometry` from a labware definition's metadata. + + Explicit ``spacing_x_mm``/``spacing_y_mm``/``offset_x_mm``/``offset_y_mm`` + values win; anything missing falls back to the standard SBS defaults for + the plate's well density. + + Args: + metadata: The labware's metadata dict, or a nested ``well_dimensions_mm`` + sub-dict. ``None`` is treated as empty. + + Returns: + The resolved well geometry. + """ + raw = dict(metadata or {}) + well_dims = dict(raw.get("well_dimensions_mm") or raw) + rows, cols = _rows_cols_from_metadata(well_dims or raw) + pitch_x_mm = float(well_dims.get("spacing_x_mm") or _default_pitch_mm(cols)) + pitch_y_mm = float(well_dims.get("spacing_y_mm") or _default_pitch_mm(rows)) + default_offset_x_mm, default_offset_y_mm = _default_offset_mm(rows, cols, pitch_x_mm, pitch_y_mm) + raw_offset_x_mm = well_dims.get("offset_x_mm") + raw_offset_y_mm = well_dims.get("offset_y_mm") + offset_x_mm = float(raw_offset_x_mm) if raw_offset_x_mm is not None else default_offset_x_mm + offset_y_mm = float(raw_offset_y_mm) if raw_offset_y_mm is not None else default_offset_y_mm + if offset_x_mm == 0.0 and offset_y_mm == 0.0 and (rows >= 16 or cols >= 24): + offset_x_mm = default_offset_x_mm + offset_y_mm = default_offset_y_mm + return WellGeometry( + rows=rows, + cols=cols, + pitch_x_mm=pitch_x_mm, + pitch_y_mm=pitch_y_mm, + offset_x_mm=offset_x_mm, + offset_y_mm=offset_y_mm, + ) + + +def a1_center_offset_from_teachpoint_mm(metadata: Optional[dict[str, Any]]) -> tuple[float, float]: + """Return the (x, y) offset from a labware's teachpoint to well A1's center. + + Args: + metadata: The labware's metadata dict. + + Returns: + The offset in millimetres. + """ + geometry = well_geometry_from_metadata(metadata) + return -geometry.offset_x_mm, -geometry.offset_y_mm + + +def well_center_offset_from_teachpoint_mm( + metadata: Optional[dict[str, Any]], + *, + row: int, + col: int, +) -> tuple[float, float]: + """Return the (x, y) offset from a labware's teachpoint to a well's center. + + Args: + metadata: The labware's metadata dict. + row: Zero-based well row. + col: Zero-based well column. + + Returns: + The offset in millimetres. + """ + geometry = well_geometry_from_metadata(metadata) + base_x_mm, base_y_mm = a1_center_offset_from_teachpoint_mm(metadata) + return ( + base_x_mm + int(col) * geometry.pitch_x_mm, + base_y_mm + int(row) * geometry.pitch_y_mm, + ) + + +def tipbox_anchor_offset_from_teachpoint_mm( + metadata: Optional[dict[str, Any]], + selection: TipSelection, +) -> tuple[float, float]: + """Return the (x, y) offset from a tipbox's teachpoint to a selection's anchor cell. + + Args: + metadata: The tipbox's metadata dict. + selection: The tip selection whose anchor cell to resolve. + + Returns: + The offset in millimetres. + """ + return well_center_offset_from_teachpoint_mm( + metadata, + row=int(selection.row), + col=int(selection.col), + ) diff --git a/pylabrobot/agilent/bravo/deck/geometry_tests.py b/pylabrobot/agilent/bravo/deck/geometry_tests.py new file mode 100644 index 00000000000..04c8b1abf44 --- /dev/null +++ b/pylabrobot/agilent/bravo/deck/geometry_tests.py @@ -0,0 +1,125 @@ +import unittest + +from pylabrobot.agilent.bravo.deck.geometry import ( + WellGeometry, + a1_center_offset_from_teachpoint_mm, + tipbox_anchor_offset_from_teachpoint_mm, + well_center_offset_from_teachpoint_mm, + well_geometry_from_metadata, +) +from pylabrobot.agilent.bravo.head_mode import TipSelection + + +class WellGeometryFromMetadataTests(unittest.TestCase): + def test_96_well_plate_defaults(self): + geometry = well_geometry_from_metadata({"rows": 8, "cols": 12}) + self.assertEqual( + geometry, + WellGeometry( + rows=8, cols=12, pitch_x_mm=9.0, pitch_y_mm=9.0, offset_x_mm=0.0, offset_y_mm=0.0 + ), + ) + + def test_384_well_plate_defaults(self): + geometry = well_geometry_from_metadata({"rows": 16, "cols": 24}) + self.assertEqual( + geometry, + WellGeometry( + rows=16, cols=24, pitch_x_mm=4.5, pitch_y_mm=4.5, offset_x_mm=2.25, offset_y_mm=2.25 + ), + ) + + def test_1536_well_plate_defaults(self): + geometry = well_geometry_from_metadata({"rows": 32, "cols": 48}) + self.assertEqual( + geometry, + WellGeometry( + rows=32, + cols=48, + pitch_x_mm=2.25, + pitch_y_mm=2.25, + offset_x_mm=3.375, + offset_y_mm=3.375, + ), + ) + + def test_wells_count_infers_row_col_grid(self): + self.assertEqual(well_geometry_from_metadata({"wells": 96}).rows, 8) + self.assertEqual(well_geometry_from_metadata({"wells": 96}).cols, 12) + self.assertEqual(well_geometry_from_metadata({"wells": 384}).rows, 16) + self.assertEqual(well_geometry_from_metadata({"wells": 1536}).cols, 48) + + def test_nested_well_dimensions_mm_metadata_is_used(self): + geometry = well_geometry_from_metadata({"well_dimensions_mm": {"rows": 8, "cols": 12}}) + self.assertEqual((geometry.rows, geometry.cols), (8, 12)) + + def test_explicit_spacing_overrides_default_pitch(self): + geometry = well_geometry_from_metadata( + {"rows": 8, "cols": 12, "spacing_x_mm": 4.5, "spacing_y_mm": 4.5} + ) + self.assertEqual((geometry.pitch_x_mm, geometry.pitch_y_mm), (4.5, 4.5)) + + def test_explicit_offset_overrides_default_offset(self): + geometry = well_geometry_from_metadata( + {"rows": 16, "cols": 24, "offset_x_mm": 1.0, "offset_y_mm": 1.5} + ) + self.assertEqual((geometry.offset_x_mm, geometry.offset_y_mm), (1.0, 1.5)) + + def test_explicit_zero_offset_on_dense_plate_falls_back_to_default(self): + # A 384-density plate with an explicit (0, 0) offset is treated as + # "unset" and gets the standard 2.25 mm SBS offset instead. + geometry = well_geometry_from_metadata( + {"rows": 16, "cols": 24, "offset_x_mm": 0.0, "offset_y_mm": 0.0} + ) + self.assertEqual((geometry.offset_x_mm, geometry.offset_y_mm), (2.25, 2.25)) + + def test_none_metadata_yields_empty_geometry(self): + geometry = well_geometry_from_metadata(None) + self.assertEqual(geometry.rows, 0) + self.assertEqual(geometry.cols, 0) + + +class A1CenterOffsetTests(unittest.TestCase): + def test_96_well_a1_offset_is_zero(self): + self.assertEqual(a1_center_offset_from_teachpoint_mm({"rows": 8, "cols": 12}), (0.0, 0.0)) + + def test_384_well_a1_offset_is_negative_of_geometry_offset(self): + offset = a1_center_offset_from_teachpoint_mm({"rows": 16, "cols": 24}) + self.assertEqual(offset, (-2.25, -2.25)) + + +class WellCenterOffsetTests(unittest.TestCase): + def test_row0_col0_equals_a1_offset(self): + metadata = {"rows": 8, "cols": 12} + self.assertEqual( + well_center_offset_from_teachpoint_mm(metadata, row=0, col=0), + a1_center_offset_from_teachpoint_mm(metadata), + ) + + def test_offset_advances_by_pitch_per_row_and_col(self): + metadata = {"rows": 8, "cols": 12} + base_x, base_y = a1_center_offset_from_teachpoint_mm(metadata) + x, y = well_center_offset_from_teachpoint_mm(metadata, row=2, col=3) + self.assertAlmostEqual(x, base_x + 3 * 9.0) + self.assertAlmostEqual(y, base_y + 2 * 9.0) + + def test_384_well_offset_uses_384_pitch(self): + metadata = {"rows": 16, "cols": 24} + base_x, base_y = a1_center_offset_from_teachpoint_mm(metadata) + x, y = well_center_offset_from_teachpoint_mm(metadata, row=1, col=1) + self.assertAlmostEqual(x, base_x + 4.5) + self.assertAlmostEqual(y, base_y + 4.5) + + +class TipboxAnchorOffsetTests(unittest.TestCase): + def test_matches_well_center_offset_at_selection_row_col(self): + metadata = {"rows": 8, "cols": 12} + selection = TipSelection(location=1, row=2, col=3) + self.assertEqual( + tipbox_anchor_offset_from_teachpoint_mm(metadata, selection), + well_center_offset_from_teachpoint_mm(metadata, row=2, col=3), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/deck/labware.py b/pylabrobot/agilent/bravo/deck/labware.py new file mode 100644 index 00000000000..12cf0060232 --- /dev/null +++ b/pylabrobot/agilent/bravo/deck/labware.py @@ -0,0 +1,623 @@ +"""Labware definitions and deck tracking. + +Defines a normalized, physical description of a labware item +(:class:`LabwareDefinition`, :class:`Labware`), a simple in-memory catalog of +definitions (:class:`LabwareCatalog`, :class:`InMemoryLabwareCatalog`), and +per-location stack tracking for the nine deck locations +(:class:`LabwareStack`, :class:`DeckState`). +""" + +from __future__ import annotations + +import logging +from dataclasses import asdict, dataclass, field +from typing import Any, Optional + +from ..types import MAX_LOCATIONS, MIN_LOCATION + +logger = logging.getLogger(__name__) + + +@dataclass +class LabwareDefinition: + """A normalized labware definition: dimensions, stacking, and well geometry. + + Every field is a plain value with a zero/empty default, so a definition can + be constructed with only the fields that matter for a given piece of + labware. + """ + + id: str + name: str + kind: str + vendor: str = "" + catalog_number: str = "" + description: str = "" + base_class: str = "" + wells: int = 0 + length_mm: float = 0.0 + width_mm: float = 0.0 + height_mm: float = 0.0 + stack_height_mm: float = 0.0 + gripper_offset_mm: float = 0.0 + lid_gripper_offset_mm: Optional[float] = None + empty_check_offset_mm: Optional[float] = None + shim_thickness_mm: float = 0.0 + can_be_sealed: bool = False + sealed_height_mm: float = 0.0 + sealed_stacking_height_mm: float = 0.0 + can_have_lid: bool = False + lidded_height_mm: float = 0.0 + lidded_stack_height_mm: float = 0.0 + lid_resting_height_mm: float = 0.0 + lid_departure_height_mm: float = 0.0 + max_robot_handling_speed: str = "" + rows: int = 0 + cols: int = 0 + well_depth_mm: float = 0.0 + offset_x_mm: float = 0.0 + offset_y_mm: float = 0.0 + spacing_x_mm: float = 0.0 + spacing_y_mm: float = 0.0 + well_volume_ul: float = 0.0 + well_diameter_mm: float = 0.0 + disposable_tip_capacity_ul: float = 0.0 + tip_definition_id: str = "" + supported_tip_ids: list[str] = field(default_factory=list) + # Mount-ability flags — consulted by mount/unmount plate handling. + # can_mount = "this plate can sit on top of another and lock into + # it" (e.g. a filter plate that nests into a + # collection plate for vacuum filtration). + # can_be_mounted = "another plate can sit on top of me and lock in" + # (e.g. a collection plate that accepts a filter). + # Both flags are soft — a mount task should validate them but treat a + # missing flag as a warning rather than a hard rejection. + can_mount: bool = False + can_be_mounted: bool = False + + def to_summary(self) -> dict[str, Any]: + """Return this definition as a plain dict.""" + return asdict(self) + + +@dataclass +class Labware: + """Physical description of a single labware item on the deck.""" + + id: str + name: str + height: float + width: float + length: float + labware_type: str = "" + gripper_offset: float = 0.0 + stack_height: float = 0.0 + is_lidded: bool = False + is_sealed: bool = False + definition_id: str = "" + wells: int = 0 + metadata: dict[str, Any] = field(default_factory=dict) + # Instance-level barcode. Populated by sensor/barcode-read tasks; travels + # with the plate across pick/place because LabwareStack.remove_top() and + # .add() preserve the live instance. Distinct from `metadata` (which is + # definition-level). + barcode: str = "" + # Free-form per-instance annotations written by workflow scripts + # (`plate.tags["lane"] = "A2"`). Like `barcode`, travels with the plate + # across pick/place/stack because it's on the live instance, not the + # definition. Distinct from `metadata` (which is definition-level and + # shared across all instances from a single LabwareDefinition). + tags: dict[str, Any] = field(default_factory=dict) + # Mount state — instance-level, set True when this labware is placed on + # top of another and they become a locked unit (e.g. a filter plate + # mounted on a collection plate for vacuum filtration). When True, + # pick/place transports this labware together with the one immediately + # below it on the stack. Travels with the instance across moves. + is_mounted: bool = False + + @classmethod + def from_definition( + cls, + definition: LabwareDefinition, + *, + is_lidded: bool = False, + is_sealed: bool = False, + ) -> "Labware": + """Build a :class:`Labware` instance from a definition. + + Args: + definition: The labware definition to instantiate. + is_lidded: Whether this instance currently has a lid on. + is_sealed: Whether this instance is currently sealed. + + Returns: + A new instance whose height/stack height reflect the lidded/sealed + state, with a metadata dict carrying the full definition plus derived + geometry fields. + """ + height, stack_height = _active_labware_geometry( + definition, + is_lidded=is_lidded, + is_sealed=is_sealed, + ) + metadata = definition.to_summary() + metadata["base_height_mm"] = float(definition.height_mm or height) + metadata["total_height_mm"] = float(height) + metadata["is_lidded"] = bool(is_lidded) + metadata["is_sealed"] = bool(is_sealed) + metadata["height_mm"] = float(height) + metadata["stack_height_mm"] = float(stack_height) + if is_lidded: + generated_lid = generated_lid_metadata(metadata) + if generated_lid is not None: + metadata["generated_lid"] = generated_lid + return cls( + id=definition.id, + definition_id=definition.id, + name=definition.name, + height=height, + width=definition.width_mm, + length=definition.length_mm, + labware_type=definition.kind, + gripper_offset=definition.gripper_offset_mm, + stack_height=stack_height, + is_lidded=is_lidded, + is_sealed=is_sealed, + wells=definition.wells, + metadata=metadata, + ) + + +def _active_labware_geometry( + definition: LabwareDefinition, + *, + is_lidded: bool, + is_sealed: bool, +) -> tuple[float, float]: + """Return the (height, stack_height) that apply for a lidded/sealed state. + + Args: + definition: The labware definition to read geometry from. + is_lidded: Whether the lidded height/stack-height should apply. + is_sealed: Whether the sealed height/stack-height should apply. + + Returns: + ``(height_mm, stack_height_mm)`` for the requested state, falling back + to the base (bare) height wherever a state-specific value is zero. + """ + base_height = float(definition.height_mm or 0.0) + base_stack_height = float(definition.stack_height_mm or base_height) + if is_lidded: + height = float(definition.lidded_height_mm or base_height) + stack_height = float(definition.lidded_stack_height_mm or height) + return height, stack_height + if is_sealed: + height = float(definition.sealed_height_mm or base_height) + stack_height = float(definition.sealed_stacking_height_mm or height) + return height, stack_height + return base_height, base_stack_height + + +def lid_thickness_mm(metadata: Optional[dict[str, Any]]) -> float: + """Return a plate's lid thickness, inferred from its metadata. + + Args: + metadata: The plate's metadata dict (as produced by + :meth:`Labware.from_definition`). + + Returns: + The lid thickness in millimetres, preferring + ``lidded_height_mm - lid_resting_height_mm``, then + ``lidded_height_mm - base_height_mm``, then ``lid_resting_height_mm`` + alone, and finally a 0.1 mm floor so a lid is never zero-thickness. + """ + meta = metadata or {} + resting_height = float(meta.get("lid_resting_height_mm") or 0.0) + lidded_height = float(meta.get("lidded_height_mm") or 0.0) + if lidded_height > 0.0 and resting_height > 0.0 and lidded_height > resting_height: + return max(0.1, lidded_height - resting_height) + base_height = float(meta.get("base_height_mm") or meta.get("height_mm") or 0.0) + if lidded_height > 0.0 and base_height > 0.0 and lidded_height > base_height: + return max(0.1, lidded_height - base_height) + if resting_height > 0.0: + return max(0.1, resting_height) + return 0.1 + + +def lid_gripper_offset_mm( + metadata: Optional[dict[str, Any]], + *, + fallback_gripper_offset_mm: float = 0.0, + label: str = "labware", +) -> float: + """Return the gripper offset to use when handling a plate's lid. + + Args: + metadata: The plate's metadata dict. + fallback_gripper_offset_mm: The offset to fall back to when no explicit + lid gripper offset is configured. + label: A human-readable name for the plate, used only in the warning + logged when the fallback exceeds the lid's own thickness. + + Returns: + The explicit ``lid_gripper_offset_mm``/``robot_lid_gripper_offset_mm`` + metadata value if present; otherwise the fallback offset, clamped to the + lid's thickness (with a warning) so the gripper never targets a depth + beyond the lid itself. + """ + meta = dict(metadata or {}) + explicit = meta.get("lid_gripper_offset_mm") + if explicit is None: + explicit = meta.get("robot_lid_gripper_offset_mm") + if explicit is not None: + return max(0.0, float(explicit)) + + fallback = max( + 0.0, + float( + fallback_gripper_offset_mm + or meta.get("gripper_offset_mm") + or meta.get("robot_gripper_offset_mm") + or 0.0 + ), + ) + lid_height = lid_thickness_mm(meta) + if fallback > lid_height: + logger.warning( + "Clamping fallback lid gripper offset for %s from %.3f to lid thickness %.3f; " + "configure robot_lid_gripper_offset_mm for correct lid handling geometry", + label, + fallback, + lid_height, + ) + fallback = lid_height + return fallback + + +def generated_lid_metadata(metadata: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]: + """Derive metadata for a synthetic lid sized to a plate's footprint. + + Args: + metadata: The plate's metadata dict. + + Returns: + A metadata dict for the lid, or ``None`` if the plate has no valid + length/width to size the lid against. + """ + meta = dict(metadata or {}) + length_mm = float(meta.get("length_mm") or meta.get("length") or 0.0) + width_mm = float(meta.get("width_mm") or meta.get("width") or 0.0) + if length_mm <= 0.0 or width_mm <= 0.0: + return None + thickness_mm = lid_thickness_mm(meta) + grip_offset_mm = lid_gripper_offset_mm( + meta, + fallback_gripper_offset_mm=float( + meta.get("gripper_offset_mm") or meta.get("robot_gripper_offset_mm") or 0.0 + ), + label=str(meta.get("name") or "labware"), + ) + return { + "name": f"{meta.get('name', 'Labware')} Lid", + "kind": "lid", + "base_class": "lid", + "length_mm": length_mm, + "width_mm": width_mm, + "height_mm": thickness_mm, + "stack_height_mm": thickness_mm, + "lid_thickness_mm": thickness_mm, + "lid_gripper_offset_mm": grip_offset_mm, + "lid_resting_height_mm": float(meta.get("lid_resting_height_mm") or 0.0), + "lid_departure_height_mm": float(meta.get("lid_departure_height_mm") or 0.0), + "source_plate_name": str(meta.get("name") or ""), + "render_mode": "generated_lid", + } + + +def synthesize_lid_labware(plate: Labware) -> Labware: + """Build a standalone lid :class:`Labware` sized to fit *plate*. + + Args: + plate: The plate to synthesize a lid for. + + Returns: + A new ``Labware`` representing the lid, with its own id + (``f"{plate.id}::lid"``) and the source plate's ``definition_id``. + + Raises: + ValueError: If the plate's metadata has no valid length/width footprint. + """ + plate_meta = dict(plate.metadata or {}) + lid_meta = generated_lid_metadata(plate_meta) + if lid_meta is None: + raise ValueError(f"Cannot synthesize lid for labware without valid footprint: {plate.name}") + return Labware( + id=f"{plate.id}::lid", + definition_id=plate.definition_id, + name=str(lid_meta["name"]), + height=float(lid_meta["height_mm"]), + width=float(lid_meta["width_mm"]), + length=float(lid_meta["length_mm"]), + labware_type="lid", + gripper_offset=float(lid_meta.get("lid_gripper_offset_mm") or 0.0), + stack_height=float(lid_meta["stack_height_mm"]), + is_lidded=False, + is_sealed=False, + wells=0, + metadata=lid_meta, + ) + + +class LabwareCatalog: + """Lookup interface for normalized labware definitions.""" + + def list_definitions(self) -> list[LabwareDefinition]: + """Return every definition in the catalog.""" + raise NotImplementedError + + def get_definition(self, labware_id: str) -> Optional[LabwareDefinition]: + """Return the definition with the given id, or ``None`` if not found.""" + raise NotImplementedError + + +class InMemoryLabwareCatalog(LabwareCatalog): + """A fixed, in-memory list of labware definitions.""" + + def __init__( + self, + definitions: list[LabwareDefinition], + *, + aliases: Optional[dict[str, str]] = None, + ) -> None: + """Initialize the catalog. + + Args: + definitions: The definitions the catalog serves. + aliases: Extra lookup ids that resolve to an existing definition's id, + for ids that a caller might still reference. An alias that collides + with a real definition id is ignored. + """ + self._definitions = list(definitions) + self._by_id = {d.id: d for d in definitions} + for alias_id, canonical_id in dict(aliases or {}).items(): + if alias_id in self._by_id: + continue + canonical = self._by_id.get(canonical_id) + if canonical is not None: + self._by_id[alias_id] = canonical + + def list_definitions(self) -> list[LabwareDefinition]: + """Return every definition in the catalog.""" + return list(self._definitions) + + def get_definition(self, labware_id: str) -> Optional[LabwareDefinition]: + """Return the definition with the given id or alias, or ``None``.""" + return self._by_id.get(labware_id) + + +class LabwareStack: + """An ordered stack of :class:`Labware` at a single deck position.""" + + def __init__(self) -> None: + self._items: list[Labware] = [] + + def add(self, labware: Labware) -> None: + """Push *labware* onto the top of the stack.""" + self._items.append(labware) + + def replace(self, labware: Labware) -> None: + """Replace the entire stack with a single item.""" + self._items = [labware] + + def remove_top(self) -> Labware: + """Pop and return the top item. + + Raises: + IndexError: If the stack is empty. + """ + if not self._items: + raise IndexError("Cannot remove from an empty labware stack") + return self._items.pop() + + def get_total_height(self) -> float: + """Return the full physical height of the stack, in millimetres. + + Every item but the top contributes its stacking thickness (how much it + adds once another item is nested/stacked on it); the top item + contributes its full height. + """ + if not self._items: + return 0.0 + if len(self._items) == 1: + return self._items[0].height + total = 0.0 + for item in self._items[:-1]: + total += item.stack_height or item.height + total += self._items[-1].height + return total + + def get_location_height(self) -> float: + """Return vendor-style pickup offset above the taught plate-pad plane. + + This matches the vendor GetLocationHeight semantics for a visible top + plate: the offset corresponds to the support surface under the top plate + rather than the full physical height to the top of that plate. + """ + if not self._items: + return 0.0 + return max(0.0, self.get_total_height() - self._items[-1].height) + + def get_stacking_height(self) -> float: + """Return the placement support height using stacking thickness. + + For placing a new plate onto a destination stack, this is the effective + support surface produced by the plates already present, not the full + physical top height of the visible top plate. + """ + total = 0.0 + for item in self._items: + total += item.stack_height or item.height + return max(0.0, total) + + @property + def top(self) -> Optional[Labware]: + """Return the top item, or ``None`` if the stack is empty.""" + return self._items[-1] if self._items else None + + @property + def items(self) -> list[Labware]: + """Return the stack's items, bottom-first.""" + return list(self._items) + + def mounted_group_from_top(self) -> list[Labware]: + """Return the slice of plates that move as a unit when the top is picked up. + + Returns items top->bottom. + + A plate with ``is_mounted=True`` is physically locked to the plate + immediately below it (filter plate on collection plate, etc.), so + picking the top drags the bottom along. This walks the stack downward + from the top, including each subsequent plate while the plate above it + is ``is_mounted``. + + Always returns at least one item for a non-empty stack — the top itself + — so callers can treat the result as "the thing we actually pick up" + regardless of mount state. + """ + if not self._items: + return [] + group: list[Labware] = [self._items[-1]] + i = len(self._items) - 1 + # While the current top of the group is mounted, the plate immediately + # below it moves with us. + while i > 0 and group[-1].is_mounted: + i -= 1 + group.append(self._items[i]) + return group + + def get_support_height_below_group(self) -> float: + """Return the stacking-surface height under the mounted group at the top. + + This is what a gripper needs to clear on the way down to engage the + bottom plate of the mounted group by its flanges. + + * For an ordinary (unmounted) stack, the group is just the top plate, so + this is identical to :meth:`get_location_height` — everything + beneath the top is support. + * For a mounted pair at the top (filter on collection), the group is + both plates. The gripper must engage the collection plate's flanges, + which sit at the height of any plates below the collection plate + (possibly zero if the pair is directly on the pad). + """ + if not self._items: + return 0.0 + group_size = len(self.mounted_group_from_top()) + below_count = len(self._items) - group_size + if below_count <= 0: + return 0.0 + # Stack heights of everything that stays put when the group lifts off — + # matches the semantics of get_stacking_height() applied to the + # sub-stack below the group. + total = 0.0 + for item in self._items[:below_count]: + total += item.stack_height or item.height + return max(0.0, total) + + def __len__(self) -> int: + return len(self._items) + + def __bool__(self) -> bool: + return bool(self._items) + + +class DeckState: + """Tracks a :class:`LabwareStack` at each of the nine deck locations.""" + + def __init__(self) -> None: + self._stacks: dict[int, LabwareStack] = { + loc: LabwareStack() for loc in range(MIN_LOCATION, MAX_LOCATIONS + 1) + } + + def _validate_location(self, location: int) -> None: + if not (MIN_LOCATION <= location <= MAX_LOCATIONS): + raise ValueError(f"Location must be {MIN_LOCATION}-{MAX_LOCATIONS}, got {location}") + + def add(self, location: int, labware: Labware) -> None: + """Push *labware* onto the stack at *location*.""" + self._validate_location(location) + self._stacks[location].add(labware) + + def set_single(self, location: int, labware: Labware) -> None: + """Replace the stack at *location* with a single item.""" + self._validate_location(location) + self._stacks[location].replace(labware) + + def remove(self, location: int) -> Labware: + """Pop and return the top item at *location*.""" + self._validate_location(location) + return self._stacks[location].remove_top() + + def remove_mounted_group(self, location: int) -> list[Labware]: + """Remove every plate that moves as a unit from the top of *location*. + + Returns them top-first. + + Semantically a superset of :meth:`remove` — on an unmounted stack this + is identical to removing the top plate (returns a single-item list). On + a mounted pair (or N-level mounted stack) every locked member pops + together so the move-to-destination side can re-add them as a unit. + """ + self._validate_location(location) + stack = self._stacks[location] + group = stack.mounted_group_from_top() + # Pop them off the stack in the same order the caller will re-add them + # (top-first). This keeps the bottom of the mounted group as the new + # top of the source after the move. + for _ in group: + stack.remove_top() + return group + + def add_mounted_group(self, location: int, group: list[Labware]) -> None: + """Place a mounted group produced by :meth:`remove_mounted_group` onto *location*. + + Expects the group top-first, the same ordering that removal returns, so + items are pushed in reverse to preserve the original bottom->top layout. + """ + self._validate_location(location) + stack = self._stacks[location] + for labware in reversed(group): + stack.add(labware) + + def get_stack(self, location: int) -> LabwareStack: + """Return the :class:`LabwareStack` at *location*.""" + self._validate_location(location) + return self._stacks[location] + + def get_height(self, location: int) -> float: + """Return the full physical stack height at *location*, in millimetres.""" + self._validate_location(location) + return self._stacks[location].get_total_height() + + def get_location_height(self, location: int) -> float: + """Return the vendor-style pickup offset at *location*, in millimetres.""" + self._validate_location(location) + return self._stacks[location].get_location_height() + + def get_stacking_height(self, location: int) -> float: + """Return the placement support height at *location*, in millimetres.""" + self._validate_location(location) + return self._stacks[location].get_stacking_height() + + def get_all_heights(self) -> dict[int, float]: + """Return the full physical stack height at every deck location.""" + return {loc: stack.get_total_height() for loc, stack in self._stacks.items()} + + def clear(self, location: int) -> None: + """Empty the stack at *location*.""" + self._validate_location(location) + self._stacks[location] = LabwareStack() + + def clear_all(self) -> None: + """Empty every stack on the deck.""" + for loc in self._stacks: + self._stacks[loc] = LabwareStack() diff --git a/pylabrobot/agilent/bravo/deck/labware_tests.py b/pylabrobot/agilent/bravo/deck/labware_tests.py new file mode 100644 index 00000000000..b04df536c8f --- /dev/null +++ b/pylabrobot/agilent/bravo/deck/labware_tests.py @@ -0,0 +1,460 @@ +import unittest + +from pylabrobot.agilent.bravo.deck.geometry import well_geometry_from_metadata +from pylabrobot.agilent.bravo.deck.labware import ( + DeckState, + InMemoryLabwareCatalog, + Labware, + LabwareDefinition, + LabwareStack, + generated_lid_metadata, + lid_gripper_offset_mm, + lid_thickness_mm, + synthesize_lid_labware, +) + +_PLATE_384 = LabwareDefinition( + id="builtin-384-greiner-781091", + name="384 Greiner 781091 PS uclear", + kind="sbs_plate", + vendor="Greiner", + catalog_number="781091", + base_class="microplate", + wells=384, + length_mm=127.76, + width_mm=85.48, + height_mm=14.4, + stack_height_mm=8.6, + gripper_offset_mm=2.5, + can_have_lid=True, + lidded_height_mm=16.5, + lidded_stack_height_mm=14.5, + lid_resting_height_mm=9.5, + lid_departure_height_mm=8.5, + rows=16, + cols=24, + well_depth_mm=11.5, + offset_x_mm=2.25, + offset_y_mm=2.25, + spacing_x_mm=4.5, + spacing_y_mm=4.5, + well_volume_ul=130.0, + well_diameter_mm=3.3, +) + +_PLATE_96 = LabwareDefinition( + id="builtin-96-greiner-655101", + name="96 Greiner 655101 PS Clr Rnd Well Flat Btm", + kind="sbs_plate", + vendor="Greiner", + catalog_number="655101", + base_class="microplate", + wells=96, + length_mm=127.76, + width_mm=85.48, + height_mm=14.4, + stack_height_mm=8.6, + gripper_offset_mm=0.5, + can_have_lid=True, + lidded_height_mm=16.5, + lidded_stack_height_mm=14.5, + lid_resting_height_mm=9.5, + lid_departure_height_mm=8.5, + rows=8, + cols=12, + spacing_x_mm=9.0, + spacing_y_mm=9.0, + well_volume_ul=300.0, + well_diameter_mm=6.9, +) + +_PLATE_SEALABLE = LabwareDefinition( + id="sealable-plate", + name="Sealable Plate", + kind="sbs_plate", + height_mm=14.4, + stack_height_mm=8.6, + can_be_sealed=True, + sealed_height_mm=15.2, + sealed_stacking_height_mm=9.0, +) + + +class LabwareDefinitionTests(unittest.TestCase): + def test_to_summary_round_trips_construction_kwargs(self): + summary = _PLATE_96.to_summary() + self.assertEqual(summary["id"], "builtin-96-greiner-655101") + self.assertEqual(summary["wells"], 96) + self.assertEqual(summary["rows"], 8) + self.assertEqual(summary["cols"], 12) + + def test_defaults_are_zero_or_empty(self): + minimal = LabwareDefinition(id="x", name="X", kind="plate") + self.assertEqual(minimal.height_mm, 0.0) + self.assertEqual(minimal.supported_tip_ids, []) + self.assertIsNone(minimal.lid_gripper_offset_mm) + + +class LabwareFromDefinitionTests(unittest.TestCase): + def test_bare_plate_uses_base_height_and_stack_height(self): + plate = Labware.from_definition(_PLATE_384) + self.assertEqual(plate.height, 14.4) + self.assertEqual(plate.stack_height, 8.6) + self.assertFalse(plate.is_lidded) + self.assertFalse(plate.is_sealed) + + def test_lidded_plate_uses_lidded_height_and_stack_height(self): + plate = Labware.from_definition(_PLATE_384, is_lidded=True) + self.assertEqual(plate.height, 16.5) + self.assertEqual(plate.stack_height, 14.5) + self.assertTrue(plate.is_lidded) + self.assertIn("generated_lid", plate.metadata) + + def test_sealed_plate_uses_sealed_height_and_stack_height(self): + plate = Labware.from_definition(_PLATE_SEALABLE, is_sealed=True) + self.assertEqual(plate.height, 15.2) + self.assertEqual(plate.stack_height, 9.0) + + def test_lidded_height_falls_back_to_base_height_when_unset(self): + definition = LabwareDefinition(id="p", name="P", kind="plate", height_mm=10.0) + plate = Labware.from_definition(definition, is_lidded=True) + self.assertEqual(plate.height, 10.0) + + def test_metadata_carries_the_full_definition_and_derived_fields(self): + plate = Labware.from_definition(_PLATE_384) + self.assertEqual(plate.metadata["name"], _PLATE_384.name) + self.assertEqual(plate.metadata["base_height_mm"], 14.4) + self.assertEqual(plate.metadata["total_height_mm"], 14.4) + + def test_well_geometry_derived_from_metadata_matches_definition(self): + plate = Labware.from_definition(_PLATE_384) + geometry = well_geometry_from_metadata(plate.metadata) + self.assertEqual((geometry.rows, geometry.cols), (16, 24)) + self.assertEqual((geometry.pitch_x_mm, geometry.pitch_y_mm), (4.5, 4.5)) + self.assertEqual((geometry.offset_x_mm, geometry.offset_y_mm), (2.25, 2.25)) + + +class LidThicknessTests(unittest.TestCase): + def test_uses_lidded_minus_resting_height_when_both_present(self): + self.assertAlmostEqual( + lid_thickness_mm({"lidded_height_mm": 16.5, "lid_resting_height_mm": 9.5}), 7.0 + ) + + def test_uses_lidded_minus_base_height_when_no_resting_height(self): + self.assertAlmostEqual( + lid_thickness_mm({"lidded_height_mm": 16.5, "base_height_mm": 14.4}), 2.1 + ) + + def test_uses_resting_height_alone_when_lidded_height_missing(self): + self.assertAlmostEqual(lid_thickness_mm({"lid_resting_height_mm": 3.0}), 3.0) + + def test_floors_at_point_one_mm(self): + self.assertEqual(lid_thickness_mm({}), 0.1) + self.assertEqual(lid_thickness_mm(None), 0.1) + + +class LidGripperOffsetTests(unittest.TestCase): + def test_explicit_lid_gripper_offset_wins(self): + self.assertEqual(lid_gripper_offset_mm({"lid_gripper_offset_mm": 3.0}), 3.0) + + def test_robot_lid_gripper_offset_used_when_explicit_missing(self): + self.assertEqual(lid_gripper_offset_mm({"robot_lid_gripper_offset_mm": 4.0}), 4.0) + + def test_fallback_used_when_no_metadata_offset(self): + self.assertEqual( + lid_gripper_offset_mm({"lid_resting_height_mm": 9.5}, fallback_gripper_offset_mm=1.0), 1.0 + ) + + def test_fallback_is_clamped_to_lid_thickness(self): + # lid thickness here is the 0.1 mm floor (no lidded/resting height given). + offset = lid_gripper_offset_mm({}, fallback_gripper_offset_mm=5.0) + self.assertEqual(offset, 0.1) + + +class GeneratedLidMetadataTests(unittest.TestCase): + def test_valid_footprint_produces_lid_metadata(self): + lid = generated_lid_metadata({"name": "Plate", "length_mm": 127.76, "width_mm": 85.48}) + self.assertIsNotNone(lid) + assert lid is not None + self.assertEqual(lid["name"], "Plate Lid") + self.assertEqual(lid["kind"], "lid") + self.assertEqual(lid["length_mm"], 127.76) + self.assertEqual(lid["width_mm"], 85.48) + + def test_zero_footprint_returns_none(self): + self.assertIsNone(generated_lid_metadata({"length_mm": 0.0, "width_mm": 85.48})) + self.assertIsNone(generated_lid_metadata(None)) + + +class SynthesizeLidLabwareTests(unittest.TestCase): + def test_builds_a_lid_labware_from_a_plate(self): + plate = Labware.from_definition(_PLATE_384) + lid = synthesize_lid_labware(plate) + self.assertEqual(lid.id, f"{plate.id}::lid") + self.assertEqual(lid.definition_id, plate.definition_id) + self.assertEqual(lid.labware_type, "lid") + self.assertGreater(lid.height, 0.0) + + def test_raises_without_a_valid_footprint(self): + plate = Labware(id="p", name="P", height=1.0, width=1.0, length=1.0, metadata={}) + with self.assertRaises(ValueError): + synthesize_lid_labware(plate) + + +class InMemoryLabwareCatalogTests(unittest.TestCase): + def test_list_definitions_returns_all_rows(self): + catalog = InMemoryLabwareCatalog([_PLATE_384, _PLATE_96]) + self.assertEqual(len(catalog.list_definitions()), 2) + + def test_get_definition_by_id(self): + catalog = InMemoryLabwareCatalog([_PLATE_384, _PLATE_96]) + self.assertIs(catalog.get_definition(_PLATE_96.id), _PLATE_96) + + def test_get_definition_unknown_id_returns_none(self): + catalog = InMemoryLabwareCatalog([_PLATE_384]) + self.assertIsNone(catalog.get_definition("does-not-exist")) + + def test_alias_resolves_to_canonical_definition(self): + catalog = InMemoryLabwareCatalog([_PLATE_384], aliases={"old-id": _PLATE_384.id}) + self.assertIs(catalog.get_definition("old-id"), _PLATE_384) + + def test_alias_colliding_with_a_real_id_is_ignored(self): + catalog = InMemoryLabwareCatalog([_PLATE_384, _PLATE_96], aliases={_PLATE_96.id: _PLATE_384.id}) + self.assertIs(catalog.get_definition(_PLATE_96.id), _PLATE_96) + + +class LabwareStackHeightTests(unittest.TestCase): + def test_empty_stack_height_is_zero(self): + self.assertEqual(LabwareStack().get_total_height(), 0.0) + + def test_single_item_height_is_its_own_height(self): + stack = LabwareStack() + stack.add(Labware.from_definition(_PLATE_384)) + self.assertEqual(stack.get_total_height(), 14.4) + + def test_two_bare_plates_nest_using_stack_height_plus_top_height(self): + stack = LabwareStack() + stack.add(Labware.from_definition(_PLATE_384)) + stack.add(Labware.from_definition(_PLATE_384)) + # bottom contributes stack_height (8.6), top contributes full height (14.4) + self.assertAlmostEqual(stack.get_total_height(), 8.6 + 14.4) + + def test_three_plate_stack_nests_all_but_the_top(self): + stack = LabwareStack() + for _ in range(3): + stack.add(Labware.from_definition(_PLATE_384)) + self.assertAlmostEqual(stack.get_total_height(), 8.6 + 8.6 + 14.4) + + def test_lidded_top_plate_adds_full_lidded_height(self): + stack = LabwareStack() + stack.add(Labware.from_definition(_PLATE_384)) + stack.add(Labware.from_definition(_PLATE_384, is_lidded=True)) + self.assertAlmostEqual(stack.get_total_height(), 8.6 + 16.5) + + def test_get_location_height_excludes_the_top_plates_own_height(self): + stack = LabwareStack() + stack.add(Labware.from_definition(_PLATE_384)) + stack.add(Labware.from_definition(_PLATE_384)) + self.assertAlmostEqual(stack.get_location_height(), 8.6) + + def test_get_location_height_on_empty_stack_is_zero(self): + self.assertEqual(LabwareStack().get_location_height(), 0.0) + + def test_get_stacking_height_sums_every_items_stack_height(self): + stack = LabwareStack() + stack.add(Labware.from_definition(_PLATE_384)) + stack.add(Labware.from_definition(_PLATE_384)) + self.assertAlmostEqual(stack.get_stacking_height(), 8.6 + 8.6) + + def test_falls_back_to_full_height_when_stack_height_is_zero(self): + definition = LabwareDefinition(id="p", name="P", kind="plate", height_mm=5.0) + stack = LabwareStack() + stack.add(Labware.from_definition(definition)) + stack.add(Labware.from_definition(definition)) + self.assertAlmostEqual(stack.get_total_height(), 5.0 + 5.0) + + +class LabwareStackOperationsTests(unittest.TestCase): + def test_add_then_top_returns_the_last_added_item(self): + stack = LabwareStack() + a = Labware.from_definition(_PLATE_384) + b = Labware.from_definition(_PLATE_96) + stack.add(a) + stack.add(b) + self.assertIs(stack.top, b) + + def test_remove_top_pops_and_returns_the_top_item(self): + stack = LabwareStack() + a = Labware.from_definition(_PLATE_384) + b = Labware.from_definition(_PLATE_96) + stack.add(a) + stack.add(b) + self.assertIs(stack.remove_top(), b) + self.assertIs(stack.top, a) + + def test_remove_top_on_empty_stack_raises(self): + with self.assertRaises(IndexError): + LabwareStack().remove_top() + + def test_replace_discards_the_previous_stack(self): + stack = LabwareStack() + stack.add(Labware.from_definition(_PLATE_384)) + stack.add(Labware.from_definition(_PLATE_384)) + replacement = Labware.from_definition(_PLATE_96) + stack.replace(replacement) + self.assertEqual(len(stack), 1) + self.assertIs(stack.top, replacement) + + def test_bool_and_len_reflect_contents(self): + stack = LabwareStack() + self.assertFalse(stack) + self.assertEqual(len(stack), 0) + stack.add(Labware.from_definition(_PLATE_384)) + self.assertTrue(stack) + self.assertEqual(len(stack), 1) + + def test_items_returns_a_bottom_first_copy(self): + stack = LabwareStack() + a = Labware.from_definition(_PLATE_384) + b = Labware.from_definition(_PLATE_96) + stack.add(a) + stack.add(b) + items = stack.items + self.assertEqual(items, [a, b]) + items.append(Labware.from_definition(_PLATE_384)) + self.assertEqual(len(stack), 2) # the copy's mutation did not leak in + + +class MountedGroupTests(unittest.TestCase): + def test_unmounted_top_group_is_just_the_top_item(self): + stack = LabwareStack() + bottom = Labware.from_definition(_PLATE_384) + top = Labware.from_definition(_PLATE_96) + stack.add(bottom) + stack.add(top) + self.assertEqual(stack.mounted_group_from_top(), [top]) + + def test_mounted_pair_travels_together(self): + stack = LabwareStack() + bottom = Labware.from_definition(_PLATE_384) + top = Labware.from_definition(_PLATE_96) + top.is_mounted = True + stack.add(bottom) + stack.add(top) + self.assertEqual(stack.mounted_group_from_top(), [top, bottom]) + + def test_empty_stack_group_is_empty(self): + self.assertEqual(LabwareStack().mounted_group_from_top(), []) + + def test_support_height_below_group_for_unmounted_top(self): + stack = LabwareStack() + stack.add(Labware.from_definition(_PLATE_384)) + stack.add(Labware.from_definition(_PLATE_96)) + self.assertAlmostEqual(stack.get_support_height_below_group(), stack.get_location_height()) + + def test_support_height_below_mounted_pair_skips_the_group_itself(self): + stack = LabwareStack() + base = Labware.from_definition(_PLATE_384) + filter_plate = Labware.from_definition(_PLATE_96) + collection_plate = Labware.from_definition(_PLATE_96) + filter_plate.is_mounted = True + stack.add(base) + stack.add(collection_plate) + stack.add(filter_plate) + # group = [filter_plate, collection_plate]; only `base` remains as support. + self.assertAlmostEqual(stack.get_support_height_below_group(), base.stack_height or base.height) + + def test_support_height_below_group_on_bare_deck_is_zero(self): + stack = LabwareStack() + stack.add(Labware.from_definition(_PLATE_384)) + self.assertEqual(stack.get_support_height_below_group(), 0.0) + + +class DeckStateTests(unittest.TestCase): + def test_new_deck_has_nine_empty_stacks(self): + deck = DeckState() + self.assertEqual(deck.get_all_heights(), {loc: 0.0 for loc in range(1, 10)}) + + def test_add_then_get_stack_returns_the_populated_stack(self): + deck = DeckState() + plate = Labware.from_definition(_PLATE_384) + deck.add(3, plate) + self.assertIs(deck.get_stack(3).top, plate) + + def test_remove_pops_the_top_item(self): + deck = DeckState() + plate = Labware.from_definition(_PLATE_384) + deck.add(3, plate) + self.assertIs(deck.remove(3), plate) + self.assertEqual(deck.get_height(3), 0.0) + + def test_set_single_replaces_the_whole_stack(self): + deck = DeckState() + deck.add(3, Labware.from_definition(_PLATE_384)) + deck.add(3, Labware.from_definition(_PLATE_384)) + replacement = Labware.from_definition(_PLATE_96) + deck.set_single(3, replacement) + self.assertEqual(len(deck.get_stack(3)), 1) + self.assertIs(deck.get_stack(3).top, replacement) + + def test_get_height_reflects_stack_arithmetic(self): + deck = DeckState() + deck.add(5, Labware.from_definition(_PLATE_384)) + deck.add(5, Labware.from_definition(_PLATE_384)) + self.assertAlmostEqual(deck.get_height(5), 8.6 + 14.4) + + def test_get_location_height_and_get_stacking_height_delegate_to_the_stack(self): + deck = DeckState() + deck.add(5, Labware.from_definition(_PLATE_384)) + deck.add(5, Labware.from_definition(_PLATE_384)) + self.assertAlmostEqual(deck.get_location_height(5), 8.6) + self.assertAlmostEqual(deck.get_stacking_height(5), 8.6 + 8.6) + + def test_clear_empties_a_single_location(self): + deck = DeckState() + deck.add(1, Labware.from_definition(_PLATE_384)) + deck.add(2, Labware.from_definition(_PLATE_384)) + deck.clear(1) + self.assertEqual(deck.get_height(1), 0.0) + self.assertGreater(deck.get_height(2), 0.0) + + def test_clear_all_empties_every_location(self): + deck = DeckState() + for loc in range(1, 10): + deck.add(loc, Labware.from_definition(_PLATE_384)) + deck.clear_all() + self.assertEqual(deck.get_all_heights(), {loc: 0.0 for loc in range(1, 10)}) + + def test_remove_mounted_group_and_add_mounted_group_round_trip(self): + source = DeckState() + dest = DeckState() + base = Labware.from_definition(_PLATE_384) + top = Labware.from_definition(_PLATE_96) + top.is_mounted = True + source.add(1, base) + source.add(1, top) + group = source.remove_mounted_group(1) + self.assertEqual(group, [top, base]) + self.assertEqual(len(source.get_stack(1)), 0) + dest.add_mounted_group(2, group) + self.assertEqual(dest.get_stack(2).items, [base, top]) + + def test_out_of_range_location_is_rejected_by_every_accessor(self): + deck = DeckState() + plate = Labware.from_definition(_PLATE_384) + for bad_location in (0, 10, -1): + with self.subTest(location=bad_location): + with self.assertRaises(ValueError): + deck.add(bad_location, plate) + with self.assertRaises(ValueError): + deck.remove(bad_location) + with self.assertRaises(ValueError): + deck.get_stack(bad_location) + with self.assertRaises(ValueError): + deck.get_height(bad_location) + with self.assertRaises(ValueError): + deck.clear(bad_location) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/deck/layout.py b/pylabrobot/agilent/bravo/deck/layout.py new file mode 100644 index 00000000000..6cf42c6ccd1 --- /dev/null +++ b/pylabrobot/agilent/bravo/deck/layout.py @@ -0,0 +1,107 @@ +"""The 3x3 deck grid model. + +Provides spatial queries over the nine deck locations: adjacency, bounding +regions, and inter-location distance scoring. +""" + +from __future__ import annotations + +from ..types import ( + MAX_COLS, + MAX_ROWS, + location_to_row_col, + row_col_to_location, +) + + +class DeckLayout: + """Spatial model for the 3x3 deck grid (locations 1-9). + + Location numbering:: + + 1 2 3 row 0 + 4 5 6 row 1 + 7 8 9 row 2 + """ + + @staticmethod + def get_row_col(location: int) -> tuple[int, int]: + """Return the ``(row, col)`` tuple for a 1-based location.""" + return location_to_row_col(location) + + @staticmethod + def get_location(row: int, col: int) -> int: + """Return the 1-based location for a ``(row, col)`` pair.""" + return row_col_to_location(row, col) + + @staticmethod + def get_adjacent_locations(location: int) -> list[int]: + """Return all locations neighbouring *location* (including diagonals).""" + row, col = location_to_row_col(location) + neighbours: list[int] = [] + for dr in (-1, 0, 1): + for dc in (-1, 0, 1): + if dr == 0 and dc == 0: + continue + nr, nc = row + dr, col + dc + if 0 <= nr < MAX_ROWS and 0 <= nc < MAX_COLS: + neighbours.append(row_col_to_location(nr, nc)) + return neighbours + + @staticmethod + def get_region( + start_locations: list[int], + end_locations: list[int], + ) -> set[int]: + """Return the set of locations inside the bounding rectangle. + + The bounding rectangle is the smallest rectangle that encloses all + *start_locations* and *end_locations*. + """ + all_locs = start_locations + end_locations + if not all_locs: + return set() + + rows_cols = [location_to_row_col(loc) for loc in all_locs] + min_row = min(r for r, _ in rows_cols) + max_row = max(r for r, _ in rows_cols) + min_col = min(c for _, c in rows_cols) + max_col = max(c for _, c in rows_cols) + + region: set[int] = set() + for r in range(min_row, max_row + 1): + for c in range(min_col, max_col + 1): + region.add(row_col_to_location(r, c)) + return region + + @staticmethod + def get_distance(loc_a: int, loc_b: int) -> int: + """Return a proximity score (0-6) between two locations. + + Scoring follows the CConcurrentLocationManager scale: + - 0: same location + - 1: horizontally adjacent (same row, +-1 col) + - 2: vertically adjacent (same col, +-1 row) + - 3: diagonally adjacent + - 4: two steps in one axis + - 5: two in one axis + one in the other + - 6: opposite corners (max distance) + """ + if loc_a == loc_b: + return 0 + ra, ca = location_to_row_col(loc_a) + rb, cb = location_to_row_col(loc_b) + dr = abs(ra - rb) + dc = abs(ca - cb) + + if dr == 0 and dc == 1: + return 1 + if dr == 1 and dc == 0: + return 2 + if dr == 1 and dc == 1: + return 3 + if (dr == 0 and dc == 2) or (dr == 2 and dc == 0): + return 4 + if (dr == 2 and dc == 1) or (dr == 1 and dc == 2): + return 5 + return 6 diff --git a/pylabrobot/agilent/bravo/deck/layout_tests.py b/pylabrobot/agilent/bravo/deck/layout_tests.py new file mode 100644 index 00000000000..83ed1bdf1b7 --- /dev/null +++ b/pylabrobot/agilent/bravo/deck/layout_tests.py @@ -0,0 +1,120 @@ +import unittest + +from pylabrobot.agilent.bravo.deck.layout import DeckLayout + +# Every 1-based deck location mapped to its expected 0-based (row, col). +_EXPECTED_ROW_COL = { + 1: (0, 0), + 2: (0, 1), + 3: (0, 2), + 4: (1, 0), + 5: (1, 1), + 6: (1, 2), + 7: (2, 0), + 8: (2, 1), + 9: (2, 2), +} + + +class GetRowColTests(unittest.TestCase): + def test_every_location_maps_to_the_expected_row_col(self): + for location, expected in _EXPECTED_ROW_COL.items(): + with self.subTest(location=location): + self.assertEqual(DeckLayout.get_row_col(location), expected) + + def test_out_of_range_location_raises(self): + with self.assertRaises(ValueError): + DeckLayout.get_row_col(0) + with self.assertRaises(ValueError): + DeckLayout.get_row_col(10) + + +class GetLocationTests(unittest.TestCase): + def test_every_row_col_maps_back_to_the_expected_location(self): + for location, (row, col) in _EXPECTED_ROW_COL.items(): + with self.subTest(location=location): + self.assertEqual(DeckLayout.get_location(row, col), location) + + def test_round_trips_with_get_row_col(self): + for location in range(1, 10): + row, col = DeckLayout.get_row_col(location) + self.assertEqual(DeckLayout.get_location(row, col), location) + + +class GetAdjacentLocationsTests(unittest.TestCase): + def test_center_has_all_eight_neighbours(self): + self.assertEqual( + sorted(DeckLayout.get_adjacent_locations(5)), + [1, 2, 3, 4, 6, 7, 8, 9], + ) + + def test_corner_has_three_neighbours(self): + self.assertEqual(sorted(DeckLayout.get_adjacent_locations(1)), [2, 4, 5]) + + def test_edge_has_five_neighbours(self): + self.assertEqual(sorted(DeckLayout.get_adjacent_locations(2)), [1, 3, 4, 5, 6]) + + def test_every_location_has_the_expected_neighbour_count(self): + expected_counts = {1: 3, 2: 5, 3: 3, 4: 5, 5: 8, 6: 5, 7: 3, 8: 5, 9: 3} + for location, count in expected_counts.items(): + with self.subTest(location=location): + self.assertEqual(len(DeckLayout.get_adjacent_locations(location)), count) + + def test_a_location_is_never_its_own_neighbour(self): + for location in range(1, 10): + self.assertNotIn(location, DeckLayout.get_adjacent_locations(location)) + + +class GetRegionTests(unittest.TestCase): + def test_empty_inputs_return_empty_region(self): + self.assertEqual(DeckLayout.get_region([], []), set()) + + def test_single_location_region_is_itself(self): + self.assertEqual(DeckLayout.get_region([5], []), {5}) + + def test_bounding_rectangle_spans_full_grid(self): + self.assertEqual(DeckLayout.get_region([1], [9]), set(range(1, 10))) + + def test_bounding_rectangle_top_row_only(self): + self.assertEqual(DeckLayout.get_region([1], [3]), {1, 2, 3}) + + def test_bounding_rectangle_combines_both_lists(self): + # start=[1] end=[6] -> rows 0-1, cols 0-2 -> {1,2,3,4,5,6} + self.assertEqual(DeckLayout.get_region([1], [6]), {1, 2, 3, 4, 5, 6}) + + +class GetDistanceTests(unittest.TestCase): + def test_same_location_is_zero(self): + self.assertEqual(DeckLayout.get_distance(5, 5), 0) + + def test_horizontally_adjacent_is_one(self): + self.assertEqual(DeckLayout.get_distance(4, 5), 1) + + def test_vertically_adjacent_is_two(self): + self.assertEqual(DeckLayout.get_distance(2, 5), 2) + + def test_diagonally_adjacent_is_three(self): + self.assertEqual(DeckLayout.get_distance(1, 5), 3) + + def test_two_steps_same_row_is_four(self): + self.assertEqual(DeckLayout.get_distance(1, 3), 4) + + def test_two_steps_same_col_is_four(self): + self.assertEqual(DeckLayout.get_distance(1, 7), 4) + + def test_knight_move_is_five(self): + self.assertEqual(DeckLayout.get_distance(1, 6), 5) + self.assertEqual(DeckLayout.get_distance(1, 8), 5) + + def test_opposite_corners_is_six(self): + self.assertEqual(DeckLayout.get_distance(1, 9), 6) + self.assertEqual(DeckLayout.get_distance(3, 7), 6) + + def test_distance_is_symmetric(self): + for a in range(1, 10): + for b in range(1, 10): + self.assertEqual(DeckLayout.get_distance(a, b), DeckLayout.get_distance(b, a)) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/deck/resource.py b/pylabrobot/agilent/bravo/deck/resource.py new file mode 100644 index 00000000000..af2ea58736d --- /dev/null +++ b/pylabrobot/agilent/bravo/deck/resource.py @@ -0,0 +1,309 @@ +"""PyLabRobot deck resource for the Agilent Bravo's nine deck sites. + +Bridges PyLabRobot's own resource-assignment model onto the instrument's +nine taught deck locations, so plates and tip racks a protocol assigns +through PyLabRobot can be translated into the internal +:class:`~.labware.Labware` model the state-machine tasks consume. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.deck import Deck +from pylabrobot.resources.itemized_resource import ItemizedResource +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.resource_holder import ResourceHolder +from pylabrobot.resources.tip_rack import TipRack +from pylabrobot.resources.trash import Trash + +from ..types import ( + MAX_COLS, + MAX_LOCATIONS, + MAX_ROWS, + MIN_LOCATION, + X_TO_X_DISTANCE, + Y_TO_Y_DISTANCE, + HeadType, +) +from .labware import Labware +from .teachpoints import Teachpoints + +_SITE_SIZE_X_MM = 127.76 +"""Nominal SLAS plate-footprint width for an empty site holder's own bounding +box. Purely cosmetic for an empty site: once a resource is assigned, its own +footprint is what matters.""" + +_SITE_SIZE_Y_MM = 85.48 +"""Nominal SLAS plate-footprint depth. See :data:`_SITE_SIZE_X_MM`.""" + +_GRID_MARGIN_X_MM = 130.0 +"""Extra width, beyond the taught site-to-site pitch, the deck's own bounding +box extends past the outermost sites. Cosmetic only -- physical motion is +governed entirely by teachpoints and axis travel limits, not this size.""" + +_GRID_MARGIN_Y_MM = 95.0 +"""Extra depth beyond the taught site-to-site pitch. See :data:`_GRID_MARGIN_X_MM`.""" + + +def _default_teachpoints(head_type: HeadType) -> Teachpoints: + """Build a default set of teachpoints for *head_type*.""" + teachpoints = Teachpoints() + teachpoints.set_default_teachpoints(head_type) + return teachpoints + + +def _labware_kind_for_resource(resource: Resource) -> str: + """Return the internal labware ``kind``/``base_class`` string for *resource*. + + A :class:`~pylabrobot.resources.trash.Trash` (including subclasses, e.g. + :class:`~pylabrobot.resources.tecan.trash.TecanTrash`) maps to + ``"tip_trash"`` so :meth:`~..bravo.Bravo.tips_off`'s own + ``{"tip_box", "tip_trash"}`` check accepts a site a protocol drops tips + into -- without this, every :class:`Trash` fell through to the generic + ``"plate"`` case below and a tip drop to trash always failed with "Tips + off requires a tip box or tip trash". + """ + if isinstance(resource, TipRack): + return "tip_box" + if isinstance(resource, Trash): + return "tip_trash" + return "plate" + + +def _well_grid_metadata(resource: Resource) -> "dict[str, Any]": + """Return well-grid metadata for *resource*, if it is a uniform item grid. + + Populated from the resource's own item-grid introspection (row/column + count, item pitch, and item A1's offset from the resource's own origin) + when *resource* is an :class:`~pylabrobot.resources.itemized_resource.ItemizedResource` + (covers both :class:`~pylabrobot.resources.plate.Plate` and + :class:`~pylabrobot.resources.tip_rack.TipRack`). Empty for any other + resource type, or one whose item grid cannot be read, which leaves + well-geometry lookups on that labware falling back to + :func:`~.geometry.well_geometry_from_metadata`'s own SBS defaults. + + Unvalidated against real hardware, specifically: ``offset_x_mm``/ + ``offset_y_mm`` here are set directly from ``item_a1.location`` -- + PyLabRobot's own offset from the resource's origin corner to well A1's + center. Combined with this package's own + :func:`~.geometry.well_center_offset_from_teachpoint_mm` (which treats + ``offset_x_mm``/``offset_y_mm`` as the teachpoint-to-A1 offset and + negates it), the resulting well target this translator currently + produces is:: + + A1_target_xy = site_teachpoint_xy - (item_a1.location.x, item_a1.location.y) + + not the sign-flipped alternative (``site_teachpoint_xy + item_a1.location``). + Which of the two actually matches where the instrument physically expects + A1 to be has not been confirmed against a real Bravo -- a + hardware-in-the-loop check (teach a site, place a plate whose A1 corner + is visually known, compare against this formula's prediction) is needed + before trusting this for real liquid handling. + :meth:`resource_tests.LabwareForSiteTests.test_pins_the_well_target_sign_convention_this_translator_assumes` + pins the formula above exactly, so a hardware check that finds it + backwards fails that one test and points straight at this docstring. + + A symptom downstream of this same unconfirmed sign, observed while + testing :class:`~..backend.AgilentBravoBackend` against a real + :class:`~pylabrobot.resources.plate.Plate` (``cor_96_wellplate_360uL_Fb``) + under :meth:`~.teachpoints.Teachpoints.set_default_teachpoints`'s default + teachpoints for a ``96_d_70`` head: of the deck's nine sites, only sites 5 + and 8 (the middle column) had every one of the plate's 96 wells reachable + as a plate anchor; sites 1, 2, and 3 (the back row) had none reachable at + all, and sites 4, 6, 7, and 9 had some but not all. This is expected to + change once the sign convention above is confirmed on a real instrument -- + it is not a separate bug to chase, just a concrete instance of the same + unconfirmed formula, recorded here rather than left to be rediscovered. + """ + if not isinstance(resource, ItemizedResource): + return {} + try: + rows = resource.num_items_y + cols = resource.num_items_x + item_a1 = resource.get_item("A1") + except (ValueError, IndexError): + return {} + if item_a1.location is None: + return {} + metadata: "dict[str, Any]" = { + "rows": rows, + "cols": cols, + "offset_x_mm": float(item_a1.location.x), + "offset_y_mm": float(item_a1.location.y), + } + if cols >= 2: + metadata["spacing_x_mm"] = abs(float(resource.item_dx)) + if rows >= 2: + metadata["spacing_y_mm"] = abs(float(resource.item_dy)) + return metadata + + +def labware_from_resource(resource: Resource) -> Labware: + """Build an internal :class:`Labware` description from a PyLabRobot resource. + + Draws physical dimensions directly from the resource's own + ``get_size_x``/``get_size_y``/``get_size_z``, and, for a + :class:`~pylabrobot.resources.itemized_resource.ItemizedResource` (a + :class:`~pylabrobot.resources.plate.Plate` or + :class:`~pylabrobot.resources.tip_rack.TipRack`), well-grid metadata from + the resource's item layout. See :func:`_well_grid_metadata` for the + validation caveat on that grid metadata. + + Args: + resource: The PyLabRobot resource assigned to a Bravo deck site. + + Returns: + The internal labware description the state-machine tasks consume. + """ + kind = _labware_kind_for_resource(resource) + metadata: "dict[str, Any]" = { + "name": resource.name, + "kind": kind, + "base_class": kind, + "length_mm": resource.get_size_x(), + "width_mm": resource.get_size_y(), + "height_mm": resource.get_size_z(), + } + metadata.update(_well_grid_metadata(resource)) + return Labware( + id=resource.name, + definition_id=resource.name, + name=resource.name, + height=resource.get_size_z(), + width=resource.get_size_y(), + length=resource.get_size_x(), + labware_type=kind, + gripper_offset=0.0, + stack_height=resource.get_size_z(), + wells=int(metadata.get("rows", 0)) * int(metadata.get("cols", 0)), + metadata=metadata, + ) + + +class BravoDeck(Deck): + """PyLabRobot deck model for the Agilent Bravo's nine deck sites. + + Models the instrument's fixed 3x3 grid of deck locations as nine + :class:`~pylabrobot.resources.resource_holder.ResourceHolder` children, + one per site, positioned directly from the instrument's taught X/Y/Z -- + the same taught positions a :class:`~..bravo.Bravo` facade constructed + against this deck uses to drive the physical head, so the deck model + reflects the real machine rather than a nominal layout. Sites are + numbered 1 through 9 in the row-major order the rest of this package uses + for deck locations. + + A site's taught position is used as its origin unchanged, in the same + coordinate frame the teachpoints are already expressed in -- no + additional transform is applied. + """ + + def __init__( + self, + head_type: HeadType = "96_d_70", + teachpoints: Optional[Teachpoints] = None, + name: str = "bravo_deck", + category: str = "deck", + ) -> None: + """Initialize the deck and its nine sites. + + Args: + head_type: The installed head type, used to build a default set of + taught positions when *teachpoints* is not given. + teachpoints: The taught positions to place each site's origin at. + Defaults to a fresh set built for *head_type*. When a + :class:`~..bravo.Bravo` facade is also constructed for the same + instrument, pass this same object to both so they agree on where + every site actually is. + name: The deck's resource name. + category: The deck's resource category. + """ + size_x = (MAX_COLS - 1) * X_TO_X_DISTANCE + _GRID_MARGIN_X_MM + size_y = (MAX_ROWS - 1) * Y_TO_Y_DISTANCE + _GRID_MARGIN_Y_MM + super().__init__(size_x=size_x, size_y=size_y, size_z=0.0, name=name, category=category) + self._teachpoints = teachpoints if teachpoints is not None else _default_teachpoints(head_type) + self._site_holders: "dict[int, ResourceHolder]" = {} + for site in range(MIN_LOCATION, MAX_LOCATIONS + 1): + x = self._teachpoints.get_teachpoint(site, "x") + y = self._teachpoints.get_teachpoint(site, "y") + z = self._teachpoints.get_teachpoint(site, "z") + holder = ResourceHolder( + name=f"{self.name}_site_{site}", + size_x=_SITE_SIZE_X_MM, + size_y=_SITE_SIZE_Y_MM, + size_z=0.0, + ) + self._site_holders[site] = holder + super().assign_child_resource(holder, location=Coordinate(x=x, y=y, z=z)) + + @property + def teachpoints(self) -> Teachpoints: + """The taught positions each site's origin was placed from.""" + return self._teachpoints + + def _validate_site(self, site: int) -> None: + """Raise if *site* is not one of the nine deck sites.""" + if site not in self._site_holders: + raise ValueError(f"Site must be {MIN_LOCATION}-{MAX_LOCATIONS}, got {site}") + + def assign_child_at_site(self, resource: Resource, site: int) -> None: + """Assign *resource* to a deck site. + + Args: + resource: The plate, tip rack, or other resource to place. + site: The deck site to place it at, 1 through 9. + + Raises: + ValueError: If *site* is out of range or already occupied. + """ + self._validate_site(site) + holder = self._site_holders[site] + if holder.resource is not None: + raise ValueError(f"Site {site} is already occupied") + holder.assign_child_resource(resource) + + def unassign_site(self, site: int) -> None: + """Remove whatever resource is assigned to a deck site, if any. + + Args: + site: The deck site to clear, 1 through 9. + """ + self._validate_site(site) + holder = self._site_holders[site] + if holder.resource is not None: + holder.unassign_child_resource(holder.resource) + + def resource_at_site(self, site: int) -> Optional[Resource]: + """Return the resource currently assigned to a deck site, if any. + + Args: + site: The deck site to query, 1 through 9. + """ + self._validate_site(site) + return self._site_holders[site].resource + + def site_for_resource(self, resource: Resource) -> Optional[int]: + """Return the deck site a resource is assigned to, or ``None``. + + Args: + resource: The resource to look up. + """ + for site, holder in self._site_holders.items(): + if holder.resource is resource: + return site + return None + + def labware_for_site(self, site: int) -> Optional[Labware]: + """Return the internal labware description for whatever occupies a site. + + Args: + site: The deck site to describe, 1 through 9. + + Returns: + The translated :class:`Labware`, or ``None`` if the site is empty. + """ + resource = self.resource_at_site(site) + if resource is None: + return None + return labware_from_resource(resource) diff --git a/pylabrobot/agilent/bravo/deck/resource_tests.py b/pylabrobot/agilent/bravo/deck/resource_tests.py new file mode 100644 index 00000000000..5718e53780f --- /dev/null +++ b/pylabrobot/agilent/bravo/deck/resource_tests.py @@ -0,0 +1,190 @@ +"""Unit tests for :mod:`.resource`.""" + +from __future__ import annotations + +import unittest + +from pylabrobot.resources import Trash, cor_96_wellplate_360uL_Fb, opentrons_96_tiprack_300ul + +from ..types import MAX_LOCATIONS, MIN_LOCATION +from .geometry import well_center_offset_from_teachpoint_mm +from .resource import BravoDeck, labware_from_resource +from .teachpoints import Teachpoints + + +class SiteOriginFromTeachpointsTests(unittest.TestCase): + """Every site's origin must come from the taught X/Y/Z, unchanged.""" + + def test_every_site_origin_matches_its_teachpoint(self): + deck = BravoDeck(head_type="96_d_70") + for site in range(MIN_LOCATION, MAX_LOCATIONS + 1): + holder = deck._site_holders[site] + assert holder.location is not None + expected_x = deck.teachpoints.get_teachpoint(site, "x") + expected_y = deck.teachpoints.get_teachpoint(site, "y") + expected_z = deck.teachpoints.get_teachpoint(site, "z") + self.assertAlmostEqual(holder.location.x, expected_x) + self.assertAlmostEqual(holder.location.y, expected_y) + self.assertAlmostEqual(holder.location.z, expected_z) + + def test_uses_the_injected_teachpoints_instance_directly(self): + teachpoints = Teachpoints() + teachpoints.set_default_teachpoints("96_d_70") + teachpoints.set_teachpoint(1, "x", 999.0) + deck = BravoDeck(teachpoints=teachpoints) + self.assertIs(deck.teachpoints, teachpoints) + holder = deck._site_holders[1] + assert holder.location is not None + self.assertEqual(holder.location.x, 999.0) + + +class SiteResourceMappingTests(unittest.TestCase): + """Bidirectional site<->resource lookup.""" + + def test_assign_then_lookup_both_directions(self): + deck = BravoDeck() + plate = cor_96_wellplate_360uL_Fb(name="my_plate") + deck.assign_child_at_site(plate, 3) + self.assertIs(deck.resource_at_site(3), plate) + self.assertEqual(deck.site_for_resource(plate), 3) + + def test_empty_site_has_no_resource_and_no_reverse_mapping(self): + deck = BravoDeck() + self.assertIsNone(deck.resource_at_site(5)) + plate = cor_96_wellplate_360uL_Fb(name="unassigned_plate") + self.assertIsNone(deck.site_for_resource(plate)) + + def test_unassign_clears_the_site(self): + deck = BravoDeck() + plate = cor_96_wellplate_360uL_Fb(name="my_plate") + deck.assign_child_at_site(plate, 4) + deck.unassign_site(4) + self.assertIsNone(deck.resource_at_site(4)) + self.assertIsNone(deck.site_for_resource(plate)) + + def test_reassigning_an_occupied_site_raises(self): + deck = BravoDeck() + deck.assign_child_at_site(cor_96_wellplate_360uL_Fb(name="p1"), 2) + with self.assertRaises(ValueError): + deck.assign_child_at_site(cor_96_wellplate_360uL_Fb(name="p2"), 2) + + +class OutOfRangeSiteRejectedTests(unittest.TestCase): + """Sites outside 1-9 are rejected, not silently accepted.""" + + def test_assign_at_site_zero_raises(self): + deck = BravoDeck() + with self.assertRaises(ValueError): + deck.assign_child_at_site(cor_96_wellplate_360uL_Fb(name="p1"), 0) + + def test_assign_at_site_ten_raises(self): + deck = BravoDeck() + with self.assertRaises(ValueError): + deck.assign_child_at_site(cor_96_wellplate_360uL_Fb(name="p1"), 10) + + def test_resource_at_out_of_range_site_raises(self): + deck = BravoDeck() + with self.assertRaises(ValueError): + deck.resource_at_site(10) + + def test_unassign_out_of_range_site_raises(self): + deck = BravoDeck() + with self.assertRaises(ValueError): + deck.unassign_site(10) + + +class LabwareForSiteTests(unittest.TestCase): + """The internal Labware translation for whatever occupies a site.""" + + def test_empty_site_has_no_labware(self): + deck = BravoDeck() + self.assertIsNone(deck.labware_for_site(6)) + + def test_plate_translates_to_a_well_grid_labware(self): + deck = BravoDeck() + plate = cor_96_wellplate_360uL_Fb(name="my_plate") + deck.assign_child_at_site(plate, 1) + labware = deck.labware_for_site(1) + assert labware is not None + self.assertEqual(labware.name, "my_plate") + self.assertEqual(labware.metadata["kind"], "plate") + self.assertEqual(labware.metadata["base_class"], "plate") + self.assertEqual(labware.metadata["rows"], 8) + self.assertEqual(labware.metadata["cols"], 12) + self.assertAlmostEqual(labware.metadata["spacing_x_mm"], plate.item_dx) + self.assertAlmostEqual(labware.metadata["spacing_y_mm"], plate.item_dy) + a1 = plate.get_item("A1") + assert a1.location is not None + self.assertAlmostEqual(labware.metadata["offset_x_mm"], a1.location.x) + self.assertAlmostEqual(labware.metadata["offset_y_mm"], a1.location.y) + self.assertEqual(labware.height, plate.get_size_z()) + self.assertEqual(labware.width, plate.get_size_y()) + self.assertEqual(labware.length, plate.get_size_x()) + self.assertEqual(labware.wells, 96) + + def test_tip_rack_translates_to_a_tip_box_labware(self): + deck = BravoDeck() + tip_rack = opentrons_96_tiprack_300ul(name="my_tips") + deck.assign_child_at_site(tip_rack, 2) + labware = deck.labware_for_site(2) + assert labware is not None + self.assertEqual(labware.metadata["kind"], "tip_box") + self.assertEqual(labware.metadata["base_class"], "tip_box") + self.assertEqual(labware.metadata["rows"], 8) + self.assertEqual(labware.metadata["cols"], 12) + + def test_trash_translates_to_a_tip_trash_labware(self): + # Bravo.tips_off's _require_tip_receptacle only accepts kind/base_class + # in {"tip_box", "tip_trash"}; a Trash that fell through to the + # generic "plate" case would make every drop-to-trash fail. + deck = BravoDeck() + trash = Trash(name="my_trash", size_x=127.0, size_y=85.0, size_z=10.0) + deck.assign_child_at_site(trash, 3) + labware = deck.labware_for_site(3) + assert labware is not None + self.assertEqual(labware.metadata["kind"], "tip_trash") + self.assertEqual(labware.metadata["base_class"], "tip_trash") + + def test_labware_from_resource_matches_labware_for_site(self): + deck = BravoDeck() + plate = cor_96_wellplate_360uL_Fb(name="my_plate") + deck.assign_child_at_site(plate, 1) + direct = labware_from_resource(plate) + via_site = deck.labware_for_site(1) + assert via_site is not None + self.assertEqual(direct.metadata, via_site.metadata) + + def test_pins_the_well_target_sign_convention_this_translator_assumes(self): + """Not validated against hardware (see resource.py's _well_grid_metadata + docstring): this pins the CURRENT sign convention exactly, so a future + hardware check that finds it backwards fails this one test rather than + surfacing as a silent, hard-to-trace liquid-handling geometry error. + + The convention this translator currently produces, combined with + well_center_offset_from_teachpoint_mm, is: + + A1_target_xy == site_teachpoint_xy - item_a1.location + + not the sign-flipped alternative (site_teachpoint_xy + item_a1.location). + """ + deck = BravoDeck() + plate = cor_96_wellplate_360uL_Fb(name="my_plate") + deck.assign_child_at_site(plate, 1) + labware = deck.labware_for_site(1) + assert labware is not None + a1 = plate.get_item("A1") + assert a1.location is not None + teach_x = deck.teachpoints.get_teachpoint(1, "x") + teach_y = deck.teachpoints.get_teachpoint(1, "y") + offset_x, offset_y = well_center_offset_from_teachpoint_mm(labware.metadata, row=0, col=0) + target_x, target_y = teach_x + offset_x, teach_y + offset_y + self.assertAlmostEqual(target_x, teach_x - a1.location.x, places=6) + self.assertAlmostEqual(target_y, teach_y - a1.location.y, places=6) + # And explicitly NOT the sign-flipped alternative, so a reader who + # flips the docstring's formula without updating this test notices. + self.assertNotAlmostEqual(target_x, teach_x + a1.location.x, places=3) + self.assertNotAlmostEqual(target_y, teach_y + a1.location.y, places=3) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/deck/teachpoints.py b/pylabrobot/agilent/bravo/deck/teachpoints.py new file mode 100644 index 00000000000..81483c0054f --- /dev/null +++ b/pylabrobot/agilent/bravo/deck/teachpoints.py @@ -0,0 +1,106 @@ +"""Per-location XYZ teachpoints. + +Stores and manipulates the calibrated head positions for each of the nine +deck locations, with head-type-specific defaults and tip-length compensation. +""" + +from __future__ import annotations + +from ..types import ( + MAX_COLS, + MAX_ROWS, + MIN_LOCATION, + X_TO_X_DISTANCE, + Y_TO_Y_DISTANCE, + Axis, + HeadType, + axis_label, +) + +_LOC1_DEFAULTS: dict[str, dict[Axis, float]] = { + "96ch_disposable": {"x": 5.79, "y": 5.98, "z": 60.0}, + "384ch_disposable": {"x": 8.03, "y": 8.22, "z": 60.0}, + "8ch_lt": {"x": -49.24, "y": 5.98, "z": 60.0}, +} + +_HEAD_TYPE_CATEGORY: dict[HeadType, str] = { + "96_d_70": "96ch_disposable", + "96_d_70_s2": "96ch_disposable", + "96_d_200": "96ch_disposable", + "96_d_200_s2": "96ch_disposable", + "384_d_70": "384ch_disposable", + "384_d_70_s2": "384ch_disposable", + "8_d_lt": "8ch_lt", +} + + +class Teachpoints: + """Calibrated head positions for each deck location. + + Positions are stored as ``{location: {axis: value_mm}}``. + """ + + def __init__(self) -> None: + self._data: dict[int, dict[Axis, float]] = {} + + def get_teachpoint(self, location: int, axis: Axis) -> float: + """Return the teachpoint value for *location* and *axis*.""" + try: + return self._data[location][axis] + except KeyError: + raise KeyError(f"No teachpoint for location {location}, {axis_label(axis)}") from None + + def set_teachpoint(self, location: int, axis: Axis, value: float) -> None: + """Set (or overwrite) a single teachpoint value.""" + self._data.setdefault(location, {})[axis] = value + + def set_default_teachpoints(self, head_type: HeadType) -> None: + """Populate all 9 locations with default teachpoints for *head_type*. + + Location 1 uses head-type-specific offsets; remaining locations are + derived from the grid spacing constants ``X_TO_X_DISTANCE`` and + ``Y_TO_Y_DISTANCE``. + """ + category = _HEAD_TYPE_CATEGORY.get(head_type) + if category is None: + raise ValueError(f"No default teachpoints defined for head type {head_type}") + + loc1 = _LOC1_DEFAULTS[category] + self._data.clear() + + for row in range(MAX_ROWS): + for col in range(MAX_COLS): + location = row * MAX_COLS + col + MIN_LOCATION + self._data[location] = { + "x": loc1["x"] + col * X_TO_X_DISTANCE, + "y": loc1["y"] + row * Y_TO_Y_DISTANCE, + "z": loc1["z"], + } + + def compensate_for_tip( + self, + location: int, + default_tip_length: float, + current_tip_length: float, + ) -> None: + """Adjust the Z teachpoint at *location* for a non-default tip length. + + The Z position is shifted by ``(default_tip_length - current_tip_length)`` + so the pipette tips reach the same physical height regardless of tip + length. + """ + z = self.get_teachpoint(location, "z") + self.set_teachpoint( + location, + "z", + z + (default_tip_length - current_tip_length), + ) + + @property + def locations(self) -> list[int]: + """Return sorted list of locations that have teachpoints.""" + return sorted(self._data.keys()) + + def as_dict(self) -> dict[int, dict[Axis, float]]: + """Return a shallow copy of the internal data.""" + return {loc: dict(axes) for loc, axes in self._data.items()} diff --git a/pylabrobot/agilent/bravo/deck/teachpoints_tests.py b/pylabrobot/agilent/bravo/deck/teachpoints_tests.py new file mode 100644 index 00000000000..5b324491a28 --- /dev/null +++ b/pylabrobot/agilent/bravo/deck/teachpoints_tests.py @@ -0,0 +1,153 @@ +import unittest + +from pylabrobot.agilent.bravo.deck.teachpoints import Teachpoints +from pylabrobot.agilent.bravo.types import X_TO_X_DISTANCE, Y_TO_Y_DISTANCE + + +class SetGetRoundTripTests(unittest.TestCase): + def test_set_then_get_returns_the_same_value(self): + tp = Teachpoints() + tp.set_teachpoint(1, "x", 12.34) + self.assertEqual(tp.get_teachpoint(1, "x"), 12.34) + + def test_overwriting_a_teachpoint_replaces_the_value(self): + tp = Teachpoints() + tp.set_teachpoint(1, "z", 1.0) + tp.set_teachpoint(1, "z", 2.0) + self.assertEqual(tp.get_teachpoint(1, "z"), 2.0) + + def test_different_axes_at_the_same_location_are_independent(self): + tp = Teachpoints() + tp.set_teachpoint(3, "x", 1.0) + tp.set_teachpoint(3, "y", 2.0) + tp.set_teachpoint(3, "z", 3.0) + self.assertEqual( + (tp.get_teachpoint(3, "x"), tp.get_teachpoint(3, "y"), tp.get_teachpoint(3, "z")), + (1.0, 2.0, 3.0), + ) + + def test_get_on_never_set_location_raises_key_error(self): + tp = Teachpoints() + with self.assertRaises(KeyError): + tp.get_teachpoint(1, "x") + + def test_locations_reports_only_populated_locations(self): + tp = Teachpoints() + tp.set_teachpoint(7, "x", 1.0) + tp.set_teachpoint(2, "y", 1.0) + self.assertEqual(tp.locations, [2, 7]) + + def test_as_dict_is_a_copy(self): + tp = Teachpoints() + tp.set_teachpoint(1, "x", 1.0) + snapshot = tp.as_dict() + snapshot[1]["x"] = 999.0 + self.assertEqual(tp.get_teachpoint(1, "x"), 1.0) + + +class SetDefaultTeachpointsTests(unittest.TestCase): + def test_populates_all_nine_locations(self): + tp = Teachpoints() + tp.set_default_teachpoints("96_d_70") + self.assertEqual(tp.locations, list(range(1, 10))) + + def test_location_1_uses_the_96_disposable_head_defaults(self): + tp = Teachpoints() + tp.set_default_teachpoints("96_d_70") + self.assertAlmostEqual(tp.get_teachpoint(1, "x"), 5.79) + self.assertAlmostEqual(tp.get_teachpoint(1, "y"), 5.98) + self.assertAlmostEqual(tp.get_teachpoint(1, "z"), 60.0) + + def test_location_1_uses_the_384_disposable_head_defaults(self): + tp = Teachpoints() + tp.set_default_teachpoints("384_d_70") + self.assertAlmostEqual(tp.get_teachpoint(1, "x"), 8.03) + self.assertAlmostEqual(tp.get_teachpoint(1, "y"), 8.22) + + def test_location_1_uses_the_8_lt_head_defaults(self): + tp = Teachpoints() + tp.set_default_teachpoints("8_d_lt") + self.assertAlmostEqual(tp.get_teachpoint(1, "x"), -49.24) + self.assertAlmostEqual(tp.get_teachpoint(1, "y"), 5.98) + + def test_grid_spacing_advances_x_across_columns(self): + tp = Teachpoints() + tp.set_default_teachpoints("96_d_70") + self.assertAlmostEqual(tp.get_teachpoint(2, "x") - tp.get_teachpoint(1, "x"), X_TO_X_DISTANCE) + self.assertAlmostEqual(tp.get_teachpoint(3, "x") - tp.get_teachpoint(2, "x"), X_TO_X_DISTANCE) + + def test_grid_spacing_advances_y_across_rows(self): + tp = Teachpoints() + tp.set_default_teachpoints("96_d_70") + self.assertAlmostEqual(tp.get_teachpoint(4, "y") - tp.get_teachpoint(1, "y"), Y_TO_Y_DISTANCE) + self.assertAlmostEqual(tp.get_teachpoint(7, "y") - tp.get_teachpoint(4, "y"), Y_TO_Y_DISTANCE) + + def test_z_is_constant_across_all_locations(self): + tp = Teachpoints() + tp.set_default_teachpoints("96_d_70") + z_values = {tp.get_teachpoint(loc, "z") for loc in range(1, 10)} + self.assertEqual(z_values, {60.0}) + + def test_head_type_with_no_default_category_raises(self): + tp = Teachpoints() + with self.assertRaises(ValueError): + tp.set_default_teachpoints("96_pintool") + + def test_repopulating_clears_previous_data(self): + tp = Teachpoints() + tp.set_teachpoint(1, "w", 42.0) + tp.set_default_teachpoints("96_d_70") + with self.assertRaises(KeyError): + tp.get_teachpoint(1, "w") + + +class OutOfRangeLocationTests(unittest.TestCase): + def test_location_beyond_the_populated_deck_is_rejected_on_read(self): + tp = Teachpoints() + tp.set_default_teachpoints("96_d_70") + # Only locations 1-9 are ever populated by set_default_teachpoints; + # anything past MAX_LOCATIONS was never taught and is rejected. + with self.assertRaises(KeyError): + tp.get_teachpoint(10, "x") + + def test_location_zero_is_rejected_on_read(self): + tp = Teachpoints() + tp.set_default_teachpoints("96_d_70") + with self.assertRaises(KeyError): + tp.get_teachpoint(0, "x") + + +class CompensateForTipTests(unittest.TestCase): + def test_shorter_current_tip_raises_z(self): + tp = Teachpoints() + tp.set_teachpoint(1, "z", 60.0) + tp.compensate_for_tip(1, default_tip_length=30.0, current_tip_length=20.0) + self.assertAlmostEqual(tp.get_teachpoint(1, "z"), 70.0) + + def test_longer_current_tip_lowers_z(self): + tp = Teachpoints() + tp.set_teachpoint(1, "z", 60.0) + tp.compensate_for_tip(1, default_tip_length=30.0, current_tip_length=50.0) + self.assertAlmostEqual(tp.get_teachpoint(1, "z"), 40.0) + + def test_matching_tip_length_leaves_z_unchanged(self): + tp = Teachpoints() + tp.set_teachpoint(1, "z", 60.0) + tp.compensate_for_tip(1, default_tip_length=30.0, current_tip_length=30.0) + self.assertAlmostEqual(tp.get_teachpoint(1, "z"), 60.0) + + def test_compensation_only_touches_z(self): + tp = Teachpoints() + tp.set_teachpoint(1, "x", 5.0) + tp.set_teachpoint(1, "z", 60.0) + tp.compensate_for_tip(1, default_tip_length=30.0, current_tip_length=20.0) + self.assertAlmostEqual(tp.get_teachpoint(1, "x"), 5.0) + + def test_compensation_on_unset_location_raises(self): + tp = Teachpoints() + with self.assertRaises(KeyError): + tp.compensate_for_tip(1, default_tip_length=30.0, current_tip_length=20.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/testdata/tip_lengths_golden.json b/pylabrobot/agilent/bravo/testdata/tip_lengths_golden.json new file mode 100644 index 00000000000..3ddaf8118ef --- /dev/null +++ b/pylabrobot/agilent/bravo/testdata/tip_lengths_golden.json @@ -0,0 +1,937 @@ +[ + { + "head_type": "1536_pintool", + "tip_id": "lt_200ul", + "length_mm": null + }, + { + "head_type": "1536_pintool", + "tip_id": "lt_250ul", + "length_mm": null + }, + { + "head_type": "1536_pintool", + "tip_id": "pin_fp1cb", + "length_mm": null + }, + { + "head_type": "1536_pintool", + "tip_id": "pin_fp1n", + "length_mm": null + }, + { + "head_type": "1536_pintool", + "tip_id": "pin_fp1t", + "length_mm": null + }, + { + "head_type": "1536_pintool", + "tip_id": "st_10ul", + "length_mm": null + }, + { + "head_type": "1536_pintool", + "tip_id": "st_15ul", + "length_mm": null + }, + { + "head_type": "1536_pintool", + "tip_id": "st_30ul", + "length_mm": null + }, + { + "head_type": "1536_pintool", + "tip_id": "st_50ul", + "length_mm": null + }, + { + "head_type": "1536_pintool", + "tip_id": "st_51ul", + "length_mm": null + }, + { + "head_type": "1536_pintool", + "tip_id": "st_70ul", + "length_mm": null + }, + { + "head_type": "16_d_st", + "tip_id": "lt_200ul", + "length_mm": null + }, + { + "head_type": "16_d_st", + "tip_id": "lt_250ul", + "length_mm": null + }, + { + "head_type": "16_d_st", + "tip_id": "pin_fp1cb", + "length_mm": null + }, + { + "head_type": "16_d_st", + "tip_id": "pin_fp1n", + "length_mm": null + }, + { + "head_type": "16_d_st", + "tip_id": "pin_fp1t", + "length_mm": null + }, + { + "head_type": "16_d_st", + "tip_id": "st_10ul", + "length_mm": 19.9 + }, + { + "head_type": "16_d_st", + "tip_id": "st_15ul", + "length_mm": null + }, + { + "head_type": "16_d_st", + "tip_id": "st_30ul", + "length_mm": 26.1 + }, + { + "head_type": "16_d_st", + "tip_id": "st_50ul", + "length_mm": null + }, + { + "head_type": "16_d_st", + "tip_id": "st_51ul", + "length_mm": null + }, + { + "head_type": "16_d_st", + "tip_id": "st_70ul", + "length_mm": null + }, + { + "head_type": "384_d_70", + "tip_id": "lt_200ul", + "length_mm": null + }, + { + "head_type": "384_d_70", + "tip_id": "lt_250ul", + "length_mm": null + }, + { + "head_type": "384_d_70", + "tip_id": "pin_fp1cb", + "length_mm": null + }, + { + "head_type": "384_d_70", + "tip_id": "pin_fp1n", + "length_mm": null + }, + { + "head_type": "384_d_70", + "tip_id": "pin_fp1t", + "length_mm": null + }, + { + "head_type": "384_d_70", + "tip_id": "st_10ul", + "length_mm": 19.9 + }, + { + "head_type": "384_d_70", + "tip_id": "st_15ul", + "length_mm": null + }, + { + "head_type": "384_d_70", + "tip_id": "st_30ul", + "length_mm": 26.1 + }, + { + "head_type": "384_d_70", + "tip_id": "st_50ul", + "length_mm": null + }, + { + "head_type": "384_d_70", + "tip_id": "st_51ul", + "length_mm": null + }, + { + "head_type": "384_d_70", + "tip_id": "st_70ul", + "length_mm": null + }, + { + "head_type": "384_d_70_s2", + "tip_id": "lt_200ul", + "length_mm": null + }, + { + "head_type": "384_d_70_s2", + "tip_id": "lt_250ul", + "length_mm": null + }, + { + "head_type": "384_d_70_s2", + "tip_id": "pin_fp1cb", + "length_mm": null + }, + { + "head_type": "384_d_70_s2", + "tip_id": "pin_fp1n", + "length_mm": null + }, + { + "head_type": "384_d_70_s2", + "tip_id": "pin_fp1t", + "length_mm": null + }, + { + "head_type": "384_d_70_s2", + "tip_id": "st_10ul", + "length_mm": 19.9 + }, + { + "head_type": "384_d_70_s2", + "tip_id": "st_15ul", + "length_mm": null + }, + { + "head_type": "384_d_70_s2", + "tip_id": "st_30ul", + "length_mm": 26.1 + }, + { + "head_type": "384_d_70_s2", + "tip_id": "st_50ul", + "length_mm": null + }, + { + "head_type": "384_d_70_s2", + "tip_id": "st_51ul", + "length_mm": null + }, + { + "head_type": "384_d_70_s2", + "tip_id": "st_70ul", + "length_mm": null + }, + { + "head_type": "384_f_50", + "tip_id": "lt_200ul", + "length_mm": null + }, + { + "head_type": "384_f_50", + "tip_id": "lt_250ul", + "length_mm": null + }, + { + "head_type": "384_f_50", + "tip_id": "pin_fp1cb", + "length_mm": null + }, + { + "head_type": "384_f_50", + "tip_id": "pin_fp1n", + "length_mm": null + }, + { + "head_type": "384_f_50", + "tip_id": "pin_fp1t", + "length_mm": null + }, + { + "head_type": "384_f_50", + "tip_id": "st_10ul", + "length_mm": null + }, + { + "head_type": "384_f_50", + "tip_id": "st_15ul", + "length_mm": null + }, + { + "head_type": "384_f_50", + "tip_id": "st_30ul", + "length_mm": null + }, + { + "head_type": "384_f_50", + "tip_id": "st_50ul", + "length_mm": null + }, + { + "head_type": "384_f_50", + "tip_id": "st_51ul", + "length_mm": null + }, + { + "head_type": "384_f_50", + "tip_id": "st_70ul", + "length_mm": null + }, + { + "head_type": "384_pintool", + "tip_id": "lt_200ul", + "length_mm": null + }, + { + "head_type": "384_pintool", + "tip_id": "lt_250ul", + "length_mm": null + }, + { + "head_type": "384_pintool", + "tip_id": "pin_fp1cb", + "length_mm": null + }, + { + "head_type": "384_pintool", + "tip_id": "pin_fp1n", + "length_mm": null + }, + { + "head_type": "384_pintool", + "tip_id": "pin_fp1t", + "length_mm": null + }, + { + "head_type": "384_pintool", + "tip_id": "st_10ul", + "length_mm": null + }, + { + "head_type": "384_pintool", + "tip_id": "st_15ul", + "length_mm": null + }, + { + "head_type": "384_pintool", + "tip_id": "st_30ul", + "length_mm": null + }, + { + "head_type": "384_pintool", + "tip_id": "st_50ul", + "length_mm": null + }, + { + "head_type": "384_pintool", + "tip_id": "st_51ul", + "length_mm": null + }, + { + "head_type": "384_pintool", + "tip_id": "st_70ul", + "length_mm": null + }, + { + "head_type": "8_d_lt", + "tip_id": "lt_200ul", + "length_mm": null + }, + { + "head_type": "8_d_lt", + "tip_id": "lt_250ul", + "length_mm": 55.2 + }, + { + "head_type": "8_d_lt", + "tip_id": "pin_fp1cb", + "length_mm": null + }, + { + "head_type": "8_d_lt", + "tip_id": "pin_fp1n", + "length_mm": null + }, + { + "head_type": "8_d_lt", + "tip_id": "pin_fp1t", + "length_mm": null + }, + { + "head_type": "8_d_lt", + "tip_id": "st_10ul", + "length_mm": null + }, + { + "head_type": "8_d_lt", + "tip_id": "st_15ul", + "length_mm": null + }, + { + "head_type": "8_d_lt", + "tip_id": "st_30ul", + "length_mm": null + }, + { + "head_type": "8_d_lt", + "tip_id": "st_50ul", + "length_mm": null + }, + { + "head_type": "8_d_lt", + "tip_id": "st_51ul", + "length_mm": null + }, + { + "head_type": "8_d_lt", + "tip_id": "st_70ul", + "length_mm": null + }, + { + "head_type": "8_f_50", + "tip_id": "lt_200ul", + "length_mm": null + }, + { + "head_type": "8_f_50", + "tip_id": "lt_250ul", + "length_mm": null + }, + { + "head_type": "8_f_50", + "tip_id": "pin_fp1cb", + "length_mm": null + }, + { + "head_type": "8_f_50", + "tip_id": "pin_fp1n", + "length_mm": null + }, + { + "head_type": "8_f_50", + "tip_id": "pin_fp1t", + "length_mm": null + }, + { + "head_type": "8_f_50", + "tip_id": "st_10ul", + "length_mm": null + }, + { + "head_type": "8_f_50", + "tip_id": "st_15ul", + "length_mm": null + }, + { + "head_type": "8_f_50", + "tip_id": "st_30ul", + "length_mm": null + }, + { + "head_type": "8_f_50", + "tip_id": "st_50ul", + "length_mm": null + }, + { + "head_type": "8_f_50", + "tip_id": "st_51ul", + "length_mm": null + }, + { + "head_type": "8_f_50", + "tip_id": "st_70ul", + "length_mm": null + }, + { + "head_type": "96_assaymap", + "tip_id": "lt_200ul", + "length_mm": null + }, + { + "head_type": "96_assaymap", + "tip_id": "lt_250ul", + "length_mm": null + }, + { + "head_type": "96_assaymap", + "tip_id": "pin_fp1cb", + "length_mm": null + }, + { + "head_type": "96_assaymap", + "tip_id": "pin_fp1n", + "length_mm": null + }, + { + "head_type": "96_assaymap", + "tip_id": "pin_fp1t", + "length_mm": null + }, + { + "head_type": "96_assaymap", + "tip_id": "st_10ul", + "length_mm": null + }, + { + "head_type": "96_assaymap", + "tip_id": "st_15ul", + "length_mm": null + }, + { + "head_type": "96_assaymap", + "tip_id": "st_30ul", + "length_mm": null + }, + { + "head_type": "96_assaymap", + "tip_id": "st_50ul", + "length_mm": null + }, + { + "head_type": "96_assaymap", + "tip_id": "st_51ul", + "length_mm": null + }, + { + "head_type": "96_assaymap", + "tip_id": "st_70ul", + "length_mm": null + }, + { + "head_type": "96_d_200", + "tip_id": "lt_200ul", + "length_mm": null + }, + { + "head_type": "96_d_200", + "tip_id": "lt_250ul", + "length_mm": 55.2 + }, + { + "head_type": "96_d_200", + "tip_id": "pin_fp1cb", + "length_mm": null + }, + { + "head_type": "96_d_200", + "tip_id": "pin_fp1n", + "length_mm": null + }, + { + "head_type": "96_d_200", + "tip_id": "pin_fp1t", + "length_mm": null + }, + { + "head_type": "96_d_200", + "tip_id": "st_10ul", + "length_mm": null + }, + { + "head_type": "96_d_200", + "tip_id": "st_15ul", + "length_mm": null + }, + { + "head_type": "96_d_200", + "tip_id": "st_30ul", + "length_mm": null + }, + { + "head_type": "96_d_200", + "tip_id": "st_50ul", + "length_mm": null + }, + { + "head_type": "96_d_200", + "tip_id": "st_51ul", + "length_mm": null + }, + { + "head_type": "96_d_200", + "tip_id": "st_70ul", + "length_mm": null + }, + { + "head_type": "96_d_200_s2", + "tip_id": "lt_200ul", + "length_mm": null + }, + { + "head_type": "96_d_200_s2", + "tip_id": "lt_250ul", + "length_mm": 55.2 + }, + { + "head_type": "96_d_200_s2", + "tip_id": "pin_fp1cb", + "length_mm": null + }, + { + "head_type": "96_d_200_s2", + "tip_id": "pin_fp1n", + "length_mm": null + }, + { + "head_type": "96_d_200_s2", + "tip_id": "pin_fp1t", + "length_mm": null + }, + { + "head_type": "96_d_200_s2", + "tip_id": "st_10ul", + "length_mm": null + }, + { + "head_type": "96_d_200_s2", + "tip_id": "st_15ul", + "length_mm": null + }, + { + "head_type": "96_d_200_s2", + "tip_id": "st_30ul", + "length_mm": null + }, + { + "head_type": "96_d_200_s2", + "tip_id": "st_50ul", + "length_mm": null + }, + { + "head_type": "96_d_200_s2", + "tip_id": "st_51ul", + "length_mm": null + }, + { + "head_type": "96_d_200_s2", + "tip_id": "st_70ul", + "length_mm": null + }, + { + "head_type": "96_d_70", + "tip_id": "lt_200ul", + "length_mm": null + }, + { + "head_type": "96_d_70", + "tip_id": "lt_250ul", + "length_mm": null + }, + { + "head_type": "96_d_70", + "tip_id": "pin_fp1cb", + "length_mm": null + }, + { + "head_type": "96_d_70", + "tip_id": "pin_fp1n", + "length_mm": null + }, + { + "head_type": "96_d_70", + "tip_id": "pin_fp1t", + "length_mm": null + }, + { + "head_type": "96_d_70", + "tip_id": "st_10ul", + "length_mm": 19.9 + }, + { + "head_type": "96_d_70", + "tip_id": "st_15ul", + "length_mm": null + }, + { + "head_type": "96_d_70", + "tip_id": "st_30ul", + "length_mm": 26.1 + }, + { + "head_type": "96_d_70", + "tip_id": "st_50ul", + "length_mm": null + }, + { + "head_type": "96_d_70", + "tip_id": "st_51ul", + "length_mm": null + }, + { + "head_type": "96_d_70", + "tip_id": "st_70ul", + "length_mm": null + }, + { + "head_type": "96_d_70_s2", + "tip_id": "lt_200ul", + "length_mm": null + }, + { + "head_type": "96_d_70_s2", + "tip_id": "lt_250ul", + "length_mm": null + }, + { + "head_type": "96_d_70_s2", + "tip_id": "pin_fp1cb", + "length_mm": null + }, + { + "head_type": "96_d_70_s2", + "tip_id": "pin_fp1n", + "length_mm": null + }, + { + "head_type": "96_d_70_s2", + "tip_id": "pin_fp1t", + "length_mm": null + }, + { + "head_type": "96_d_70_s2", + "tip_id": "st_10ul", + "length_mm": 19.9 + }, + { + "head_type": "96_d_70_s2", + "tip_id": "st_15ul", + "length_mm": null + }, + { + "head_type": "96_d_70_s2", + "tip_id": "st_30ul", + "length_mm": 26.1 + }, + { + "head_type": "96_d_70_s2", + "tip_id": "st_50ul", + "length_mm": null + }, + { + "head_type": "96_d_70_s2", + "tip_id": "st_51ul", + "length_mm": null + }, + { + "head_type": "96_d_70_s2", + "tip_id": "st_70ul", + "length_mm": null + }, + { + "head_type": "96_f_200", + "tip_id": "lt_200ul", + "length_mm": null + }, + { + "head_type": "96_f_200", + "tip_id": "lt_250ul", + "length_mm": null + }, + { + "head_type": "96_f_200", + "tip_id": "pin_fp1cb", + "length_mm": null + }, + { + "head_type": "96_f_200", + "tip_id": "pin_fp1n", + "length_mm": null + }, + { + "head_type": "96_f_200", + "tip_id": "pin_fp1t", + "length_mm": null + }, + { + "head_type": "96_f_200", + "tip_id": "st_10ul", + "length_mm": null + }, + { + "head_type": "96_f_200", + "tip_id": "st_15ul", + "length_mm": null + }, + { + "head_type": "96_f_200", + "tip_id": "st_30ul", + "length_mm": null + }, + { + "head_type": "96_f_200", + "tip_id": "st_50ul", + "length_mm": null + }, + { + "head_type": "96_f_200", + "tip_id": "st_51ul", + "length_mm": null + }, + { + "head_type": "96_f_200", + "tip_id": "st_70ul", + "length_mm": null + }, + { + "head_type": "96_f_50", + "tip_id": "lt_200ul", + "length_mm": null + }, + { + "head_type": "96_f_50", + "tip_id": "lt_250ul", + "length_mm": null + }, + { + "head_type": "96_f_50", + "tip_id": "pin_fp1cb", + "length_mm": null + }, + { + "head_type": "96_f_50", + "tip_id": "pin_fp1n", + "length_mm": null + }, + { + "head_type": "96_f_50", + "tip_id": "pin_fp1t", + "length_mm": null + }, + { + "head_type": "96_f_50", + "tip_id": "st_10ul", + "length_mm": null + }, + { + "head_type": "96_f_50", + "tip_id": "st_15ul", + "length_mm": null + }, + { + "head_type": "96_f_50", + "tip_id": "st_30ul", + "length_mm": null + }, + { + "head_type": "96_f_50", + "tip_id": "st_50ul", + "length_mm": null + }, + { + "head_type": "96_f_50", + "tip_id": "st_51ul", + "length_mm": null + }, + { + "head_type": "96_f_50", + "tip_id": "st_70ul", + "length_mm": null + }, + { + "head_type": "96_pintool", + "tip_id": "lt_200ul", + "length_mm": null + }, + { + "head_type": "96_pintool", + "tip_id": "lt_250ul", + "length_mm": null + }, + { + "head_type": "96_pintool", + "tip_id": "pin_fp1cb", + "length_mm": null + }, + { + "head_type": "96_pintool", + "tip_id": "pin_fp1n", + "length_mm": null + }, + { + "head_type": "96_pintool", + "tip_id": "pin_fp1t", + "length_mm": null + }, + { + "head_type": "96_pintool", + "tip_id": "st_10ul", + "length_mm": null + }, + { + "head_type": "96_pintool", + "tip_id": "st_15ul", + "length_mm": null + }, + { + "head_type": "96_pintool", + "tip_id": "st_30ul", + "length_mm": null + }, + { + "head_type": "96_pintool", + "tip_id": "st_50ul", + "length_mm": null + }, + { + "head_type": "96_pintool", + "tip_id": "st_51ul", + "length_mm": null + }, + { + "head_type": "96_pintool", + "tip_id": "st_70ul", + "length_mm": null + }, + { + "head_type": "unknown", + "tip_id": "lt_200ul", + "length_mm": null + }, + { + "head_type": "unknown", + "tip_id": "lt_250ul", + "length_mm": null + }, + { + "head_type": "unknown", + "tip_id": "pin_fp1cb", + "length_mm": null + }, + { + "head_type": "unknown", + "tip_id": "pin_fp1n", + "length_mm": null + }, + { + "head_type": "unknown", + "tip_id": "pin_fp1t", + "length_mm": null + }, + { + "head_type": "unknown", + "tip_id": "st_10ul", + "length_mm": null + }, + { + "head_type": "unknown", + "tip_id": "st_15ul", + "length_mm": null + }, + { + "head_type": "unknown", + "tip_id": "st_30ul", + "length_mm": null + }, + { + "head_type": "unknown", + "tip_id": "st_50ul", + "length_mm": null + }, + { + "head_type": "unknown", + "tip_id": "st_51ul", + "length_mm": null + }, + { + "head_type": "unknown", + "tip_id": "st_70ul", + "length_mm": null + } +] diff --git a/pylabrobot/agilent/bravo/tip_offsets.py b/pylabrobot/agilent/bravo/tip_offsets.py new file mode 100644 index 00000000000..77a7c6af5d5 --- /dev/null +++ b/pylabrobot/agilent/bravo/tip_offsets.py @@ -0,0 +1,256 @@ +"""Per-(head, tip box) Tips On / Tips Off geometry overrides. + +The Tips On press depth/tolerance and the Tips Off eject depth (Z) and +ejector throw (W) depend on the physical combination of head type and tip +box: a long LT200 tip needs to eject much further above the box than a short +ST10 tip, and a deeper-engaging tip trips the Tips On force-press accept +window if it is sized for a shorter tip. + +These overrides are resolved at Tips On / Tips Off time by matching the +active head type and the tip box labware (by name, case/whitespace- +insensitive, or by ``tipbox_id``). Any field left unset on a matching entry +-- or the absence of any matching entry -- falls back to the caller-supplied +defaults (typically a profile's own Tips On/Off settings) and the global +:data:`~pylabrobot.agilent.bravo.types.TIPBOX_JOG_TOLERANCE` for the press +window. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional, Union + +from .types import TIPBOX_JOG_TOLERANCE, HeadType + + +@dataclass(frozen=True) +class TipOffsetEntry: + """One (head, tip box) override row. + + Every numeric field is optional; ``None`` means "fall back to the caller's + default for this field". Matching requires ``head_type`` plus at least one + of ``tipbox`` (labware name) or ``tipbox_id``. + + Attributes: + head_type: The head type this row applies to. + tipbox: The tip box labware name to match, case/whitespace-insensitive. + tipbox_id: The tip box labware type id to match. + tips_off_z_offset: Millimetres the head ejects above the seated press + depth during Tips Off. + tips_off_w_position: The W (plunger) target during Tips Off, before + returning W to 0. + tips_on_jog_tolerance: Millimetres of accept window for the Tips On + force press. + tips_on_z_offset: Millimetres added to the Tips On press target + (positive presses deeper). + """ + + head_type: HeadType + tipbox: str = "" + tipbox_id: str = "" + tips_off_z_offset: Optional[float] = None + tips_off_w_position: Optional[float] = None + tips_on_jog_tolerance: Optional[float] = None + tips_on_z_offset: Optional[float] = None + + +@dataclass(frozen=True) +class ResolvedTipOffsets: + """Fully resolved offsets, with every field filled (override or default). + + Attributes: + tips_off_z_offset: Millimetres the head ejects above the seated press + depth during Tips Off. + tips_off_w_position: The W (plunger) target during Tips Off. + tips_on_jog_tolerance: Millimetres of accept window for the Tips On + force press. + tips_on_z_offset: Millimetres added to the Tips On press target. + matched: Whether an override row was found. + source: A human-readable description of where the values came from. + """ + + tips_off_z_offset: float + tips_off_w_position: float + tips_on_jog_tolerance: float + tips_on_z_offset: float + matched: bool + source: str + + +def _normalize_key(value: Any) -> str: + """Collapse whitespace and case for a name/id comparison key. + + Args: + value: The value to normalize; falsy values normalize to ``""``. + + Returns: + The value's whitespace-collapsed, lowercased string form. + """ + return " ".join(str(value or "").split()).strip().lower() + + +def _normalize_head(value: Any) -> str: + """Normalize a head type value for comparison. + + Args: + value: A head type string, in any case. + + Returns: + The lowercase, stripped string form. + """ + return str(value if value is not None else "").strip().lower() + + +class TipOffsetTable: + """In-memory collection of :class:`TipOffsetEntry` rows with lookup.""" + + def __init__(self, entries: list[TipOffsetEntry]) -> None: + """Initialize the table. + + Args: + entries: The override rows the table serves, in match priority order. + """ + self._entries = list(entries) + + @property + def entries(self) -> list[TipOffsetEntry]: + """Return the table's rows, in match priority order.""" + return list(self._entries) + + def find( + self, + head_type: Union[HeadType, str], + *, + tipbox_name: str = "", + tipbox_id: str = "", + ) -> Optional[TipOffsetEntry]: + """Return the first entry matching *head_type* and the tip box. + + A row matches when its head type matches AND either its tip box id or + its tip box name matches the supplied values. Id match is preferred but + a name match is equally accepted (entries are scanned in file order). + + Args: + head_type: The active head type. + tipbox_name: The tip box labware's name. + tipbox_id: The tip box labware's type id. + + Returns: + The first matching entry, or ``None``. + """ + head = _normalize_head(head_type) + if not head: + return None + name_key = _normalize_key(tipbox_name) + id_key = _normalize_key(tipbox_id) + for entry in self._entries: + if _normalize_head(entry.head_type) != head: + continue + entry_id = _normalize_key(entry.tipbox_id) + entry_name = _normalize_key(entry.tipbox) + if entry_id and id_key and entry_id == id_key: + return entry + if entry_name and name_key and entry_name == name_key: + return entry + return None + + def resolve( + self, + head_type: Union[HeadType, str], + *, + tipbox_name: str = "", + tipbox_id: str = "", + default_z_offset: float, + default_w_position: float, + default_jog_tolerance: float = TIPBOX_JOG_TOLERANCE, + default_z_on_offset: float = 0.0, + ) -> ResolvedTipOffsets: + """Resolve offsets for a (head, tip box), filling gaps with defaults. + + Args: + head_type: The active head type. + tipbox_name: The tip box labware's name. + tipbox_id: The tip box labware's type id. + default_z_offset: Fallback for ``tips_off_z_offset``. + default_w_position: Fallback for ``tips_off_w_position``. + default_jog_tolerance: Fallback for ``tips_on_jog_tolerance``. + default_z_on_offset: Fallback for ``tips_on_z_offset``. + + Returns: + The resolved offsets, with ``matched`` set to whether an override row + was found and ``source`` describing where the values came from. + """ + entry = self.find(head_type, tipbox_name=tipbox_name, tipbox_id=tipbox_id) + if entry is None: + return ResolvedTipOffsets( + tips_off_z_offset=float(default_z_offset), + tips_off_w_position=float(default_w_position), + tips_on_jog_tolerance=float(default_jog_tolerance), + tips_on_z_offset=float(default_z_on_offset), + matched=False, + source="caller defaults", + ) + label = entry.tipbox or entry.tipbox_id or "?" + return ResolvedTipOffsets( + tips_off_z_offset=float( + entry.tips_off_z_offset if entry.tips_off_z_offset is not None else default_z_offset + ), + tips_off_w_position=float( + entry.tips_off_w_position if entry.tips_off_w_position is not None else default_w_position + ), + tips_on_jog_tolerance=float( + entry.tips_on_jog_tolerance + if entry.tips_on_jog_tolerance is not None + else default_jog_tolerance + ), + tips_on_z_offset=float( + entry.tips_on_z_offset if entry.tips_on_z_offset is not None else default_z_on_offset + ), + matched=True, + source=f"tip_offsets[{_normalize_head(entry.head_type)} / {label}]", + ) + + +# Transcribed from config/tip_offsets.yaml. Every numeric field is a +# tuned physical value measured against real hardware. +_TIP_OFFSET_ENTRIES: tuple[TipOffsetEntry, ...] = ( + # 96-channel LT (200 uL) head with the long V11 LT200 tips. These tips + # engage ~7-8 mm shallower than the ST10-tuned press target, which trips a + # false "within tolerance" warning at the default jog tolerance, so the + # accept window is widened. They also stand much taller out of the box, so + # Tips Off must eject ~25 mm above the seated depth. + # + # tips_off_w_position: the W ejector hits a hard mechanical stop at -11 on + # this head -- driving to -11 (or beyond) stalls the W servo and aborts + # the move even though the tips strip off. -9 clears the stop and ejects + # cleanly; do not round this toward -10/-11. + TipOffsetEntry( + head_type="96_d_200", + tipbox="96 V11 LT200 Tip Box 06880.002", + tipbox_id="lw-b0704e550d2a", + tips_off_z_offset=25.0, + tips_off_w_position=-9.0, + tips_on_jog_tolerance=12.0, + tips_on_z_offset=0.0, + ), + # 384-channel ST (70 uL) head with the short V11 ST10 tips. The short tips + # never trip the Tips On warning, so the press window stays at the 5 mm + # default. A small eject offset keeps the short tips close to the box. + TipOffsetEntry( + head_type="384_d_70", + tipbox="384 V11 ST10 Tip Box 10734.102", + tipbox_id="lw-4914769d0af7", + tips_off_z_offset=14.0, + tips_off_w_position=-7.0, + tips_on_jog_tolerance=5.0, + tips_on_z_offset=0.0, + ), +) + +DEFAULT_TIP_OFFSET_TABLE = TipOffsetTable(list(_TIP_OFFSET_ENTRIES)) +"""The built-in (head, tip box) offset table.""" + + +def get_tip_offset_table() -> TipOffsetTable: + """Return the built-in (head, tip box) offset table.""" + return DEFAULT_TIP_OFFSET_TABLE diff --git a/pylabrobot/agilent/bravo/tip_offsets_tests.py b/pylabrobot/agilent/bravo/tip_offsets_tests.py new file mode 100644 index 00000000000..a5369a103c1 --- /dev/null +++ b/pylabrobot/agilent/bravo/tip_offsets_tests.py @@ -0,0 +1,181 @@ +import unittest + +from pylabrobot.agilent.bravo.tip_offsets import ( + DEFAULT_TIP_OFFSET_TABLE, + ResolvedTipOffsets, + TipOffsetEntry, + TipOffsetTable, + get_tip_offset_table, +) +from pylabrobot.agilent.bravo.types import TIPBOX_JOG_TOLERANCE + +# The two rows transcribed from config/tip_offsets.yaml, written out +# independently as TipOffsetEntry instances. +_EXPECTED_ROWS: tuple[TipOffsetEntry, ...] = ( + TipOffsetEntry( + head_type="96_d_200", + tipbox="96 V11 LT200 Tip Box 06880.002", + tipbox_id="lw-b0704e550d2a", + tips_off_z_offset=25.0, + tips_off_w_position=-9.0, + tips_on_jog_tolerance=12.0, + tips_on_z_offset=0.0, + ), + TipOffsetEntry( + head_type="384_d_70", + tipbox="384 V11 ST10 Tip Box 10734.102", + tipbox_id="lw-4914769d0af7", + tips_off_z_offset=14.0, + tips_off_w_position=-7.0, + tips_on_jog_tolerance=5.0, + tips_on_z_offset=0.0, + ), +) + + +class TranscribedOffsetRowsMatchYamlTests(unittest.TestCase): + def test_row_count_matches_the_yaml_file(self): + self.assertEqual(len(DEFAULT_TIP_OFFSET_TABLE.entries), len(_EXPECTED_ROWS)) + + def test_every_row_matches_field_by_field(self): + by_head = {entry.head_type: entry for entry in DEFAULT_TIP_OFFSET_TABLE.entries} + for expected in _EXPECTED_ROWS: + with self.subTest(head_type=expected.head_type): + actual = by_head[expected.head_type] + self.assertEqual(actual.tipbox, expected.tipbox) + self.assertEqual(actual.tipbox_id, expected.tipbox_id) + self.assertEqual(actual.tips_off_z_offset, expected.tips_off_z_offset) + self.assertEqual(actual.tips_off_w_position, expected.tips_off_w_position) + self.assertEqual(actual.tips_on_jog_tolerance, expected.tips_on_jog_tolerance) + self.assertEqual(actual.tips_on_z_offset, expected.tips_on_z_offset) + + def test_lt200_w_position_is_not_rounded_toward_the_mechanical_stop(self): + entry = next(e for e in DEFAULT_TIP_OFFSET_TABLE.entries if e.head_type == "96_d_200") + self.assertEqual(entry.tips_off_w_position, -9.0) + + +class GetTipOffsetTableTests(unittest.TestCase): + def test_returns_the_default_table(self): + self.assertIs(get_tip_offset_table(), DEFAULT_TIP_OFFSET_TABLE) + + +class FindTests(unittest.TestCase): + def test_finds_by_tipbox_id(self): + entry = DEFAULT_TIP_OFFSET_TABLE.find("96_d_200", tipbox_id="lw-b0704e550d2a") + self.assertIsNotNone(entry) + assert entry is not None + self.assertEqual(entry.tipbox, "96 V11 LT200 Tip Box 06880.002") + + def test_finds_by_tipbox_name_case_and_whitespace_insensitive(self): + entry = DEFAULT_TIP_OFFSET_TABLE.find( + "384_d_70", tipbox_name=" 384 v11 st10 TIP box 10734.102 " + ) + self.assertIsNotNone(entry) + assert entry is not None + self.assertEqual(entry.tipbox_id, "lw-4914769d0af7") + + def test_no_match_for_unknown_head_returns_none(self): + self.assertIsNone(DEFAULT_TIP_OFFSET_TABLE.find("96_pintool", tipbox_id="lw-b0704e550d2a")) + + def test_no_match_when_tipbox_does_not_match(self): + self.assertIsNone(DEFAULT_TIP_OFFSET_TABLE.find("96_d_200", tipbox_id="not-a-real-id")) + + def test_empty_head_type_returns_none(self): + self.assertIsNone(DEFAULT_TIP_OFFSET_TABLE.find("", tipbox_id="lw-b0704e550d2a")) + + +class ResolveWithOverrideTests(unittest.TestCase): + def test_resolves_matched_entry_values(self): + resolved = DEFAULT_TIP_OFFSET_TABLE.resolve( + "96_d_200", + tipbox_id="lw-b0704e550d2a", + default_z_offset=15.0, + default_w_position=-5.0, + ) + self.assertEqual( + resolved, + ResolvedTipOffsets( + tips_off_z_offset=25.0, + tips_off_w_position=-9.0, + tips_on_jog_tolerance=12.0, + tips_on_z_offset=0.0, + matched=True, + source="tip_offsets[96_d_200 / 96 V11 LT200 Tip Box 06880.002]", + ), + ) + + def test_partial_override_fills_missing_fields_from_defaults(self): + table = TipOffsetTable( + [ + TipOffsetEntry( + head_type="96_d_70", + tipbox_id="tb-1", + tips_off_z_offset=20.0, + # tips_off_w_position / tips_on_jog_tolerance / tips_on_z_offset + # are left unset (None) on this row. + ) + ] + ) + resolved = table.resolve( + "96_d_70", + tipbox_id="tb-1", + default_z_offset=15.0, + default_w_position=-6.0, + default_jog_tolerance=8.0, + default_z_on_offset=1.0, + ) + self.assertEqual(resolved.tips_off_z_offset, 20.0) # overridden + self.assertEqual(resolved.tips_off_w_position, -6.0) # default + self.assertEqual(resolved.tips_on_jog_tolerance, 8.0) # default + self.assertEqual(resolved.tips_on_z_offset, 1.0) # default + self.assertTrue(resolved.matched) + + +class ResolveWithoutOverrideTests(unittest.TestCase): + def test_unmatched_head_falls_back_entirely_to_defaults(self): + resolved = DEFAULT_TIP_OFFSET_TABLE.resolve( + "96_pintool", + tipbox_id="lw-b0704e550d2a", + default_z_offset=15.0, + default_w_position=-5.0, + ) + self.assertEqual( + resolved, + ResolvedTipOffsets( + tips_off_z_offset=15.0, + tips_off_w_position=-5.0, + tips_on_jog_tolerance=TIPBOX_JOG_TOLERANCE, + tips_on_z_offset=0.0, + matched=False, + source="caller defaults", + ), + ) + + def test_empty_table_always_falls_back_to_defaults(self): + table = TipOffsetTable([]) + resolved = table.resolve( + "96_d_200", + tipbox_id="anything", + default_z_offset=1.0, + default_w_position=2.0, + ) + self.assertFalse(resolved.matched) + self.assertEqual(resolved.tips_off_z_offset, 1.0) + self.assertEqual(resolved.tips_off_w_position, 2.0) + + def test_custom_jog_tolerance_and_z_on_offset_defaults_are_honored(self): + table = TipOffsetTable([]) + resolved = table.resolve( + "96_d_200", + tipbox_id="anything", + default_z_offset=1.0, + default_w_position=2.0, + default_jog_tolerance=3.0, + default_z_on_offset=4.0, + ) + self.assertEqual(resolved.tips_on_jog_tolerance, 3.0) + self.assertEqual(resolved.tips_on_z_offset, 4.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/tips.py b/pylabrobot/agilent/bravo/tips.py new file mode 100644 index 00000000000..01ca116534e --- /dev/null +++ b/pylabrobot/agilent/bravo/tips.py @@ -0,0 +1,322 @@ +"""The disposable/pintool tip catalogue. + +Every tip a Bravo head can pick up: its capacity, physical length (where +measured), and which head types it fits. The catalogue is a fixed table of +measured values, not a runtime-editable store. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Optional, Union + +from .types import ALL_HEAD_TYPES, HeadType + + +@dataclass(frozen=True) +class TipDefinition: + """One entry in the tip catalogue. + + Attributes: + tip_id: The catalogue's stable identifier for this tip, e.g. ``"st_10ul"``. + capacity_ul: The tip's nominal liquid capacity, in microlitres. + label: A short human-readable name, e.g. ``"10 uL"``. + length_mm: The tip's measured physical length, in millimetres, or + ``None`` if it has not been measured. + source: Where the value came from, e.g. ``"measured"`` or + ``"vendor-source-option"``. + compatible_heads: The head types this tip fits. An empty tuple means no + compatibility filter is recorded for this tip. + """ + + tip_id: str + capacity_ul: float + label: str + length_mm: Optional[float] + source: str + compatible_heads: tuple[HeadType, ...] = () + + +# Transcribed from config/tips.yaml. Every row is a measured or +# vendor-documented physical value; a `length_mm` of `None` means the tip's +# length has not been measured, not that it is zero. +_TIP_DEFINITIONS: tuple[TipDefinition, ...] = ( + TipDefinition( + "st_10ul", + 10.0, + "10 uL", + 19.9, + "measured", + ("16_d_st", "384_d_70", "384_d_70_s2", "96_d_70", "96_d_70_s2"), + ), + TipDefinition( + "st_15ul", + 15.0, + "15 uL", + None, + "vendor-source-option", + ("16_d_st", "384_d_70", "384_d_70_s2", "96_d_70", "96_d_70_s2"), + ), + TipDefinition( + "lt_200ul", + 200.0, + "200 uL", + None, + "vendor-source-option", + ("8_d_lt", "96_d_200", "96_d_200_s2"), + ), + TipDefinition( + "lt_250ul", + 250.0, + "250 uL", + 55.2, + "vendor-source-default", + ("8_d_lt", "96_d_200", "96_d_200_s2"), + ), + TipDefinition( + "st_30ul", + 30.0, + "30 uL", + 26.1, + "vendor-source-comment", + ("16_d_st", "384_d_70", "384_d_70_s2", "96_d_70", "96_d_70_s2"), + ), + TipDefinition( + "st_50ul", + 50.0, + "50 uL", + None, + "vendor-source-option", + ("16_d_st", "384_d_70", "384_d_70_s2", "96_d_70", "96_d_70_s2"), + ), + TipDefinition( + "st_51ul", + 51.0, + "51 uL", + None, + "vendor-source-option", + ("16_d_st", "384_d_70", "384_d_70_s2", "96_d_70", "96_d_70_s2"), + ), + TipDefinition( + "st_70ul", + 70.0, + "70 uL", + None, + "vendor-source-option", + ("16_d_st", "384_d_70", "384_d_70_s2", "96_d_70", "96_d_70_s2"), + ), + TipDefinition( + "pin_fp1cb", + 0.0, + "FP1CB", + None, + "vendor-source-option", + ("1536_pintool", "384_pintool", "96_pintool"), + ), + TipDefinition( + "pin_fp1n", + 0.0, + "FP1N", + None, + "vendor-source-option", + ("1536_pintool", "384_pintool", "96_pintool"), + ), + TipDefinition( + "pin_fp1t", + 0.0, + "FP1T", + None, + "vendor-source-option", + ("1536_pintool", "384_pintool", "96_pintool"), + ), +) + + +def _is_close_capacity(value: object, capacity_ul: float) -> bool: + """Return whether *value* is numerically close to *capacity_ul*. + + Args: + value: A candidate capacity, of any type; non-numeric values return + False rather than raising. + capacity_ul: The capacity to compare against, in microlitres. + + Returns: + True if *value* converts to a float within 1e-6 of *capacity_ul*. + """ + try: + return abs(float(value) - float(capacity_ul)) < 1e-6 # type: ignore[arg-type] + except (TypeError, ValueError): + return False + + +def _normalize_head_type(head_type: Union[HeadType, str]) -> Optional[HeadType]: + """Normalize a head type value to a canonical :data:`HeadType`. + + Args: + head_type: A head type string, in any case. + + Returns: + The lowercase, canonical head type if it is a recognized value, + otherwise ``None``. + """ + text = str(head_type).strip().lower() + return text if text in ALL_HEAD_TYPES else None # type: ignore[return-value] + + +def get_tip_definitions_for_head(head_type: Union[HeadType, str]) -> list[TipDefinition]: + """Return every tip compatible with a head type, sorted by capacity. + + Args: + head_type: The head type to look up tips for. + + Returns: + Tips whose ``compatible_heads`` includes *head_type* (or is empty, which + means no compatibility filter is recorded), sorted by capacity, then + label, then tip id. Returns an empty list for an unrecognized head type. + """ + normalized = _normalize_head_type(head_type) + if normalized is None: + return [] + matches = [ + tip + for tip in _TIP_DEFINITIONS + if not tip.compatible_heads or normalized in tip.compatible_heads + ] + matches.sort(key=lambda tip: (float(tip.capacity_ul or 0.0), tip.label.lower(), tip.tip_id)) + return matches + + +def get_tip_definition( + head_type: Union[HeadType, str], tip_id_or_capacity: Union[str, float, int, None] +) -> Optional[TipDefinition]: + """Resolve a tip definition for a head, by id or by capacity. + + Args: + head_type: The head type the tip must be compatible with. + tip_id_or_capacity: Either a ``tip_id`` string, or a capacity in + microlitres (matched within a small tolerance). + + Returns: + The first matching :class:`TipDefinition`, or ``None`` if nothing + matches. + """ + if tip_id_or_capacity is None: + return None + for tip in get_tip_definitions_for_head(head_type): + if str(tip.tip_id) == str(tip_id_or_capacity): + return tip + if _is_close_capacity(tip_id_or_capacity, tip.capacity_ul): + return tip + return None + + +def get_tip_definition_by_id(tip_id: Optional[str]) -> Optional[TipDefinition]: + """Return the tip definition with the given id, regardless of head. + + Args: + tip_id: The tip id to look up. + + Returns: + The matching :class:`TipDefinition`, or ``None`` if not found or + *tip_id* is falsy. + """ + if not tip_id: + return None + for tip in _TIP_DEFINITIONS: + if tip.tip_id == str(tip_id): + return tip + return None + + +def get_tip_length_mm( + head_type: Union[HeadType, str], tip_id_or_capacity: Union[str, float, int, None] +) -> Optional[float]: + """Return a tip's measured length, in millimetres. + + Args: + head_type: The head type the tip must be compatible with. + tip_id_or_capacity: Either a ``tip_id`` string, or a capacity in + microlitres. + + Returns: + The tip's ``length_mm``, which is ``None`` both when the tip cannot be + resolved and when it resolves to a tip whose length has not been + measured. + """ + tip = get_tip_definition(head_type, tip_id_or_capacity) + return None if tip is None else tip.length_mm + + +def get_tip_capacity_ul( + head_type: Union[HeadType, str], tip_id_or_capacity: Union[str, float, int, None] +) -> float: + """Return a tip's capacity, in microlitres. + + Args: + head_type: The head type the tip must be compatible with. + tip_id_or_capacity: Either a ``tip_id`` string, or a capacity value. + + Returns: + The resolved tip's ``capacity_ul``. If no tip resolves, falls back to + interpreting *tip_id_or_capacity* itself as a numeric capacity, or 0.0 + if that also fails. + """ + tip = get_tip_definition(head_type, tip_id_or_capacity) + if tip is not None: + return float(tip.capacity_ul) + try: + return float(tip_id_or_capacity or 0.0) # type: ignore[arg-type] + except (TypeError, ValueError): + return 0.0 + + +def get_tip_id_for_capacity( + head_type: Union[HeadType, str], capacity_ul: Optional[float] +) -> Optional[str]: + """Return the tip id matching a capacity for a head type. + + Args: + head_type: The head type the tip must be compatible with. + capacity_ul: The capacity to match, in microlitres. + + Returns: + The matching tip's ``tip_id``, or ``None`` if nothing matches. + """ + tip = get_tip_definition(head_type, capacity_ul) + return None if tip is None else tip.tip_id + + +def get_default_tip_id_for_head(head_type: Union[HeadType, str]) -> Optional[str]: + """Return the tip id a head type should default to. + + Args: + head_type: The head type to pick a default tip for. + + Returns: + The 200 uL tip's id for long-tip (8_d_lt/96_d_200/96_d_200_s2) heads, the + 30 uL tip's id for every other compatible head, or the first compatible + tip's id if the preferred capacity has no match. ``None`` if the head + type is unrecognized or has no compatible tips. + """ + normalized = _normalize_head_type(head_type) + if normalized is None: + return None + options = get_tip_definitions_for_head(normalized) + if not options: + return None + preferred_capacity = 200.0 if normalized in {"8_d_lt", "96_d_200", "96_d_200_s2"} else 30.0 + match = get_tip_definition(normalized, preferred_capacity) + return match.tip_id if match is not None else options[0].tip_id + + +def serialize_tip_options_for_head(head_type: Union[HeadType, str]) -> list[dict[str, object]]: + """Return every tip compatible with a head type as plain dicts. + + Args: + head_type: The head type to look up tips for. + + Returns: + Each compatible :class:`TipDefinition`, in the same order as + :func:`get_tip_definitions_for_head`, converted with ``dataclasses.asdict``. + """ + return [asdict(tip) for tip in get_tip_definitions_for_head(head_type)] diff --git a/pylabrobot/agilent/bravo/tips_golden_frame_tests.py b/pylabrobot/agilent/bravo/tips_golden_frame_tests.py new file mode 100644 index 00000000000..869fa0b61ae --- /dev/null +++ b/pylabrobot/agilent/bravo/tips_golden_frame_tests.py @@ -0,0 +1,59 @@ +"""Golden-frame test: tip length resolution against the reference implementation. + +``testdata/tip_lengths_golden.json`` holds ``get_tip_length_mm(head_type, tip_id)`` +for every (head type, tip id) combination -- 17 head types x 11 tip ids -- captured +directly from the reference implementation's tip catalogue, by calling +``get_tip_length_mm(head, tip_id)`` for every head type and every tip id its +own tip-definition iterator enumerates, then mapping each head type onto its +ported lowercase :data:`HeadType` literal (e.g. ``"96_d_200"``). + +This is what actually pins length resolution to the reference implementation's +*behavior* rather than to a config file: its tip-lookup function reads a +YAML-backed store first and only falls back to a hardcoded per-head table +when that store has no compatible rows for the head, so the value a caller +gets for a given (head, tip) pair is not obvious from either source alone -- +only running the reference code settles it. Every entry here (including the +174 combinations that resolve to ``null``, where a tip is not compatible with +a head or has no measured length) is a value this port's +:func:`~pylabrobot.agilent.bravo.tips.get_tip_length_mm` must reproduce exactly, +since tip length feeds the Z-height calculation for aspirate/dispense. +""" + +from __future__ import annotations + +import json +import unittest +from pathlib import Path + +from pylabrobot.agilent.bravo.tips import get_tip_length_mm + +_GOLDEN_PATH = Path(__file__).parent / "testdata" / "tip_lengths_golden.json" +with open(_GOLDEN_PATH) as _f: + GOLDEN: list = json.load(_f) + + +class TipLengthMatchesReferenceImplementationTests(unittest.TestCase): + def test_fixture_covers_every_head_and_tip(self): + # 17 head types x 11 tip ids from the reference catalogue. + self.assertEqual(len(GOLDEN), 187) + + def test_every_head_tip_pair_matches_the_reference_value(self): + for row in GOLDEN: + with self.subTest(head_type=row["head_type"], tip_id=row["tip_id"]): + self.assertEqual( + get_tip_length_mm(row["head_type"], row["tip_id"]), + row["length_mm"], + ) + + def test_at_least_one_compatible_pair_per_short_tip_head(self): + # Sanity check that the fixture is not accidentally all-null: every + # short-tip head resolves st_10ul to its measured 19.9 mm length. + short_tip_heads = {"16_d_st", "384_d_70", "384_d_70_s2", "96_d_70", "96_d_70_s2"} + matched = { + row["head_type"] for row in GOLDEN if row["tip_id"] == "st_10ul" and row["length_mm"] == 19.9 + } + self.assertEqual(matched, short_tip_heads) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/tips_tests.py b/pylabrobot/agilent/bravo/tips_tests.py new file mode 100644 index 00000000000..eb3f7149548 --- /dev/null +++ b/pylabrobot/agilent/bravo/tips_tests.py @@ -0,0 +1,238 @@ +import unittest + +from pylabrobot.agilent.bravo.tips import ( + _TIP_DEFINITIONS, + TipDefinition, + get_default_tip_id_for_head, + get_tip_capacity_ul, + get_tip_definition, + get_tip_definition_by_id, + get_tip_definitions_for_head, + get_tip_id_for_capacity, + get_tip_length_mm, + serialize_tip_options_for_head, +) +from pylabrobot.agilent.bravo.types import HeadType + +_SHORT_TIP_HEADS: tuple[HeadType, ...] = ( + "16_d_st", + "384_d_70", + "384_d_70_s2", + "96_d_70", + "96_d_70_s2", +) +_LONG_TIP_HEADS: tuple[HeadType, ...] = ("8_d_lt", "96_d_200", "96_d_200_s2") +_PINTOOL_HEADS: tuple[HeadType, ...] = ("1536_pintool", "384_pintool", "96_pintool") + +# Every row transcribed from config/tips.yaml, in file order, written out +# independently as TipDefinition instances. This is the ground truth the +# table-driven test below compares _TIP_DEFINITIONS against, so a mistyped +# digit in tips.py fails loudly. +_EXPECTED_YAML_ROWS: tuple[TipDefinition, ...] = ( + TipDefinition("st_10ul", 10.0, "10 uL", 19.9, "measured", _SHORT_TIP_HEADS), + TipDefinition("st_15ul", 15.0, "15 uL", None, "vendor-source-option", _SHORT_TIP_HEADS), + TipDefinition("lt_200ul", 200.0, "200 uL", None, "vendor-source-option", _LONG_TIP_HEADS), + TipDefinition("lt_250ul", 250.0, "250 uL", 55.2, "vendor-source-default", _LONG_TIP_HEADS), + TipDefinition("st_30ul", 30.0, "30 uL", 26.1, "vendor-source-comment", _SHORT_TIP_HEADS), + TipDefinition("st_50ul", 50.0, "50 uL", None, "vendor-source-option", _SHORT_TIP_HEADS), + TipDefinition("st_51ul", 51.0, "51 uL", None, "vendor-source-option", _SHORT_TIP_HEADS), + TipDefinition("st_70ul", 70.0, "70 uL", None, "vendor-source-option", _SHORT_TIP_HEADS), + TipDefinition("pin_fp1cb", 0.0, "FP1CB", None, "vendor-source-option", _PINTOOL_HEADS), + TipDefinition("pin_fp1n", 0.0, "FP1N", None, "vendor-source-option", _PINTOOL_HEADS), + TipDefinition("pin_fp1t", 0.0, "FP1T", None, "vendor-source-option", _PINTOOL_HEADS), +) + +_NULL_LENGTH_TIP_IDS = frozenset( + {"st_15ul", "lt_200ul", "st_50ul", "st_51ul", "st_70ul", "pin_fp1cb", "pin_fp1n", "pin_fp1t"} +) + + +class TranscribedTipRowsMatchYamlTests(unittest.TestCase): + """Table-driven check that every transcribed row matches config/tips.yaml.""" + + def test_row_count_matches_the_yaml_file(self): + self.assertEqual(len(_TIP_DEFINITIONS), len(_EXPECTED_YAML_ROWS)) + + def test_every_row_matches_field_by_field(self): + by_id = {tip.tip_id: tip for tip in _TIP_DEFINITIONS} + for expected in _EXPECTED_YAML_ROWS: + with self.subTest(tip_id=expected.tip_id): + actual = by_id.get(expected.tip_id) + self.assertIsNotNone(actual, f"{expected.tip_id} missing from _TIP_DEFINITIONS") + assert actual is not None + self.assertEqual(actual.capacity_ul, expected.capacity_ul) + self.assertEqual(actual.label, expected.label) + self.assertEqual(actual.length_mm, expected.length_mm) + self.assertEqual(actual.source, expected.source) + self.assertEqual(tuple(actual.compatible_heads), tuple(expected.compatible_heads)) + + def test_null_length_rows_preserve_none_rather_than_a_number(self): + actual_null_ids = {row.tip_id for row in _EXPECTED_YAML_ROWS if row.length_mm is None} + self.assertEqual(actual_null_ids, set(_NULL_LENGTH_TIP_IDS)) + by_id = {tip.tip_id: tip for tip in _TIP_DEFINITIONS} + for tip_id in _NULL_LENGTH_TIP_IDS: + with self.subTest(tip_id=tip_id): + self.assertIsNone(by_id[tip_id].length_mm) + + def test_measured_length_rows_keep_their_exact_value(self): + by_id = {tip.tip_id: tip for tip in _TIP_DEFINITIONS} + self.assertEqual(by_id["st_10ul"].length_mm, 19.9) + self.assertEqual(by_id["st_30ul"].length_mm, 26.1) + self.assertEqual(by_id["lt_250ul"].length_mm, 55.2) + + def test_no_duplicate_tip_ids(self): + ids = [tip.tip_id for tip in _TIP_DEFINITIONS] + self.assertEqual(len(ids), len(set(ids))) + + +class GetTipDefinitionsForHeadTests(unittest.TestCase): + def test_returns_only_tips_compatible_with_the_head(self): + tips = get_tip_definitions_for_head("96_d_70") + ids = {tip.tip_id for tip in tips} + self.assertEqual(ids, {"st_10ul", "st_15ul", "st_30ul", "st_50ul", "st_51ul", "st_70ul"}) + + def test_long_tip_head_only_sees_long_tips(self): + tips = get_tip_definitions_for_head("8_d_lt") + ids = {tip.tip_id for tip in tips} + self.assertEqual(ids, {"lt_200ul", "lt_250ul"}) + + def test_pintool_head_only_sees_pintool_options(self): + tips = get_tip_definitions_for_head("96_pintool") + ids = {tip.tip_id for tip in tips} + self.assertEqual(ids, {"pin_fp1cb", "pin_fp1n", "pin_fp1t"}) + + def test_results_are_sorted_by_capacity(self): + tips = get_tip_definitions_for_head("96_d_70") + capacities = [tip.capacity_ul for tip in tips] + self.assertEqual(capacities, sorted(capacities)) + + def test_unrecognized_head_type_returns_empty_list(self): + self.assertEqual(get_tip_definitions_for_head("not_a_real_head"), []) + + def test_head_type_lookup_is_case_insensitive(self): + self.assertEqual( + [t.tip_id for t in get_tip_definitions_for_head("96_D_70")], + [t.tip_id for t in get_tip_definitions_for_head("96_d_70")], + ) + + +class GetTipDefinitionTests(unittest.TestCase): + def test_resolves_by_tip_id(self): + tip = get_tip_definition("96_d_70", "st_30ul") + self.assertIsNotNone(tip) + assert tip is not None + self.assertEqual(tip.tip_id, "st_30ul") + + def test_resolves_by_capacity(self): + tip = get_tip_definition("96_d_70", 30.0) + self.assertIsNotNone(tip) + assert tip is not None + self.assertEqual(tip.tip_id, "st_30ul") + + def test_capacity_match_uses_a_small_tolerance(self): + tip = get_tip_definition("96_d_70", 30.0000001) + self.assertIsNotNone(tip) + assert tip is not None + self.assertEqual(tip.tip_id, "st_30ul") + + def test_incompatible_capacity_for_head_returns_none(self): + # 200 uL is not compatible with a 96_d_70 (short-tip) head. + self.assertIsNone(get_tip_definition("96_d_70", 200.0)) + + def test_none_input_returns_none(self): + self.assertIsNone(get_tip_definition("96_d_70", None)) + + +class GetTipDefinitionByIdTests(unittest.TestCase): + def test_finds_a_tip_regardless_of_head(self): + tip = get_tip_definition_by_id("lt_250ul") + self.assertIsNotNone(tip) + assert tip is not None + self.assertEqual(tip.capacity_ul, 250.0) + + def test_unknown_id_returns_none(self): + self.assertIsNone(get_tip_definition_by_id("does-not-exist")) + + def test_empty_id_returns_none(self): + self.assertIsNone(get_tip_definition_by_id("")) + self.assertIsNone(get_tip_definition_by_id(None)) + + +class GetTipLengthMmTests(unittest.TestCase): + def test_measured_tip_returns_its_length(self): + self.assertEqual(get_tip_length_mm("96_d_70", "st_10ul"), 19.9) + + def test_unmeasured_tip_returns_none(self): + self.assertIsNone(get_tip_length_mm("96_d_70", "st_15ul")) + + def test_unresolvable_tip_returns_none(self): + self.assertIsNone(get_tip_length_mm("96_d_70", "no_such_tip")) + + +class GetTipCapacityUlTests(unittest.TestCase): + def test_resolved_tip_returns_its_capacity(self): + self.assertEqual(get_tip_capacity_ul("96_d_70", "st_30ul"), 30.0) + + def test_unresolved_input_falls_back_to_numeric_parse(self): + self.assertEqual(get_tip_capacity_ul("96_d_70", 123.0), 123.0) + + def test_unresolved_non_numeric_input_returns_zero(self): + self.assertEqual(get_tip_capacity_ul("96_d_70", "not-a-number"), 0.0) + + +class GetTipIdForCapacityTests(unittest.TestCase): + def test_matches_by_capacity(self): + self.assertEqual(get_tip_id_for_capacity("96_d_70", 30.0), "st_30ul") + + def test_no_match_returns_none(self): + self.assertIsNone(get_tip_id_for_capacity("96_d_70", 999.0)) + + +class GetDefaultTipIdForHeadTests(unittest.TestCase): + def test_long_tip_heads_default_to_200ul(self): + for head in ("8_d_lt", "96_d_200", "96_d_200_s2"): + with self.subTest(head=head): + self.assertEqual(get_default_tip_id_for_head(head), "lt_200ul") + + def test_short_tip_heads_default_to_30ul(self): + for head in ("16_d_st", "384_d_70", "384_d_70_s2", "96_d_70", "96_d_70_s2"): + with self.subTest(head=head): + self.assertEqual(get_default_tip_id_for_head(head), "st_30ul") + + def test_head_with_no_matching_preferred_capacity_falls_back_to_first_option(self): + tip_id = get_default_tip_id_for_head("96_pintool") + options = get_tip_definitions_for_head("96_pintool") + self.assertEqual(tip_id, options[0].tip_id) + + def test_unrecognized_head_type_returns_none(self): + self.assertIsNone(get_default_tip_id_for_head("not_a_real_head")) + + +class SerializeTipOptionsForHeadTests(unittest.TestCase): + def test_serializes_every_compatible_tip_as_a_dict(self): + serialized = serialize_tip_options_for_head("96_d_70") + self.assertEqual(len(serialized), len(get_tip_definitions_for_head("96_d_70"))) + for item in serialized: + self.assertIsInstance(item, dict) + self.assertIn("tip_id", item) + self.assertIn("length_mm", item) + + def test_model_3d_is_not_present(self): + for item in serialize_tip_options_for_head("96_d_70"): + self.assertNotIn("model_3d", item) + + +class TipDefinitionShapeTests(unittest.TestCase): + def test_tip_definition_has_no_model_3d_field(self): + fields = TipDefinition.__dataclass_fields__ + self.assertNotIn("model_3d", fields) + self.assertIn("tip_id", fields) + self.assertIn("capacity_ul", fields) + self.assertIn("label", fields) + self.assertIn("length_mm", fields) + self.assertIn("source", fields) + self.assertIn("compatible_heads", fields) + + +if __name__ == "__main__": + unittest.main() From f26eb5d440b3b27b517a58065050422e76cbf7d8 Mon Sep 17 00:00:00 2001 From: kelsorj Date: Fri, 21 Aug 2026 10:59:48 -0700 Subject: [PATCH 7/9] Add the Bravo state machine Fourteen operations -- initialize, home, dock gripper, move to location, aspirate, dispense, mix, tips on, tips off, pick and place, gripper teach move, delid, relid, and scan stack height -- expressed as tasks the engine runs step by step with support for abort, retry, and ignore. This is where an operator request becomes the ordered sequence of axis moves that accomplishes it safely: Z clearance before lateral transit, two-phase approaches, tip-touch handling, and neighbour-footprint checks. The W axis carries volumes on the Agile family and millimetres on Darwin, so a volume is converted to controller-native units where it is combined with a native position; park positions and offsets are millimetres and are never converted. Golden-frame tests pin the full controller-call sequence for 28 scenarios. Behaviour that depends on the controller generation, or that never reaches a controller call, is pinned by direct tests instead, since captured sequences cannot see it. --- .../agilent/bravo/state_machine/__init__.py | 0 .../agilent/bravo/state_machine/engine.py | 329 + .../bravo/state_machine/engine_tests.py | 231 + .../state_machine/golden_frame_support.py | 279 + .../initialization_golden_frame_tests.py | 98 + .../liquid_handling_golden_frame_tests.py | 282 + .../pick_place_golden_frame_tests.py | 229 + .../agilent/bravo/state_machine/tasks.py | 5315 +++++++++ .../bravo/state_machine/tasks_tests.py | 429 + .../testdata/task_golden_frames.json | 9861 +++++++++++++++++ .../tips_on_off_golden_frame_tests.py | 211 + 11 files changed, 17264 insertions(+) create mode 100644 pylabrobot/agilent/bravo/state_machine/__init__.py create mode 100644 pylabrobot/agilent/bravo/state_machine/engine.py create mode 100644 pylabrobot/agilent/bravo/state_machine/engine_tests.py create mode 100644 pylabrobot/agilent/bravo/state_machine/golden_frame_support.py create mode 100644 pylabrobot/agilent/bravo/state_machine/initialization_golden_frame_tests.py create mode 100644 pylabrobot/agilent/bravo/state_machine/liquid_handling_golden_frame_tests.py create mode 100644 pylabrobot/agilent/bravo/state_machine/pick_place_golden_frame_tests.py create mode 100644 pylabrobot/agilent/bravo/state_machine/tasks.py create mode 100644 pylabrobot/agilent/bravo/state_machine/tasks_tests.py create mode 100644 pylabrobot/agilent/bravo/state_machine/testdata/task_golden_frames.json create mode 100644 pylabrobot/agilent/bravo/state_machine/tips_on_off_golden_frame_tests.py diff --git a/pylabrobot/agilent/bravo/state_machine/__init__.py b/pylabrobot/agilent/bravo/state_machine/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/agilent/bravo/state_machine/engine.py b/pylabrobot/agilent/bravo/state_machine/engine.py new file mode 100644 index 00000000000..6bce33802ae --- /dev/null +++ b/pylabrobot/agilent/bravo/state_machine/engine.py @@ -0,0 +1,329 @@ +"""Step-by-step task engine with abort/retry/ignore error recovery. + +A :class:`StateMachineTask` breaks one Bravo operation (initialize, move, +aspirate, pick-and-place, ...) into an ordered list of named async steps. +:class:`StateMachineEngine` runs those steps in order. When a step raises, +the engine pauses the task, reports the failure through an error callback, +and waits for the caller to choose whether to abort the task, retry the +failed step, or ignore it and continue -- so an operator can recover a +workflow from a transient fault instead of the whole run dying. +""" + +from __future__ import annotations + +import asyncio +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum, auto +from typing import Awaitable, Callable, Optional + +logger = logging.getLogger(__name__) + + +class TaskStatus(Enum): + """The lifecycle state of a :class:`StateMachineTask`.""" + + PENDING = auto() + RUNNING = auto() + COMPLETED = auto() + FAILED = auto() + ABORTED = auto() + PAUSED = auto() + + +class ErrorAction(Enum): + """The operator's choice for recovering from a failed step.""" + + ABORT = auto() + RETRY = auto() + IGNORE = auto() + + +@dataclass +class TaskError: + """Details of a step failure. + + Attributes: + message: The failure message, typically ``str(original_exception)``. + step_name: The name of the step that failed. + original_exception: The exception the step raised, if any. + """ + + message: str + step_name: str + original_exception: Optional[Exception] = None + + +class StateMachineTask(ABC): + """Base class for one ordered, resumable Bravo operation. + + A task defines an ordered sequence of named async steps through + :meth:`get_steps`. :class:`StateMachineEngine` executes them in order; if + a step raises, the engine pauses and waits for an :class:`ErrorAction` + before proceeding. + """ + + def __init__(self, name: str) -> None: + """Initialize the task. + + Args: + name: A human-readable name for this task instance, used in status + payloads and log messages. + """ + self.name = name + self.status = TaskStatus.PENDING + self._current_step_index = 0 + self._current_step_name: Optional[str] = None + self.error: Optional[TaskError] = None + # Universal operator-prompt slot. Step handlers can set this to a + # dict like {kind, title, message, choices: [retry, ignore, abort]} + # before raising, to show a task-specific modal. If left None, the + # engine synthesizes a generic step_failed prompt so every failure is + # recoverable. + self._operator_prompt: Optional[dict] = None + + def status_payload(self) -> dict: + """Return the task's current status for display to an operator. + + The default implementation surfaces the operator prompt (task-specific + or engine-synthesized) whenever the task is in a failed state. + Subclasses that compose a richer payload should call + ``super().status_payload()`` and merge its result in. + + Returns: + A dict with an ``"operator_prompt"`` key when the task is failed and + a prompt is set, otherwise an empty dict. + """ + if self.status == TaskStatus.FAILED and self._operator_prompt: + return {"operator_prompt": dict(self._operator_prompt)} + return {} + + def on_error_action(self, action: ErrorAction) -> None: + """Handle the operator's choice after a step failure. + + The base implementation clears the operator prompt so a subsequent, + distinct failure can populate its own. Subclasses should call + ``super().on_error_action(action)`` before their own logic. + + Args: + action: The action the operator chose. + """ + self._operator_prompt = None + + @abstractmethod + def get_steps(self) -> "list[tuple[str, Callable[[], Awaitable[None]]]]": + """Return this task's steps, in execution order. + + Returns: + An ordered list of ``(step_name, async_callable)`` pairs. + """ + ... + + +class StateMachineEngine: + """Runs :class:`StateMachineTask` instances step by step. + + Executes one task's steps in order. When a step raises, the engine fires + its error callback and blocks until :meth:`abort`, :meth:`retry`, or + :meth:`ignore` is called to resolve the failure. + """ + + def __init__(self) -> None: + """Initialize the engine with no task running and no handlers set.""" + self._lock = asyncio.Lock() + self._current_task: Optional[StateMachineTask] = None + self._error_action_event = asyncio.Event() + self._pending_action: Optional[ErrorAction] = None + self._awaiting_error_action = False + self._on_error: Optional[Callable[[TaskError], None]] = None + self._on_step_complete: Optional[Callable[[str, str], None]] = None + self._on_task_complete: Optional[Callable[[StateMachineTask], None]] = None + + def set_error_handler(self, handler: Callable[[TaskError], None]) -> None: + """Set the callback invoked with a :class:`TaskError` when a step fails. + + Args: + handler: The callback to invoke. + """ + self._on_error = handler + + def set_step_handler(self, handler: Callable[[str, str], None]) -> None: + """Set the callback invoked with ``(task_name, step_name)`` after each step. + + Args: + handler: The callback to invoke. + """ + self._on_step_complete = handler + + def set_completion_handler(self, handler: Callable[[StateMachineTask], None]) -> None: + """Set the callback invoked with the task once it completes. + + Args: + handler: The callback to invoke. + """ + self._on_task_complete = handler + + async def execute(self, task: StateMachineTask) -> None: + """Run a task's steps in order until it completes, is aborted, or fails. + + If a step raises and no error handler is set, the exception propagates + to the caller instead of pausing for an :class:`ErrorAction`. + + Args: + task: The task to run. + """ + async with self._lock: + self._current_task = task + task.status = TaskStatus.RUNNING + steps = task.get_steps() + + while task._current_step_index < len(steps): + step_name, step_fn = steps[task._current_step_index] + task._current_step_name = step_name + try: + await step_fn() + if self._on_step_complete: + self._on_step_complete(task.name, step_name) + task._current_step_index += 1 + except Exception as exc: + task.error = TaskError( + message=str(exc), + step_name=step_name, + original_exception=exc, + ) + task.status = TaskStatus.FAILED + logger.error( + "Task '%s' step '%s' failed: %s", + task.name, + step_name, + exc, + ) + + if self._on_error: + self._on_error(task.error) + else: + self._current_task = None + raise + + try: + payload = task.status_payload() or {} + except Exception: + payload = {} + if not payload.get("operator_prompt"): + # No task-specific prompt was set. Synthesize a generic + # Retry/Ignore/Abort prompt so every state machine failure is + # recoverable from the UI. + fallback_step = step_name or "" + task._operator_prompt = { + "kind": "step_failed", + "title": f"{task.name} failed", + "message": ( + f"Step '{fallback_step}' raised:\n{exc!s}\n\n" + "Retry re-runs the same step.\n" + "Ignore skips this step and continues.\n" + "Abort stops the workflow." + ), + "choices": ["retry", "ignore", "abort"], + "step": fallback_step, + } + + action = await self._wait_for_error_action() + try: + task.on_error_action(action) + except Exception as hook_exc: + logger.error("Task '%s' error-action hook failed: %s", task.name, hook_exc) + task.status = TaskStatus.ABORTED + self._current_task = None + raise + + if action == ErrorAction.ABORT: + task.status = TaskStatus.ABORTED + self._current_task = None + return + elif action == ErrorAction.RETRY: + task.status = TaskStatus.RUNNING + continue + elif action == ErrorAction.IGNORE: + task.status = TaskStatus.RUNNING + task._current_step_index += 1 + continue + + task._current_step_name = None + task.status = TaskStatus.COMPLETED + if self._on_task_complete: + self._on_task_complete(task) + self._current_task = None + + async def _wait_for_error_action(self) -> ErrorAction: + """Block until an :class:`ErrorAction` is resolved for the current failure. + + Returns: + The resolved action, defaulting to :attr:`ErrorAction.ABORT` if the + event was set without a pending action recorded. + """ + self._error_action_event.clear() + self._pending_action = None + self._awaiting_error_action = True + try: + await self._error_action_event.wait() + return self._pending_action or ErrorAction.ABORT + finally: + self._awaiting_error_action = False + + def resolve_error(self, action: ErrorAction) -> bool: + """Resolve the currently paused step failure with the given action. + + Args: + action: The action to resolve the failure with. + + Returns: + True if a paused failure was waiting and this call resolved it, + False if no failure is currently paused or one was already resolved. + """ + if not self._awaiting_error_action: + return False + if self._pending_action is not None: + return False + self._pending_action = action + self._error_action_event.set() + return True + + def abort(self) -> bool: + """Resolve the current step failure with :attr:`ErrorAction.ABORT`. + + Returns: + True if this call resolved a paused failure, False otherwise. + """ + return self.resolve_error(ErrorAction.ABORT) + + def retry(self) -> bool: + """Resolve the current step failure with :attr:`ErrorAction.RETRY`. + + Returns: + True if this call resolved a paused failure, False otherwise. + """ + return self.resolve_error(ErrorAction.RETRY) + + def ignore(self) -> bool: + """Resolve the current step failure with :attr:`ErrorAction.IGNORE`. + + Returns: + True if this call resolved a paused failure, False otherwise. + """ + return self.resolve_error(ErrorAction.IGNORE) + + @property + def current_task(self) -> Optional[StateMachineTask]: + """The task currently executing, or ``None`` if the engine is idle.""" + return self._current_task + + @property + def is_busy(self) -> bool: + """Whether the engine currently has a task running.""" + return self._current_task is not None + + @property + def awaiting_error_action(self) -> bool: + """Whether the engine is currently paused waiting for an ErrorAction.""" + return self._awaiting_error_action diff --git a/pylabrobot/agilent/bravo/state_machine/engine_tests.py b/pylabrobot/agilent/bravo/state_machine/engine_tests.py new file mode 100644 index 00000000000..c13808ccc7d --- /dev/null +++ b/pylabrobot/agilent/bravo/state_machine/engine_tests.py @@ -0,0 +1,231 @@ +import asyncio +import unittest + +from pylabrobot.agilent.bravo.state_machine.engine import ( + StateMachineEngine, + StateMachineTask, + TaskError, + TaskStatus, +) + + +class _RecordingTask(StateMachineTask): + """A task whose steps are supplied by the test.""" + + def __init__(self, name, steps): + super().__init__(name) + self._steps = steps + + def get_steps(self): + return self._steps + + +class StateMachineEngineTests(unittest.IsolatedAsyncioTestCase): + async def test_successful_task_runs_every_step_in_order(self): + calls = [] + + async def step_a(): + calls.append("a") + + async def step_b(): + calls.append("b") + + task = _RecordingTask("t", [("a", step_a), ("b", step_b)]) + engine = StateMachineEngine() + await engine.execute(task) + + self.assertEqual(calls, ["a", "b"]) + self.assertEqual(task.status, TaskStatus.COMPLETED) + self.assertIsNone(engine.current_task) + self.assertFalse(engine.is_busy) + + async def test_step_completion_and_task_completion_handlers_fire(self): + step_events = [] + completed = [] + + async def step_a(): + pass + + task = _RecordingTask("t", [("a", step_a)]) + engine = StateMachineEngine() + engine.set_step_handler(lambda task_name, step_name: step_events.append((task_name, step_name))) + engine.set_completion_handler(lambda t: completed.append(t)) + await engine.execute(task) + + self.assertEqual(step_events, [("t", "a")]) + self.assertEqual(completed, [task]) + + async def test_failure_without_error_handler_raises(self): + async def failing(): + raise RuntimeError("boom") + + task = _RecordingTask("t", [("a", failing)]) + engine = StateMachineEngine() + with self.assertRaises(RuntimeError): + await engine.execute(task) + self.assertEqual(task.status, TaskStatus.FAILED) + + async def test_abort_stops_the_task(self): + async def failing(): + raise RuntimeError("boom") + + task = _RecordingTask("t", [("a", failing)]) + engine = StateMachineEngine() + engine.set_error_handler(lambda err: None) + + async def resolve_soon(): + # Give execute() a chance to reach the wait point. + while not engine.awaiting_error_action: + await asyncio.sleep(0) + engine.abort() + + await asyncio.gather(engine.execute(task), resolve_soon()) + self.assertEqual(task.status, TaskStatus.ABORTED) + self.assertIsNone(engine.current_task) + + async def test_retry_reruns_the_failed_step(self): + attempts = {"n": 0} + + async def flaky(): + attempts["n"] += 1 + if attempts["n"] < 2: + raise RuntimeError("transient") + + task = _RecordingTask("t", [("a", flaky)]) + engine = StateMachineEngine() + engine.set_error_handler(lambda err: None) + + async def resolve_soon(): + while not engine.awaiting_error_action: + await asyncio.sleep(0) + engine.retry() + + await asyncio.gather(engine.execute(task), resolve_soon()) + self.assertEqual(attempts["n"], 2) + self.assertEqual(task.status, TaskStatus.COMPLETED) + + async def test_ignore_skips_the_failed_step_and_continues(self): + calls = [] + + async def failing(): + raise RuntimeError("boom") + + async def step_b(): + calls.append("b") + + task = _RecordingTask("t", [("a", failing), ("b", step_b)]) + engine = StateMachineEngine() + engine.set_error_handler(lambda err: None) + + async def resolve_soon(): + while not engine.awaiting_error_action: + await asyncio.sleep(0) + engine.ignore() + + await asyncio.gather(engine.execute(task), resolve_soon()) + self.assertEqual(calls, ["b"]) + self.assertEqual(task.status, TaskStatus.COMPLETED) + + async def test_generic_prompt_is_synthesized_when_task_sets_none(self): + async def failing(): + raise RuntimeError("boom") + + task = _RecordingTask("t", [("a", failing)]) + engine = StateMachineEngine() + errors: list[TaskError] = [] + engine.set_error_handler(errors.append) + captured_payload = {} + + async def resolve_soon(): + while not engine.awaiting_error_action: + await asyncio.sleep(0) + # Capture the payload while the task is still FAILED — status_payload() + # only surfaces the prompt in that state. + captured_payload.update(task.status_payload()) + engine.abort() + + await asyncio.gather(engine.execute(task), resolve_soon()) + self.assertEqual(captured_payload["operator_prompt"]["kind"], "step_failed") + self.assertEqual(captured_payload["operator_prompt"]["step"], "a") + self.assertEqual(errors[0].step_name, "a") + + async def test_task_specific_prompt_is_preserved(self): + class PromptingTask(StateMachineTask): + def __init__(self): + super().__init__("t") + + def get_steps(self): + return [("a", self._fail)] + + async def _fail(self): + self._operator_prompt = {"kind": "custom", "choices": ["retry", "ignore", "abort"]} + raise RuntimeError("boom") + + task = PromptingTask() + engine = StateMachineEngine() + engine.set_error_handler(lambda err: None) + captured_payload = {} + + async def resolve_soon(): + while not engine.awaiting_error_action: + await asyncio.sleep(0) + captured_payload.update(task.status_payload()) + engine.abort() + + await asyncio.gather(engine.execute(task), resolve_soon()) + self.assertEqual(captured_payload["operator_prompt"]["kind"], "custom") + + async def test_on_error_action_hook_failure_aborts_and_propagates(self): + class BrokenHookTask(StateMachineTask): + def __init__(self): + super().__init__("t") + + def get_steps(self): + return [("a", self._fail)] + + async def _fail(self): + raise RuntimeError("boom") + + def on_error_action(self, action): + raise ValueError("hook exploded") + + task = BrokenHookTask() + engine = StateMachineEngine() + engine.set_error_handler(lambda err: None) + + async def resolve_soon(): + while not engine.awaiting_error_action: + await asyncio.sleep(0) + engine.abort() + + with self.assertRaises(ValueError): + await asyncio.gather(engine.execute(task), resolve_soon()) + self.assertEqual(task.status, TaskStatus.ABORTED) + + async def test_resolve_error_returns_false_when_not_awaiting(self): + engine = StateMachineEngine() + self.assertFalse(engine.abort()) + self.assertFalse(engine.retry()) + self.assertFalse(engine.ignore()) + + async def test_second_resolution_is_rejected(self): + async def failing(): + raise RuntimeError("boom") + + task = _RecordingTask("t", [("a", failing)]) + engine = StateMachineEngine() + engine.set_error_handler(lambda err: None) + results = [] + + async def resolve_twice(): + while not engine.awaiting_error_action: + await asyncio.sleep(0) + results.append(engine.abort()) + results.append(engine.retry()) + + await asyncio.gather(engine.execute(task), resolve_twice()) + self.assertEqual(results, [True, False]) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/state_machine/golden_frame_support.py b/pylabrobot/agilent/bravo/state_machine/golden_frame_support.py new file mode 100644 index 00000000000..1b87f688acf --- /dev/null +++ b/pylabrobot/agilent/bravo/state_machine/golden_frame_support.py @@ -0,0 +1,279 @@ +"""Shared golden-frame test machinery for the state-machine task layer. + +``testdata/task_golden_frames.json`` holds, for each named scenario, the +ordered sequence of ``{"step", "method", "args"}`` calls a reference +implementation of the state-machine tasks issues to a +:class:`~..controllers.simulation.SimulationController` while running +through :class:`~.engine.StateMachineEngine`. Every golden-frame test module +in this package drives the same task construction through an equivalent +recording :class:`~..controllers.simulation.SimulationController` and +asserts the captured sequence matches the fixture exactly, so a change in +step order, a dropped safe-Z retract, or an altered clearance constant +fails immediately -- a unit test on an individual step method would not +catch a wrong position inside a multi-step sequence the way a full +recorded comparison does. + +This module holds the recorder, task/engine driver, and fixture-comparison +base class every family's golden-frame test module reuses. +""" + +from __future__ import annotations + +import asyncio +import json +import unittest +from dataclasses import asdict, is_dataclass +from enum import Enum +from pathlib import Path +from typing import Any, Callable + +from ..config import BravoMachineConfig +from ..controllers.simulation import SimulationController +from ..deck.teachpoints import Teachpoints +from ..types import ALL_AXES, GripperDetectionState +from .engine import ErrorAction, StateMachineEngine, StateMachineTask + +_GOLDEN_PATH = Path(__file__).parent / "testdata" / "task_golden_frames.json" +with open(_GOLDEN_PATH) as _f: + GOLDEN: dict = json.load(_f) + + +def _jsonable(value: Any) -> Any: + """Recursively convert a captured call argument to a JSON-comparable value.""" + if is_dataclass(value) and not isinstance(value, type): + return {k: _jsonable(v) for k, v in asdict(value).items()} + if isinstance(value, Enum): + return value.name + if isinstance(value, (list, tuple)): + return [_jsonable(v) for v in value] + if isinstance(value, dict): + return {str(k): _jsonable(v) for k, v in value.items()} + return value + + +class RecordingSimulationController(SimulationController): + """A :class:`SimulationController` that logs every interface call in order. + + ``self.calls`` accumulates one ``{"step", "method", "args"}`` dict per + call, in call order, for the controller's lifetime. ``current_step`` is + set by the test driver before invoking each state-machine step, so every + call a step makes is tagged with the step that made it. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.calls: list = [] + self.current_step: str = "" + + def _record(self, method: str, **kwargs: Any) -> None: + self.calls.append( + { + "step": self.current_step, + "method": method, + "args": {k: _jsonable(v) for k, v in kwargs.items()}, + } + ) + + def move(self, moves, wait=True, timeout=30.0): + self._record("move", moves=moves, wait=wait, timeout=timeout) + return super().move(moves, wait=wait, timeout=timeout) + + def home_axes(self, axes, *, force=False): + self._record("home_axes", axes=axes, force=force) + return super().home_axes(axes, force=force) + + def jog(self, params): + self._record("jog", params=params) + return super().jog(params) + + def enable_motor(self, axis): + self._record("enable_motor", axis=axis) + return super().enable_motor(axis) + + def disable_motor(self, axis): + self._record("disable_motor", axis=axis) + return super().disable_motor(axis) + + def reset_faults(self, axes): + self._record("reset_faults", axes=axes) + return super().reset_faults(axes) + + def query_state(self): + self._record("query_state") + return super().query_state() + + def is_go_button_pressed(self): + self._record("is_go_button_pressed") + return super().is_go_button_pressed() + + def clear_go_button(self): + self._record("clear_go_button") + return super().clear_go_button() + + def set_light(self, command): + self._record("set_light", command=command) + return super().set_light(command) + + def clear_lights(self): + self._record("clear_lights") + return super().clear_lights() + + def read_head_adc(self): + self._record("read_head_adc") + return super().read_head_adc() + + def detect_smart_head(self): + self._record("detect_smart_head") + return super().detect_smart_head() + + def read_smart_head_type(self): + self._record("read_smart_head_type") + return super().read_smart_head_type() + + def detect_gripper(self): + self._record("detect_gripper") + return super().detect_gripper() + + def grip(self, speed, position, grip_lid=False): + self._record("grip", speed=speed, position=position, grip_lid=grip_lid) + return super().grip(speed, position, grip_lid=grip_lid) + + def open_gripper(self, position=None): + self._record("open_gripper", position=position) + return super().open_gripper(position) + + def is_plate_in_gripper(self): + self._record("is_plate_in_gripper") + return super().is_plate_in_gripper() + + def read_plate_sensor(self, transient=0.0): + self._record("read_plate_sensor", transient=transient) + return super().read_plate_sensor(transient=transient) + + def scan_stack_with_gripper(self, *, start_zg, end_zg, speed, transient=0.0): + self._record( + "scan_stack_with_gripper", start_zg=start_zg, end_zg=end_zg, speed=speed, transient=transient + ) + return super().scan_stack_with_gripper( + start_zg=start_zg, end_zg=end_zg, speed=speed, transient=transient + ) + + def send_command(self, command_id, data=b"", timeout=2.0): + self._record("send_command", command_id=command_id, data=data.hex(), timeout=timeout) + return super().send_command(command_id, data=data, timeout=timeout) + + def ping(self): + self._record("ping") + return super().ping() + + def get_firmware_version(self): + self._record("get_firmware_version") + return super().get_firmware_version() + + def get_position(self, axis): + self._record("get_position", axis=axis) + return super().get_position(axis) + + def is_axis_homed(self, axis): + self._record("is_axis_homed", axis=axis) + return super().is_axis_homed(axis) + + def get_park_position(self, axis): + self._record("get_park_position", axis=axis) + return super().get_park_position(axis) + + # get_head_type() and ul_to_mm() are deliberately not recorded here: they + # are pure internal queries with no wire or hardware effect, unlike every + # other override above, which corresponds to an actual command or move. + + +def new_controller( + *, all_homed: bool = True, gripper: bool = True +) -> RecordingSimulationController: + """Build a recording controller with every axis in a known homed state.""" + ctrl = RecordingSimulationController() + if not gripper: + ctrl.set_gripper_state(GripperDetectionState.NOT_DETECTED) + axes: list = list(ALL_AXES) if gripper else [a for a in ALL_AXES if a not in ("g", "zg")] + for axis in axes: + ctrl._axes[axis].homed = all_homed + if not all_homed: + ctrl._axes[axis].position = 0.0 + return ctrl + + +def new_config(gripper: bool = True) -> BravoMachineConfig: + config = BravoMachineConfig() + if not gripper: + config.axes = {k: v for k, v in config.axes.items() if k not in ("g", "zg")} + return config + + +def new_teachpoints() -> Teachpoints: + teachpoints = Teachpoints() + teachpoints.set_default_teachpoints("96_d_70") + return teachpoints + + +def _wrap_steps_with_current_step( + task: StateMachineTask, ctrl: RecordingSimulationController +) -> None: + """Tag every call a step makes with that step's name, for fixture grouping.""" + original = task.get_steps + + def wrapped(): + out = [] + for name, fn in original(): + + def make(name=name, fn=fn): + async def _runner(): + ctrl.current_step = name + return await fn() + + return _runner + + out.append((name, make())) + return out + + task.get_steps = wrapped # type: ignore[method-assign] + + +def default_choice(task: StateMachineTask) -> ErrorAction: + """Resolve InitializeTask's W-axis prompt with RETRY; anything else aborts. + + RETRY is chosen (rather than IGNORE) so the fixture captures the fuller + sequence that actually homes W, rather than the shorter skip-W path. + """ + prompt = task._operator_prompt or {} + kind = prompt.get("kind") + if kind == "initialize_home_w_axis": + return ErrorAction.RETRY + raise AssertionError(f"unexpected operator prompt during golden-frame run: {kind}") + + +async def run_task( + task: StateMachineTask, + ctrl: RecordingSimulationController, + choice_fn: Callable[[StateMachineTask], ErrorAction] = default_choice, +) -> dict: + """Run a task to completion through a real engine, returning its captured calls.""" + _wrap_steps_with_current_step(task, ctrl) + engine = StateMachineEngine() + errors: list = [] + engine.set_error_handler(errors.append) + exec_task = asyncio.ensure_future(engine.execute(task)) + while not exec_task.done(): + if engine.awaiting_error_action: + engine.resolve_error(choice_fn(task)) + await asyncio.sleep(0) + await exec_task + return {"status": task.status.name, "calls": ctrl.calls} + + +class GoldenFrameTestCase(unittest.IsolatedAsyncioTestCase): + """Base class: asserts a captured call list against the checked-in fixture.""" + + def assert_matches_golden(self, scenario: str, result: dict) -> None: + expected = GOLDEN[scenario] + self.assertEqual(result["status"], expected["status"], f"{scenario}: status diverges") + self.assertEqual(result["calls"], expected["calls"], f"{scenario}: captured calls diverge") diff --git a/pylabrobot/agilent/bravo/state_machine/initialization_golden_frame_tests.py b/pylabrobot/agilent/bravo/state_machine/initialization_golden_frame_tests.py new file mode 100644 index 00000000000..2c1df00d389 --- /dev/null +++ b/pylabrobot/agilent/bravo/state_machine/initialization_golden_frame_tests.py @@ -0,0 +1,98 @@ +"""Golden-frame tests for InitializeTask, HomeTask, DockGripperTask, and MoveToLocationTask. + +See :mod:`.golden_frame_support` for the recorder, task/engine driver, and +fixture-comparison base class this module reuses. +""" + +from __future__ import annotations + +import unittest + +from .golden_frame_support import ( + GoldenFrameTestCase, + new_config, + new_controller, + new_teachpoints, + run_task, +) +from .tasks import DockGripperTask, HomeTask, InitializeTask, MoveToLocationTask + + +class InitializeTaskGoldenTests(GoldenFrameTestCase): + async def test_cold_start_with_gripper(self): + ctrl = new_controller(all_homed=False, gripper=True) + task = InitializeTask(ctrl, new_config(gripper=True)) + result = await run_task(task, ctrl) + self.assert_matches_golden("initialize_task.initialize_cold_start_with_gripper", result) + + async def test_warm_start_with_gripper(self): + ctrl = new_controller(all_homed=True, gripper=True) + task = InitializeTask(ctrl, new_config(gripper=True)) + result = await run_task(task, ctrl) + self.assert_matches_golden("initialize_task.initialize_warm_start_with_gripper", result) + + async def test_partial_cold_start_w_only(self): + # Only W needs homing; X/Y/Z/G/Zg already homed, and Z sits above the + # safe position -- the only path that reaches + # InitializeTask._move_z_to_safe_position's actual retract move. + ctrl = new_controller(all_homed=True, gripper=True) + ctrl._axes["w"].homed = False + ctrl._axes["z"].position = 50.0 + task = InitializeTask(ctrl, new_config(gripper=True)) + result = await run_task(task, ctrl) + self.assert_matches_golden("initialize_task.initialize_partial_cold_start_w_only", result) + + async def test_cold_start_no_gripper(self): + ctrl = new_controller(all_homed=False, gripper=False) + task = InitializeTask(ctrl, new_config(gripper=False)) + result = await run_task(task, ctrl) + self.assert_matches_golden("initialize_task.initialize_cold_start_no_gripper", result) + + +class HomeTaskGoldenTests(GoldenFrameTestCase): + async def test_home_xyz_cold(self): + ctrl = new_controller(all_homed=False, gripper=True) + axes: list = ["x", "y", "z"] + task = HomeTask(ctrl, new_config(gripper=True), axes) + result = await run_task(task, ctrl) + self.assert_matches_golden("home_task.home_xyz_cold", result) + + async def test_home_all_forced_with_gripper_dock(self): + ctrl = new_controller(all_homed=True, gripper=True) + axes: list = ["x", "y", "z", "w", "g", "zg"] + task = HomeTask(ctrl, new_config(gripper=True), axes, force=True) + result = await run_task(task, ctrl) + self.assert_matches_golden("home_task.home_all_forced_with_gripper_dock", result) + + +class DockGripperTaskGoldenTests(GoldenFrameTestCase): + async def test_dock_gripper_no_plate(self): + ctrl = new_controller(all_homed=True, gripper=True) + task = DockGripperTask(ctrl, new_config(gripper=True)) + result = await run_task(task, ctrl) + self.assert_matches_golden("dock_gripper_task.dock_gripper_no_plate", result) + + async def test_dock_gripper_plate_detected_forced(self): + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_plate_sensor_present(True) + task = DockGripperTask(ctrl, new_config(gripper=True), force_if_plate_detected=True) + result = await run_task(task, ctrl) + self.assert_matches_golden("dock_gripper_task.dock_gripper_plate_detected_forced", result) + + +class MoveToLocationTaskGoldenTests(GoldenFrameTestCase): + async def test_move_to_location_full_with_approach(self): + ctrl = new_controller(all_homed=True, gripper=True) + task = MoveToLocationTask(ctrl, new_teachpoints(), 3, safe_z_position=0.0, approach_height=10.0) + result = await run_task(task, ctrl) + self.assert_matches_golden("move_to_location_task.move_to_location_full_with_approach", result) + + async def test_move_to_location_z_only(self): + ctrl = new_controller(all_homed=True, gripper=True) + task = MoveToLocationTask(ctrl, new_teachpoints(), 3, safe_z_position=0.0, only_move_z=True) + result = await run_task(task, ctrl) + self.assert_matches_golden("move_to_location_task.move_to_location_z_only", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/state_machine/liquid_handling_golden_frame_tests.py b/pylabrobot/agilent/bravo/state_machine/liquid_handling_golden_frame_tests.py new file mode 100644 index 00000000000..ed94104b9ea --- /dev/null +++ b/pylabrobot/agilent/bravo/state_machine/liquid_handling_golden_frame_tests.py @@ -0,0 +1,282 @@ +"""Golden-frame tests for AspirateTask, DispenseTask, and MixTask. + +See :mod:`.golden_frame_support` for the recorder, task/engine driver, and +fixture-comparison base class this module reuses. Every scenario here runs +against a :class:`~..controllers.simulation.SimulationController`, whose W +axis is microlitre-native like the Agile family. Each task combines the +controller's current W position with a converted volume delta itself (see +``_w_axis_motion_value`` in :mod:`.tasks`) before handing the result to a +move; on this microlitre-native controller that conversion is an identity, +so it is exercised here as a no-op. A W move captured against a Darwin +controller would show a millimetre-valued position where these fixtures +show a microlitre-valued one, reflecting that same conversion with a +non-identity factor -- not a capture mismatch. :mod:`.tasks_tests` pins +that Darwin-specific conversion directly, independent of this module's +fixtures. +""" + +from __future__ import annotations + +import unittest + +from ..deck.labware import DeckState, Labware +from ..head_mode import PlateSelection, normalize_head_mode +from .engine import ErrorAction +from .golden_frame_support import GoldenFrameTestCase, new_controller, new_teachpoints, run_task +from .tasks import AspirateTask, DispenseTask, MixTask + + +def _plate() -> Labware: + return Labware( + id="lw-plate96", + name="Test 96-well Plate", + height=14.5, + width=85.5, + length=127.5, + wells=96, + metadata={ + "rows": 8, + "cols": 12, + "spacing_x_mm": 9.0, + "spacing_y_mm": 9.0, + "well_depth_mm": 10.86, + "well_diameter_mm": 6.86, + }, + ) + + +class AspirateTaskGoldenTests(GoldenFrameTestCase): + async def test_simple_fixed_tip_no_labware(self): + # 96_f_50 is a fixed-tip head: no tip-length bookkeeping required. + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_head_type("96_f_50") + task = AspirateTask(ctrl, new_teachpoints(), 3, volume=50.0, head_type="96_f_50") + result = await run_task(task, ctrl) + self.assert_matches_golden("aspirate_task.aspirate_simple_fixed_tip_no_labware", result) + + async def test_full_disposable_with_pre_post_tip_touch(self): + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_head_type("96_d_70") + mode = normalize_head_mode("96_d_70", "all_barrels", None) + task = AspirateTask( + ctrl, + new_teachpoints(), + 5, + volume=50.0, + pre_aspirate_volume=5.0, + post_aspirate_volume=3.0, + tip_touch=True, + head_type="96_d_70", + head_mode=mode, + plate_selection=PlateSelection(location=5, row=0, col=0), + labware=_plate(), + teach_tip_length_mm=26.1, + attached_tip_length_mm=25.0, + tips_on_head=True, + ) + result = await run_task(task, ctrl) + self.assert_matches_golden( + "aspirate_task.aspirate_full_disposable_with_pre_post_tip_touch", result + ) + + async def test_partial_block_with_liquid_class_and_swirl(self): + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_head_type("96_d_70") + mode = normalize_head_mode("96_d_70", "single_barrel", "front_left") + liquid_class = { + "aspirate": { + "w_velocity_ul_s": 25.0, + "w_acceleration_ul_s2": 250.0, + "z_in_velocity_mm_s": 10.0, + "z_in_acceleration_mm_s2": 100.0, + "z_out_velocity_mm_s": 15.0, + "z_out_acceleration_mm_s2": 150.0, + "post_delay_ms": 0, + }, + "equation": {"coefficients": [0.5, 1.02]}, + } + pipette_technique = { + "apply_on_aspirate": True, + "z_phase": "enter", + "radius_mm": 1.0, + "segments": 4, + "clockwise": True, + } + task = AspirateTask( + ctrl, + new_teachpoints(), + 5, + volume=30.0, + dynamic_tip_extension=0.05, + head_type="96_d_70", + head_mode=mode, + plate_selection=PlateSelection(location=5, row=1, col=2), + labware=_plate(), + liquid_class=liquid_class, + pipette_technique=pipette_technique, + teach_tip_length_mm=26.1, + attached_tip_length_mm=26.1, + tips_on_head=True, + ) + result = await run_task(task, ctrl) + self.assert_matches_golden( + "aspirate_task.aspirate_partial_block_with_liquid_class_and_swirl", result + ) + + async def test_blocked_by_neighbor_clearance(self): + # A wide neighboring labware at location 6 overlaps the full-head + # footprint at location 5; its height sits between what the correct + # 2mm neighbor-clearance safety margin allows and what a smaller + # margin would allow, so the move is blocked. + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_head_type("96_f_50") + deck = DeckState() + deck.add(5, Labware(id="target", name="target plate", height=14.5, width=85.5, length=127.5)) + deck.add( + 6, + Labware( + id="wide", + name="wide reservoir", + height=38.0, + width=110.0, + length=290.0, + metadata={"length_mm": 290.0, "width_mm": 110.0, "offset_x_mm": 45.0, "offset_y_mm": 0.0}, + ), + ) + task = AspirateTask( + ctrl, + new_teachpoints(), + 5, + volume=20.0, + head_type="96_f_50", + attached_tip_length_mm=25.0, + deck=deck, + ) + result = await run_task(task, ctrl, choice_fn=lambda t: ErrorAction.ABORT) + self.assert_matches_golden("aspirate_task.aspirate_blocked_by_neighbor_clearance", result) + + async def test_headtype_fallback_probe(self): + # head_type=None on the task, but the controller's own tracked head + # type is 384_d_70 (not 96_d_70), with a subset_config ("back_right") + # whose offset genuinely differs by head geometry between the two. + # This pins that _well_xy's XY offset always resolves against 96_d_70 + # when the task omits head_type, distinct from _effective_head_type() + # (used for Z geometry), which reads the controller's real head type. + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_head_type("384_d_70") + mode = normalize_head_mode("384_d_70", "single_barrel", "back_right") + task = AspirateTask( + ctrl, + new_teachpoints(), + 5, + volume=20.0, + head_type=None, + head_mode=mode, + plate_selection=PlateSelection(location=5, row=1, col=1), + labware=_plate(), + teach_tip_length_mm=19.9, + attached_tip_length_mm=19.9, + tips_on_head=True, + ) + result = await run_task(task, ctrl) + self.assert_matches_golden("aspirate_task.aspirate_headtype_fallback_probe", result) + + +class DispenseTaskGoldenTests(GoldenFrameTestCase): + async def test_simple(self): + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_head_type("96_f_50") + task = DispenseTask(ctrl, new_teachpoints(), 3, volume=50.0, head_type="96_f_50") + result = await run_task(task, ctrl) + self.assert_matches_golden("dispense_task.dispense_simple", result) + + async def test_empty_tips(self): + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_head_type("96_f_50") + task = DispenseTask( + ctrl, new_teachpoints(), 3, volume=50.0, empty_tips=True, head_type="96_f_50" + ) + result = await run_task(task, ctrl) + self.assert_matches_golden("dispense_task.dispense_empty_tips", result) + + async def test_dynamic_retraction_and_blowout_partial_block(self): + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_head_type("96_d_70") + mode = normalize_head_mode("96_d_70", "single_barrel", "back_right") + liquid_class = { + "dispense": { + "w_velocity_ul_s": 20.0, + "w_acceleration_ul_s2": 200.0, + "z_in_velocity_mm_s": 8.0, + "z_in_acceleration_mm_s2": 80.0, + }, + "equation": { + "control_points": [ + {"desired_ul": 0.0, "commanded_ul": 0.0}, + {"desired_ul": 50.0, "commanded_ul": 52.0}, + {"desired_ul": 100.0, "commanded_ul": 103.5}, + ] + }, + } + task = DispenseTask( + ctrl, + new_teachpoints(), + 5, + volume=40.0, + blowout_volume=5.0, + dynamic_tip_retraction=0.02, + tip_touch=True, + head_type="96_d_70", + head_mode=mode, + plate_selection=PlateSelection(location=5, row=2, col=3), + labware=_plate(), + liquid_class=liquid_class, + teach_tip_length_mm=26.1, + attached_tip_length_mm=26.1, + tips_on_head=True, + ) + result = await run_task(task, ctrl) + self.assert_matches_golden( + "dispense_task.dispense_with_dynamic_retraction_and_blowout_partial_block", result + ) + + +class MixTaskGoldenTests(GoldenFrameTestCase): + async def test_basic_same_distance(self): + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_head_type("96_f_50") + task = MixTask(ctrl, new_teachpoints(), 3, volume=30.0, mix_cycles=2, head_type="96_f_50") + result = await run_task(task, ctrl) + self.assert_matches_golden("mix_task.mix_basic_same_distance", result) + + async def test_different_dispense_distance(self): + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_head_type("96_d_70") + mode = normalize_head_mode("96_d_70", "all_barrels", None) + task = MixTask( + ctrl, + new_teachpoints(), + 5, + volume=25.0, + pre_aspirate_volume=2.0, + blowout_volume=1.0, + mix_cycles=3, + aspirate_distance=1.0, + dispense_distance=4.0, + dispense_at_different_distance=True, + dynamic_tip_extension=0.03, + tip_touch=True, + head_type="96_d_70", + head_mode=mode, + plate_selection=PlateSelection(location=5, row=0, col=0), + labware=_plate(), + teach_tip_length_mm=26.1, + attached_tip_length_mm=26.5, + tips_on_head=True, + ) + result = await run_task(task, ctrl) + self.assert_matches_golden("mix_task.mix_different_dispense_distance", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/state_machine/pick_place_golden_frame_tests.py b/pylabrobot/agilent/bravo/state_machine/pick_place_golden_frame_tests.py new file mode 100644 index 00000000000..edde3da5f04 --- /dev/null +++ b/pylabrobot/agilent/bravo/state_machine/pick_place_golden_frame_tests.py @@ -0,0 +1,229 @@ +"""Golden-frame tests for PickPlaceTask, GripperTeachMoveTask, DelidPlateTask, +RelidPlateTask, and ScanStackHeightTask. + +See :mod:`.golden_frame_support` for the recorder, task/engine driver, and +fixture-comparison base class this module reuses. +""" + +from __future__ import annotations + +import unittest + +from ..config import BravoMachineConfig +from ..deck.labware import DeckState, Labware +from .engine import ErrorAction +from .golden_frame_support import ( + GoldenFrameTestCase, + new_config, + new_controller, + new_teachpoints, + run_task, +) +from .tasks import ( + DelidPlateTask, + GripperTeachMoveTask, + PickPlaceTask, + RelidPlateTask, + ScanStackHeightTask, +) + + +def _plate( + id_: str = "lw-plate", + name: str = "Plate", + height: float = 14.5, + stack_height: float = 14.5, + gripper_offset: float = 5.0, + **kwargs, +) -> Labware: + kwargs.setdefault("metadata", {}) + return Labware( + id=id_, + name=name, + height=height, + width=85.5, + length=127.5, + stack_height=stack_height, + gripper_offset=gripper_offset, + wells=96, + **kwargs, + ) + + +def _config() -> BravoMachineConfig: + config = new_config(gripper=True) + config.head.teach_tip_length_mm = 26.1 + return config + + +def _lidded_plate() -> Labware: + metadata = { + "length_mm": 127.5, + "width_mm": 85.5, + "base_height_mm": 14.5, + "lidded_height_mm": 22.0, + "lid_resting_height_mm": 7.5, + "height_mm": 22.0, + } + return Labware( + id="lw-lidded", + name="Lidded Plate", + height=22.0, + width=85.5, + length=127.5, + stack_height=22.0, + gripper_offset=5.0, + wells=96, + is_lidded=True, + metadata=metadata, + ) + + +class PickPlaceTaskGoldenTests(GoldenFrameTestCase): + async def test_basic(self): + ctrl = new_controller(all_homed=True, gripper=True) + config = _config() + deck = DeckState() + deck.add(3, _plate()) + task = PickPlaceTask(ctrl, new_teachpoints(), config, deck, 3, 6, speed="med") + result = await run_task(task, ctrl) + self.assert_matches_golden("pick_place_task.pick_place_basic", result) + + async def test_pickup_verification_failure(self): + ctrl = new_controller(all_homed=True, gripper=True) + config = _config() + deck = DeckState() + deck.add(3, _plate()) + original_grip = ctrl.grip + + def failing_grip(speed, position, grip_lid=False): + ctrl._record("grip", speed=speed, position=position, grip_lid=grip_lid) + ctrl._axes["g"].position = 12.0 # past _PICKUP_FAILURE_G_THRESHOLD_MM + + ctrl.grip = failing_grip # type: ignore[method-assign] + task = PickPlaceTask(ctrl, new_teachpoints(), config, deck, 3, 6, speed="med") + try: + result = await run_task(task, ctrl, choice_fn=lambda t: ErrorAction.ABORT) + finally: + ctrl.grip = original_grip # type: ignore[method-assign] + self.assert_matches_golden("pick_place_task.pick_place_pickup_verification_failure", result) + + async def test_mounted_group(self): + ctrl = new_controller(all_homed=True, gripper=True) + config = _config() + deck = DeckState() + collection = _plate( + id_="lw-collection", + name="Collection Plate", + height=14.5, + stack_height=14.5, + gripper_offset=5.0, + ) + filter_plate = _plate( + id_="lw-filter", + name="Filter Plate", + height=10.0, + stack_height=10.0, + gripper_offset=3.0, + is_mounted=True, + ) + deck.add_mounted_group(3, [filter_plate, collection]) + task = PickPlaceTask(ctrl, new_teachpoints(), config, deck, 3, 6, speed="med") + result = await run_task(task, ctrl) + self.assert_matches_golden("pick_place_task.pick_place_mounted_group", result) + + +class GripperTeachMoveTaskGoldenTests(GoldenFrameTestCase): + async def test_basic(self): + ctrl = new_controller(all_homed=True, gripper=True) + config = _config() + deck = DeckState() + deck.add(3, _plate()) + task = GripperTeachMoveTask(ctrl, new_teachpoints(), config, deck, 3, approach_height=5.0) + result = await run_task(task, ctrl) + self.assert_matches_golden("gripper_teach_move_task.gripper_teach_move_basic", result) + + +class DelidPlateTaskGoldenTests(GoldenFrameTestCase): + async def test_basic(self): + ctrl = new_controller(all_homed=True, gripper=True) + config = _config() + deck = DeckState() + deck.add(3, _lidded_plate()) + task = DelidPlateTask(ctrl, new_teachpoints(), config, deck, 3, 6, speed="med") + result = await run_task(task, ctrl) + self.assert_matches_golden("delid_plate_task.delid_plate_basic", result) + + +class RelidPlateTaskGoldenTests(GoldenFrameTestCase): + async def test_basic(self): + ctrl = new_controller(all_homed=True, gripper=True) + config = _config() + deck = DeckState() + lid_meta = { + "base_class": "lid", + "kind": "lid", + "length_mm": 127.5, + "width_mm": 85.5, + "height_mm": 7.5, + "stack_height_mm": 7.5, + "lid_gripper_offset_mm": 2.0, + } + lid = Labware( + id="lw-lid", + name="Standalone Lid", + height=7.5, + width=85.5, + length=127.5, + stack_height=7.5, + gripper_offset=2.0, + labware_type="lid", + metadata=lid_meta, + ) + deck.add(3, lid) + bare_plate = _plate( + id_="lw-bare", + name="Bare Plate", + height=14.5, + stack_height=14.5, + gripper_offset=5.0, + metadata={"can_have_lid": True}, + ) + deck.add(6, bare_plate) + task = RelidPlateTask(ctrl, new_teachpoints(), config, deck, 3, 6, speed="med") + result = await run_task(task, ctrl) + self.assert_matches_golden("relid_plate_task.relid_plate_basic", result) + + +class ScanStackHeightTaskGoldenTests(GoldenFrameTestCase): + async def test_simulation_completed_count_matches(self): + ctrl = new_controller(all_homed=True, gripper=True) + config = _config() + deck = DeckState() + template = _plate(id_="lw-tmpl", name="Template Plate", height=14.5, stack_height=14.5) + for _ in range(3): + deck.add(3, _plate(id_="lw-stack", name="Stacked Plate", height=14.5, stack_height=14.5)) + task = ScanStackHeightTask( + ctrl, new_teachpoints(), config, deck, location=3, template_labware=template, expected_count=3 + ) + result = await run_task(task, ctrl) + self.assert_matches_golden( + "scan_stack_height_task.scan_simulation_completed_count_matches", result + ) + + async def test_simulation_count_mismatch(self): + ctrl = new_controller(all_homed=True, gripper=True) + config = _config() + deck = DeckState() + template = _plate(id_="lw-tmpl", name="Template Plate", height=14.5, stack_height=14.5) + for _ in range(2): + deck.add(3, _plate(id_="lw-stack", name="Stacked Plate", height=14.5, stack_height=14.5)) + task = ScanStackHeightTask( + ctrl, new_teachpoints(), config, deck, location=3, template_labware=template, expected_count=5 + ) + result = await run_task(task, ctrl, choice_fn=lambda t: ErrorAction.ABORT) + self.assert_matches_golden("scan_stack_height_task.scan_simulation_count_mismatch", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/state_machine/tasks.py b/pylabrobot/agilent/bravo/state_machine/tasks.py new file mode 100644 index 00000000000..1e167a31635 --- /dev/null +++ b/pylabrobot/agilent/bravo/state_machine/tasks.py @@ -0,0 +1,5315 @@ +"""Bravo operations as ordered, resumable state-machine tasks. + +Each task defines an ordered sequence of named async steps that +:class:`~.engine.StateMachineEngine` executes. All motion goes through the +:class:`~..controllers.base.BravoController` interface, so the same task +runs unchanged against real hardware or a simulated one. + +This module implements: :class:`InitializeTask`, :class:`HomeTask`, +:class:`DockGripperTask`, :class:`MoveToLocationTask`, +:class:`AspirateTask`, :class:`DispenseTask`, :class:`MixTask`, +:class:`TipsOnTask`, :class:`TipsOffTask`, :class:`PickPlaceTask`, +:class:`GripperTeachMoveTask`, :class:`DelidPlateTask`, +:class:`RelidPlateTask`, and :class:`ScanStackHeightTask`. +""" + +from __future__ import annotations + +import asyncio +import logging +import math +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union + +from ..config import BravoMachineConfig +from ..controllers.agile_7612 import Agile7612Controller, _home_reg_register +from ..controllers.base import AxisMoveInfo, BravoController, JogParams +from ..controllers.simulation import SimulationController +from ..darwin.controller import DarwinController +from ..deck.geometry import well_center_offset_from_teachpoint_mm +from ..deck.labware import ( + DeckState, + Labware, + generated_lid_metadata, + lid_gripper_offset_mm, + lid_thickness_mm, + synthesize_lid_labware, +) +from ..deck.layout import DeckLayout +from ..deck.teachpoints import Teachpoints +from ..head_mode import ( + HeadMode, + PlateSelection, + TipSelection, + head_anchor_cell, + head_geometry_for_type, + head_mode_offsets_mm, + head_selected_ranges, + selected_anchor_ranges, + tipbox_anchor_cell, +) +from ..protocol.commands import CommandID, LightCommandData +from ..tip_offsets import ResolvedTipOffsets +from ..tips import get_tip_length_mm +from ..types import ( + ALL_AXES, + AXIS_EPSILON, + GRIPPER_THICKNESS, + GRIPPER_TO_BASE_OF_HEAD_GAP, + LT_TIP_CURRENT_TABLE, + OPEN_GRIPPER_POSITION, + ST_TIP_CURRENT_TABLE, + TIPBOX_JOG_TOLERANCE, + Z_CLEARANCE, + Z_SAFE_POSITION_DEFAULT, + Axis, + DeviceStateFlag, + GripperDetectionState, + HeadType, + LightColor, + SpeedLevel, + axis_display_name, + axis_label, + head_type_is_assaymap, + head_type_is_disposable, + head_type_is_fixed, + head_type_is_pintool, + interpolate_tip_current, + safe_home_order, +) +from .engine import ErrorAction, StateMachineTask, TaskStatus + +logger = logging.getLogger(__name__) + +Z_SAFE = Z_SAFE_POSITION_DEFAULT +"""Default safe Z position, in millimetres, used when a task's caller does +not supply one explicitly.""" + +_GRIPPER_RECESS_DEPTH = -20.0 +"""The Zg position, in millimetres, that nests the gripper clear of the deck.""" + +_GRIPPER_OPEN_TOLERANCE_MM = 0.2 +"""Position tolerance, in millimetres, for verifying the gripper reached its +open target.""" + +_LENGTH_DIFFERENCE_96_TO_384 = 0.7 +"""Millimetres added to a fixed-tip head's pick/place Z-solve to correct for +the physical length difference between the 96- and 384-channel fixed-tip +heads.""" + +_PLATE_HANDLING_ZG_MAX = 100.0 +"""The highest Zg a pick/place/scan Z-solve will target, regardless of the +axis's full configured travel.""" + +_SCAN_SENSOR_STANDOFF_MM = 0.0 +"""How far above a plate's top face the gripper's plate sensor fires. + +A property of the gripper, not of the labware, so it is a single constant -- +but it has to be measured on hardware. Scan a location holding a known +number of plates and read ``raw_measured_height_mm`` from the result, which +is the trigger height above the plate pad:: + + standoff = raw - (plate_height + (N - 1) * stack_height) + +Zero means the sensor fires level with the top face. +""" + +_PICK_PLACE_GRIP_TARGET = 9.0 +"""The G-axis position, in millimetres, the gripper closes to when gripping +a plate or lid.""" + +_NO_TIPS_HEAD_PROTRUSION_MM = 15.0 +"""How far the head body protrudes below the gripper's jaws when no tips are +mounted, for head-clearance checking during a carry.""" + +_PICKUP_FAILURE_G_THRESHOLD_MM = 10.0 +"""The G position, in millimetres, at or beyond which a just-completed grip +is judged to have closed on nothing rather than a plate.""" + + +def _stacking_support_height_for_count(count: int, stacking_thickness_mm: float) -> float: + """Return the support height a stack of *count* identical plates presents. + + Args: + count: Number of plates in the stack. + stacking_thickness_mm: How much height one plate adds to the next when + stacked. + + Returns: + ``0.0`` for zero or one plate (nothing is supported below the top + plate); otherwise ``(count - 1) * stacking_thickness_mm``. + """ + count = max(0, int(count)) + if count <= 1: + return 0.0 + return max(0.0, float(count - 1) * float(stacking_thickness_mm)) + + +def _stack_total_height_for_count( + count: int, top_plate_height_mm: float, stacking_thickness_mm: float +) -> float: + """Return the total height of a stack of *count* identical plates. + + Args: + count: Number of plates in the stack. + top_plate_height_mm: The height of one plate. + stacking_thickness_mm: How much height one plate adds to the next when + stacked. + + Returns: + ``0.0`` for zero plates; otherwise the top plate's own height plus the + support height of every plate below it. + """ + count = max(0, int(count)) + if count <= 0: + return 0.0 + return max( + 0.0, + float(top_plate_height_mm) + _stacking_support_height_for_count(count, stacking_thickness_mm), + ) + + +def _infer_stack_count_from_scan_height( + scan_height_mm: float, + stacking_thickness_mm: float, + top_plate_height_mm: float = 0.0, +) -> int: + """Infer how many plates are stacked from the scanned top-of-stack height. + + ``scan_height_mm`` is the height of the TOP of the stack above the + support surface -- i.e. the gripper's descent distance during the scan -- + so it already includes the top plate's own height. The support height + *under* the top plate is therefore ``scan_height_mm - top_plate_height_mm``; + for ``N`` identical plates that equals ``(N - 1) * stacking_thickness``. + Hence:: + + N = round((scan_height - top_plate_height) / stacking_thickness) + 1 + + Subtracting the top plate's height is what makes the count + plate-height-independent: a single plate of any height leaves ~0 support + and resolves to 1. + + Args: + scan_height_mm: The measured top-of-stack height above the support + surface. + stacking_thickness_mm: How much height one plate adds to the next when + stacked. + top_plate_height_mm: The height of the top plate, subtracted before + inferring the count. Defaults to ``0`` for a caller that already + passes a support height rather than a top-of-stack height. + + Returns: + The inferred plate count, at least 1. + """ + if stacking_thickness_mm <= 0.0: + return 1 + support_mm = max(0.0, float(scan_height_mm) - float(top_plate_height_mm)) + return max(1, int(round(support_mm / float(stacking_thickness_mm))) + 1) + + +def _gripper_head_offsets(head_type: HeadType) -> Tuple[float, float]: + """Return the (x, y) gripper-to-head origin offset for a head type. + + Args: + head_type: The installed head type. + + Returns: + ``(-2.25, -2.25)`` for a 384-pitch or 16-channel head, where the + gripper's jaw center does not coincide with the head's nozzle-array + origin; ``(0.0, 0.0)`` for every other head type. + """ + if head_type in {"384_d_70", "384_d_70_s2", "384_f_50", "16_d_st", "384_pintool"}: + return (-2.25, -2.25) + return (0.0, 0.0) + + +@dataclass(frozen=True) +class PickPlacePositions: + """The solved Z/Zg targets for one pick-and-place operation. + + Attributes: + pick_z: Head Z at the source location, during the grip. + pick_zg: Gripper Zg at the source location, during the grip. + carry_z: Head Z while transiting between locations. + carry_zg: Gripper Zg while transiting between locations. + place_z: Head Z at the destination location, during release. + place_zg: Gripper Zg at the destination location, during release. + """ + + pick_z: float + pick_zg: float + carry_z: float + carry_zg: float + place_z: float + place_zg: float + + +_NEIGHBOR_CLEARANCE_SAFETY_MM = 2.0 +"""Millimetres of margin subtracted from a neighbor-clearance check's allowed +top plane, so a move is rejected before it would just barely clip a neighbor.""" + +_DECK_OVERLAP_EPSILON_MM = 1e-6 +"""Millimetre tolerance for treating two footprints as touching rather than +overlapping.""" + +# Subset-collision checks model occupied locations from the taught A1 +# reference to the platepad edges, and head overlap from the head body's +# A1-based envelope rather than just the active nozzle array. +_PLATEPAD_A1_TO_FRONT_MM = 17.12 +_PLATEPAD_A1_TO_LEFT_MM = 13.97 +_PLATEPAD_A1_TO_BACK_MM = 116.10 +_PLATEPAD_A1_TO_RIGHT_MM = 76.96 +_HEAD_BODY_EXTRA_FRONT_MM = _PLATEPAD_A1_TO_FRONT_MM - 2.25 +_HEAD_BODY_EXTRA_BACK_MM = _PLATEPAD_A1_TO_BACK_MM - 105.75 +_HEAD_BODY_EXTRA_LEFT_MM = _PLATEPAD_A1_TO_LEFT_MM - 2.25 +_HEAD_BODY_EXTRA_RIGHT_MM = _PLATEPAD_A1_TO_RIGHT_MM - 69.75 +_HEAD_GRIPPER_BACK_OVERHANG_MM = 44.75 + + +@dataclass(frozen=True) +class LiquidZGeometry: + """The Z-axis geometry of one liquid-handling target: teachpoint, tip, and well. + + Attributes: + teachpoint_z: The location's taught head Z, in millimetres. + teach_tip_length_mm: The tip length the teachpoint was taught with, or + ``None`` for a fixed-tip head. + attached_tip_length_mm: The currently attached tip's measured length, + or ``None`` for a fixed-tip head. + tip_delta_mm: ``teach_tip_length_mm - attached_tip_length_mm``, the + correction applied between a tip-relative Z and a head-relative Z. + ``0`` for a fixed-tip head. + labware_height_mm: The target labware's height, in millimetres. + well_depth_mm: The target well's depth, in millimetres. + top_plane_tip_z: The tip's Z when it is level with the labware's top face. + well_bottom_tip_z: The tip's Z when it is level with the well bottom. + target_tip_z: The tip's Z at the requested distance from the well bottom. + top_plane_head_z: The head's Z when the (implied) tip is level with the + labware's top face. + target_head_z: The head's Z at the requested distance from the well + bottom. + distance_from_bottom_mm: The requested clearance above the well bottom. + """ + + teachpoint_z: float + teach_tip_length_mm: Optional[float] + attached_tip_length_mm: Optional[float] + tip_delta_mm: float + labware_height_mm: float + well_depth_mm: float + top_plane_tip_z: float + well_bottom_tip_z: float + target_tip_z: float + top_plane_head_z: float + target_head_z: float + distance_from_bottom_mm: float + + +def _liquid_labware_height_mm(labware: Optional[Labware]) -> float: + return float(labware.height if labware is not None else 0.0) + + +def _liquid_well_depth_mm(labware: Optional[Labware]) -> float: + if labware is None: + return 0.0 + return float((labware.metadata or {}).get("well_depth_mm") or 0.0) + + +def _build_liquid_z_geometry( + *, + teachpoints: Teachpoints, + location: int, + labware: Optional[Labware], + head_type: HeadType, + teach_tip_length_mm: Optional[float], + attached_tip_length_mm: Optional[float], + tips_on_head: bool, + distance_from_bottom_mm: float, +) -> LiquidZGeometry: + """Compute the Z geometry for a liquid-handling operation at a location. + + Args: + teachpoints: The deck teachpoints to read the location's Z from. + location: The deck location to build geometry for. + labware: The labware at the location, or ``None`` if unknown. + head_type: The installed head type. + teach_tip_length_mm: The tip length the teachpoint was taught with. + attached_tip_length_mm: The currently attached tip's measured length. + tips_on_head: Whether tips are currently on the head. + distance_from_bottom_mm: Requested clearance above the well bottom. + + Returns: + The computed Z geometry. + + Raises: + RuntimeError: For a disposable-tip head, if tips are not on the head or + either tip length is unknown. + """ + teachpoint_z = float(teachpoints.get_teachpoint(location, "z")) + labware_height_mm = _liquid_labware_height_mm(labware) + well_depth_mm = _liquid_well_depth_mm(labware) + attached_length = None if attached_tip_length_mm is None else float(attached_tip_length_mm) + teach_length = None if teach_tip_length_mm is None else float(teach_tip_length_mm) + + tip_delta_mm = 0.0 + if head_type_is_disposable(head_type): + if not tips_on_head: + raise RuntimeError( + f"Liquid handling with disposable head {head_type} requires tips on the head" + ) + if attached_length is None: + raise RuntimeError( + f"Liquid handling with disposable head {head_type} requires a known attached tip length" + ) + if teach_length is None: + raise RuntimeError( + f"Liquid handling with disposable head {head_type} requires a taught tip length" + ) + tip_delta_mm = teach_length - attached_length + + top_plane_tip_z = teachpoint_z - labware_height_mm + well_bottom_tip_z = top_plane_tip_z + well_depth_mm + target_tip_z = well_bottom_tip_z - float(distance_from_bottom_mm) + top_plane_head_z = top_plane_tip_z + tip_delta_mm + target_head_z = target_tip_z + tip_delta_mm + return LiquidZGeometry( + teachpoint_z=teachpoint_z, + teach_tip_length_mm=teach_length, + attached_tip_length_mm=attached_length, + tip_delta_mm=tip_delta_mm, + labware_height_mm=labware_height_mm, + well_depth_mm=well_depth_mm, + top_plane_tip_z=top_plane_tip_z, + well_bottom_tip_z=well_bottom_tip_z, + target_tip_z=target_tip_z, + top_plane_head_z=top_plane_head_z, + target_head_z=target_head_z, + distance_from_bottom_mm=float(distance_from_bottom_mm), + ) + + +def _liquid_geometry_status_payload(geometry: LiquidZGeometry) -> Dict[str, Optional[float]]: + return { + "teachpoint_z": geometry.teachpoint_z, + "teach_tip_length_mm": geometry.teach_tip_length_mm, + "attached_tip_length_mm": geometry.attached_tip_length_mm, + "tip_delta_mm": geometry.tip_delta_mm, + "labware_height_mm": geometry.labware_height_mm, + "well_depth_mm": geometry.well_depth_mm, + "top_plane_tip_z": geometry.top_plane_tip_z, + "well_bottom_tip_z": geometry.well_bottom_tip_z, + "target_tip_z": geometry.target_tip_z, + "top_plane_head_z": geometry.top_plane_head_z, + "target_head_z": geometry.target_head_z, + "distance_from_bottom_mm": geometry.distance_from_bottom_mm, + } + + +def _move_liquid_z_profiled( + ctrl: BravoController, + *, + top_plane_head_z: float, + target_z: float, + velocity: float, + acceleration: float, + phase: str, +) -> None: + """Lower or raise Z through the labware top plane, in two phases. + + Entering (``phase="enter"``) always passes through ``top_plane_head_z`` + first at the controller's default speed, then makes the final approach to + ``target_z`` at the requested (liquid-class) velocity/acceleration -- + the profiled speed is for the controlled final approach into liquid, not + for clearing the labware on the way down. Exiting (``phase="exit"``) is + the mirror: the profiled speed carries the head back up through the top + plane, then a final unprofiled move completes the retract. Skipping + either phase would apply the wrong speed to the wrong segment of travel. + + Args: + ctrl: The controller to move. + top_plane_head_z: The head's Z when level with the labware's top face. + target_z: The final Z target. + velocity: Z velocity for the profiled segment, in mm/s. ``0`` uses the + controller's current setting. + acceleration: Z acceleration for the profiled segment, in mm/s^2. + phase: ``"enter"`` to lower toward the target, ``"exit"`` to raise away + from it. + """ + current_z = float(ctrl.get_position("z")) + if phase == "enter": + if current_z < top_plane_head_z - AXIS_EPSILON: + ctrl.move([_axis_move(ctrl, "z", top_plane_head_z)], wait=True) + current_z = top_plane_head_z + if abs(target_z - current_z) > AXIS_EPSILON: + ctrl.move( + [_axis_move(ctrl, "z", target_z, velocity=velocity, acceleration=acceleration)], + wait=True, + ) + return + if current_z > top_plane_head_z + AXIS_EPSILON: + ctrl.move( + [_axis_move(ctrl, "z", top_plane_head_z, velocity=velocity, acceleration=acceleration)], + wait=True, + ) + current_z = top_plane_head_z + if abs(target_z - current_z) > AXIS_EPSILON: + ctrl.move([_axis_move(ctrl, "z", target_z)], wait=True) + + +def _evaluate_volume_polynomial(coefficients: List[float], volume: float) -> float: + total = 0.0 + for exponent, coefficient in enumerate(coefficients): + total += float(coefficient) * (float(volume) ** exponent) + return total + + +def _interpolate_control_points(points: List[Dict[str, float]], desired_volume: float) -> float: + if not points: + return float(desired_volume) + desired = float(desired_volume) + ordered = sorted( + ( + { + "desired_ul": float(item.get("desired_ul") or 0.0), + "commanded_ul": float(item.get("commanded_ul") or 0.0), + } + for item in points + ), + key=lambda item: item["desired_ul"], + ) + if desired <= ordered[0]["desired_ul"]: + return ordered[0]["commanded_ul"] + for left, right in zip(ordered, ordered[1:]): + if desired <= right["desired_ul"]: + span = right["desired_ul"] - left["desired_ul"] + if span <= 1e-9: + return right["commanded_ul"] + fraction = (desired - left["desired_ul"]) / span + return left["commanded_ul"] + fraction * (right["commanded_ul"] - left["commanded_ul"]) + return ordered[-1]["commanded_ul"] + + +def _simulation_motion_delay(controller: BravoController, segments: int = 1) -> float: + """Return an artificial per-segment delay for a simulated swirl move. + + Args: + controller: The controller executing the move. + segments: The number of segments the caller is about to move through. + + Returns: + A short delay in seconds for a :class:`~..controllers.simulation.SimulationController`, + so a multi-segment swirl is observable rather than instantaneous; ``0`` + for every other controller, which already takes real time to move. + """ + if not isinstance(controller, SimulationController): + return 0.0 + return max(0.0, 0.02 * max(1, int(segments))) + + +def _w_axis_motion_value(controller: BravoController, value_ul: float) -> float: + """Convert a W-axis quantity from microlitres to the controller's native unit. + + Args: + controller: The controller the value is destined for. + value_ul: The quantity, in microlitres (a volume, or a per-second + volume rate for a velocity/acceleration). + + Returns: + ``controller.ul_to_mm(value_ul)``, or ``value_ul`` unconverted if the + controller cannot perform the conversion (e.g. no head type has been + set yet). + """ + try: + return float(controller.ul_to_mm(float(value_ul))) + except Exception: + return float(value_ul) + + +def _axis_move( + controller: BravoController, + axis: Axis, + position: float, + *, + velocity: float = 0.0, + acceleration: float = 0.0, + absolute: bool = True, +) -> AxisMoveInfo: + """Build an :class:`AxisMoveInfo`, converting W-axis rates for the controller. + + ``position`` is never converted here, for any axis: a caller that means a + W-axis position in microlitres converts it itself, at the point that + value is known to be a volume (see callers of ``_w_axis_motion_value`` + below) -- this function cannot tell a volume from a millimetre park + position, and guessing wrong silently sends the wrong distance to the + plunger. ``velocity``/``acceleration`` are different: every caller here + supplies them from a liquid class's ``w_velocity_ul_s``/ + ``w_acceleration_ul_s2`` entries, which are always volume rates + regardless of controller generation, so converting them at this single + boundary is unambiguous. + + Args: + controller: The controller the move is destined for. + axis: The axis to move. + position: Target position, already in the controller's native unit. + velocity: Move velocity. For the W axis, in microlitres/s. + acceleration: Move acceleration. For the W axis, in microlitres/s^2. + absolute: Whether ``position`` is an absolute target. + + Returns: + The move, with a W-axis velocity/acceleration already converted to the + controller's native unit. + """ + move_velocity = float(velocity) + move_acceleration = float(acceleration) + if axis == "w": + move_velocity = _w_axis_motion_value(controller, move_velocity) + move_acceleration = _w_axis_motion_value(controller, move_acceleration) + return AxisMoveInfo( + axis=axis, + position=float(position), + velocity=move_velocity, + acceleration=move_acceleration, + absolute=absolute, + ) + + +def _rectangles_overlap( + a: Tuple[float, float, float, float], + b: Tuple[float, float, float, float], + *, + epsilon: float = _DECK_OVERLAP_EPSILON_MM, +) -> bool: + ax_min, ax_max, ay_min, ay_max = a + bx_min, bx_max, by_min, by_max = b + return not ( + ax_max <= bx_min + epsilon + or bx_max <= ax_min + epsilon + or ay_max <= by_min + epsilon + or by_max <= ay_min + epsilon + ) + + +def _a1_reference_bounds_mm( + origin_x: float, + origin_y: float, + *, + front_mm: float, + back_mm: float, + left_mm: float, + right_mm: float, +) -> Tuple[float, float, float, float]: + return ( + origin_x - front_mm, + origin_x + back_mm, + origin_y - left_mm, + origin_y + right_mm, + ) + + +def _union_bounds_mm( + a: Tuple[float, float, float, float], + b: Tuple[float, float, float, float], +) -> Tuple[float, float, float, float]: + return ( + min(a[0], b[0]), + max(a[1], b[1]), + min(a[2], b[2]), + max(a[3], b[3]), + ) + + +def _occupied_labware_bounds_mm( + teachpoints: Teachpoints, + location: int, + labware: Labware, +) -> Optional[Tuple[float, float, float, float]]: + try: + teach_x = teachpoints.get_teachpoint(location, "x") + teach_y = teachpoints.get_teachpoint(location, "y") + except KeyError: + return None + + # Matches the subset-collision model: the occupied XY area is the + # location platepad/accessory footprint relative to the taught A1 + # reference, not just the placed labware's own body dimensions. + location_bounds = _a1_reference_bounds_mm( + teach_x, + teach_y, + front_mm=_PLATEPAD_A1_TO_FRONT_MM, + back_mm=_PLATEPAD_A1_TO_BACK_MM, + left_mm=_PLATEPAD_A1_TO_LEFT_MM, + right_mm=_PLATEPAD_A1_TO_RIGHT_MM, + ) + + metadata = labware.metadata or {} + length_mm = float(metadata.get("length_mm") or metadata.get("length") or labware.length or 0.0) + width_mm = float(metadata.get("width_mm") or metadata.get("width") or labware.width or 0.0) + offset_x_mm = float(metadata.get("offset_x_mm") or 0.0) + offset_y_mm = float(metadata.get("offset_y_mm") or 0.0) + if length_mm > 0.0 and width_mm > 0.0: + labware_bounds = ( + teach_x - offset_x_mm, + teach_x + max(0.0, length_mm - offset_x_mm), + teach_y - offset_y_mm, + teach_y + max(0.0, width_mm - offset_y_mm), + ) + return _union_bounds_mm(location_bounds, labware_bounds) + return location_bounds + + +def _full_head_footprint_bounds_mm( + head_type: Optional[HeadType], + origin_x: float, + origin_y: float, + *, + gripper_present: bool = True, +) -> Tuple[float, float, float, float]: + geometry = head_geometry_for_type(head_type or "96_d_70") + nozzle_front_mm = geometry.pitch_x_mm / 2.0 + nozzle_back_mm = (geometry.columns - 0.5) * geometry.pitch_x_mm + nozzle_left_mm = geometry.pitch_y_mm / 2.0 + nozzle_right_mm = (geometry.rows - 0.5) * geometry.pitch_y_mm + return _a1_reference_bounds_mm( + origin_x, + origin_y, + front_mm=nozzle_front_mm + _HEAD_BODY_EXTRA_FRONT_MM, + back_mm=nozzle_back_mm + + _HEAD_BODY_EXTRA_BACK_MM + + (_HEAD_GRIPPER_BACK_OVERHANG_MM if gripper_present else 0.0), + left_mm=nozzle_left_mm + _HEAD_BODY_EXTRA_LEFT_MM, + right_mm=nozzle_right_mm + _HEAD_BODY_EXTRA_RIGHT_MM, + ) + + +def _collision_footprint_bounds_mm( + head_type: Optional[HeadType], + head_mode: Optional[HeadMode], + origin_x: float, + origin_y: float, + *, + gripper_present: bool = True, +) -> Optional[Tuple[float, float, float, float]]: + if head_mode is None: + return _full_head_footprint_bounds_mm( + head_type, + origin_x, + origin_y, + gripper_present=gripper_present, + ) + if str(head_mode.subset_type or "all_barrels") == "all_barrels": + return None + return _full_head_footprint_bounds_mm( + head_type, + origin_x, + origin_y, + gripper_present=gripper_present, + ) + + +def _assert_neighbor_clearance( + *, + command_name: str, + teachpoints: Teachpoints, + deck: Optional[DeckState], + head_type: Optional[HeadType], + head_mode: Optional[HeadMode], + target_location: int, + target_x: float, + target_y: float, + allowed_top_plane_mm: float, + gripper_present: bool = True, +) -> List[Dict[str, Union[float, int, str]]]: + """Reject a move whose head footprint overlaps a neighbor taller than allowed. + + A full-barrel-array move never triggers this check (its footprint is + ``None``, meaning "already accounted for by teachpoint spacing"); it only + applies once the head is running a row/column/rectangle/single-barrel + subset, whose narrower footprint can legally sit closer to a tall + neighbor than a full head could. + + Args: + command_name: The operation name, for the raised message. + teachpoints: The deck teachpoints, for each candidate neighbor's XY. + deck: The deck state to check neighbors against. ``None`` skips the + check entirely (no deck model available). + head_type: The installed head type. + head_mode: The active head mode/subset. + target_location: The location being moved to (excluded from the check). + target_x: The move's target X, in millimetres. + target_y: The move's target Y, in millimetres. + allowed_top_plane_mm: The highest neighboring top-of-stack the move + tolerates. + gripper_present: Whether a gripper is installed (widens the footprint's + back overhang). + + Returns: + Every neighbor whose occupied footprint overlaps the move's footprint, + whether or not it was tall enough to block the move. + + Raises: + RuntimeError: If any overlapping neighbor's height meets or exceeds + ``allowed_top_plane_mm``. + """ + if deck is None: + return [] + + footprint = _collision_footprint_bounds_mm( + head_type, + head_mode, + target_x, + target_y, + gripper_present=gripper_present, + ) + if footprint is None: + return [] + overlaps: List[Dict[str, Union[float, int, str]]] = [] + blocking: List[Dict[str, Union[float, int, str]]] = [] + for location in range(1, 10): + if location == target_location: + continue + top_labware = deck.get_stack(location).top + if top_labware is None: + continue + slot_bounds = _occupied_labware_bounds_mm(teachpoints, location, top_labware) + if slot_bounds is None or not _rectangles_overlap(footprint, slot_bounds): + continue + height_mm = float(deck.get_height(location)) + overlap: Dict[str, Union[float, int, str]] = { + "location": location, + "height_mm": height_mm, + } + overlaps.append(overlap) + if height_mm >= allowed_top_plane_mm - _DECK_OVERLAP_EPSILON_MM: + blocking.append(overlap) + + if blocking: + mode_text = "unknown" + if head_mode is not None: + mode_text = ( + f"{head_mode.subset_type} {head_mode.subset_config} " + f"({head_mode.row_count}x{head_mode.column_count})" + ) + details = ", ".join( + f"location {int(item['location'])} top {float(item['height_mm']):.1f} mm" for item in blocking + ) + raise RuntimeError( + f"{command_name} at location {target_location} is blocked: head footprint overlaps " + f"{details}, which meets or exceeds the allowed top plane {allowed_top_plane_mm:.1f} mm " + f"for head mode {mode_text}." + ) + return overlaps + + +def _tip_offsets_or_default( + config: BravoMachineConfig, tip_offsets: Optional[ResolvedTipOffsets] +) -> ResolvedTipOffsets: + """Return the supplied resolved tip offsets, or build them from the safety config. + + Args: + config: The machine configuration to build defaults from. + tip_offsets: An already-resolved (head, tip box) override, or ``None`` + to build one from the configuration's global ``safety.*`` values. + + Returns: + ``tip_offsets`` unchanged if supplied, otherwise a + :class:`~..tip_offsets.ResolvedTipOffsets` built from + ``config.safety.tips_off_z_offset``/``tips_off_w_position`` and the + default press tolerance, with ``matched=False``. + """ + if tip_offsets is not None: + return tip_offsets + safety = config.safety + return ResolvedTipOffsets( + tips_off_z_offset=float(safety.tips_off_z_offset), + tips_off_w_position=float(safety.tips_off_w_position), + tips_on_jog_tolerance=float(TIPBOX_JOG_TOLERANCE), + tips_on_z_offset=0.0, + matched=False, + source="profile defaults", + ) + + +def _normalize_tip_current_table(raw: Optional[Dict[str, Any]]) -> List[Tuple[int, float]]: + """Parse a ``{" tips": amps}``-shaped override into sorted ``(count, amps)`` pairs. + + Args: + raw: The override mapping, or ``None``. + + Returns: + ``(tip_count, current_amps)`` pairs sorted by tip count. A key with no + digits, or a value that cannot convert to a number, is skipped. + """ + table: List[Tuple[int, float]] = [] + for key, value in (raw or {}).items(): + digits = "".join(ch for ch in str(key) if ch.isdigit()) + if not digits: + continue + try: + table.append((int(digits), float(value))) + except (TypeError, ValueError): + continue + table.sort(key=lambda item: item[0]) + return table + + +def _tipbox_rows_cols(metadata: Dict[str, Any]) -> Tuple[int, int]: + """Return a tip box's (rows, cols) grid from its labware metadata. + + Args: + metadata: The tip box labware's metadata dict. + + Returns: + The explicit ``rows``/``cols`` metadata fields if both are positive; + otherwise the standard grid for a 96/384/1536-well box inferred from + ``wells``; otherwise ``(0, 0)``. + """ + rows = int(metadata.get("rows") or 0) + cols = int(metadata.get("cols") or 0) + if rows > 0 and cols > 0: + return rows, cols + wells = int(metadata.get("wells") or 0) + if wells == 96: + return 8, 12 + if wells == 384: + return 16, 24 + if wells == 1536: + return 32, 48 + return rows, cols + + +class InitializeTask(StateMachineTask): + """Cold-start initialization sequence for a Bravo. + + Pings the device, detects the gripper and head, clears faults, and homes + every axis that is not already homed on entry, in the order that keeps + the head and gripper clear of the deck (vertical clearance before any + lateral motion). + """ + + def __init__( + self, controller: BravoController, config: Optional[BravoMachineConfig] = None + ) -> None: + """Initialize the task. + + Args: + controller: The controller to operate. + config: The machine configuration to initialize against. A task + constructed without one (e.g. a bench script or test) gets a + default configuration instead. + """ + super().__init__("Initialize") + self._ctrl = controller + self._config = config if config is not None else BravoMachineConfig() + self._operator_prompt: Optional[dict] = None + self._gripper_present = "g" in self._config.axes and "zg" in self._config.axes + self._force_gripper_present = False + self._w_prompt_acknowledged = False + self._skip_w_home = False + self._plate_in_gripper_ignored = False + self._homed_on_entry: Dict[Axis, bool] = {axis: False for axis in ALL_AXES} + + def _check_head_on_init(self) -> bool: + return bool(self._config.head.check_on_init) + + def _head_type(self) -> HeadType: + return self._config.head.head_type + + def _should_home_w_axis(self) -> bool: + if bool(self._config.safety.ignore_w_axis): + return False + return not head_type_is_pintool(self._head_type()) + + def _should_prompt_home_w_axis(self) -> bool: + return self._should_home_w_axis() and bool(self._config.safety.prompt_home_w) + + def _widest_gripper_open_position(self) -> float: + g_cfg = self._config.axes.get("g") + if g_cfg is None: + return OPEN_GRIPPER_POSITION + return min(float(g_cfg.range.min_pos), OPEN_GRIPPER_POSITION) + + def _gripper_expected(self) -> bool: + return "g" in self._config.axes and "zg" in self._config.axes + + def _axis_needs_home(self, axis: Axis) -> bool: + return not self._homed_on_entry.get(axis, False) + + def _gripper_axes_need_home(self) -> bool: + if not self._gripper_present: + return False + return self._axis_needs_home("g") or self._axis_needs_home("zg") + + def _infer_gripper_present_from_homed_axes(self) -> bool: + axes: Tuple[Axis, ...] = ("g", "zg") + homed_results: Dict[Axis, bool] = {} + for axis in axes: + try: + homed = bool(self._ctrl.is_axis_homed(axis)) + except Exception as exc: + logger.debug( + "Could not read %s homed state while inferring gripper presence: %s", + axis_display_name(axis), + exc, + ) + continue + self._homed_on_entry[axis] = homed + homed_results[axis] = homed + inferred_homed = all(homed_results.get(axis, False) for axis in axes) + if inferred_homed: + logger.warning( + "Gripper detect returned not detected, but both G and Zg already report homed; " + "treating gripper as present." + ) + return inferred_homed + + def _any_axes_need_home(self) -> bool: + axes_to_check: List[Axis] = ["x", "y", "z"] + if self._should_home_w_axis(): + axes_to_check.append("w") + if self._gripper_present: + axes_to_check.extend(["g", "zg"]) + return any(self._axis_needs_home(axis) for axis in axes_to_check) + + def status_payload(self) -> dict: + """Return the task's status, including an initialize-specific tag. + + Returns: + A dict with ``"task": "initialize"`` and an ``"operator_prompt"`` + entry that is ``None`` unless the task is currently failed. + """ + return { + "task": "initialize", + "operator_prompt": None if self.status != TaskStatus.FAILED else self._operator_prompt, + } + + def on_error_action(self, action: ErrorAction) -> None: + """Apply the operator's choice for the step that just failed. + + Args: + action: The action the operator chose. + """ + if self.error is not None and self.error.step_name == "prompt_home_w": + if action == ErrorAction.RETRY: + self._w_prompt_acknowledged = True + self._skip_w_home = False + elif action == ErrorAction.IGNORE: + self._skip_w_home = True + if self.error is not None and self.error.step_name == "handle_plate_in_gripper": + self._plate_in_gripper_ignored = action == ErrorAction.IGNORE + if self.error is not None and self.error.step_name == "detect_gripper": + if action == ErrorAction.IGNORE: + self._force_gripper_present = True + self._gripper_present = True + self._operator_prompt = None + + def get_steps(self) -> "list[tuple[str, Callable[[], Awaitable[None]]]]": + """Return this task's steps, in execution order.""" + return [ + ("ping_device", self._ping_device), + ("set_light_initializing", self._set_light_initializing), + ("query_firmware", self._query_firmware), + ("detect_gripper", self._detect_gripper), + ("get_unique_value", self._get_unique_value), + ("detect_head", self._detect_head), + ("read_home_registers", self._read_home_registers), + ("check_interlock", self._check_interlock), + ("clear_motor_power_fault", self._clear_motor_power_fault), + ("reset_faults", self._reset_faults), + ("move_z_to_safe_position", self._move_z_to_safe_position), + ("home_z", self._home_z), + ("handle_plate_in_gripper", self._handle_plate_in_gripper), + ("home_g", self._home_g), + ("home_zg", self._home_zg), + ("move_zg_to_nesting", self._move_zg_to_nesting), + ("prompt_home_w", self._prompt_home_w), + ("home_w", self._home_w), + ("home_xy", self._home_xy), + ("set_light_idle", self._set_light_idle), + ("finish", self._finish), + ] + + async def _ping_device(self) -> None: + logger.info("Pinging device...") + if not self._ctrl.ping(): + raise RuntimeError("Device did not respond to ping") + + async def _query_firmware(self) -> None: + fw = self._ctrl.get_firmware_version() + logger.info("Firmware: master=%s sub1=%s sub2=%s", fw.master, fw.sub1, fw.sub2) + + async def _set_light_initializing(self) -> None: + self._ctrl.clear_lights() + self._ctrl.set_light( + LightCommandData( + light=LightColor.YELLOW, + period_ms=1000, + duty_cycle=0.8, + ) + ) + + async def _check_interlock(self) -> None: + """Check the robot-disable interlock before proceeding. + + The firmware checks the E-stop bit before any motion. + """ + state = self._ctrl.query_state() + if state & DeviceStateFlag.ROBOT_DISABLE: + raise RuntimeError( + "Robot safety interlock is active (E-stop). Release the interlock and retry." + ) + logger.info("Safety interlock OK") + + async def _clear_motor_power_fault(self) -> None: + """Clear any existing motor power fault. + + ``CLEAR_MOTOR_POWER_FAULT`` is sent during initialization. + """ + try: + self._ctrl.send_command(CommandID.CLEAR_MOTOR_POWER_FAULT) + logger.info("Motor power fault cleared") + except Exception as exc: + logger.warning("Could not clear motor power fault: %s", exc) + + async def _detect_gripper(self) -> None: + if self._force_gripper_present: + self._gripper_present = True + logger.warning("Proceeding with gripper initialization after operator override") + return + + expected = self._gripper_expected() + max_attempts = 3 if expected else 1 + last_error: Optional[Exception] = None + state = GripperDetectionState.NOT_YET_DETECTED + + for attempt in range(1, max_attempts + 1): + try: + state = self._ctrl.detect_gripper() + last_error = None + except Exception as exc: + last_error = exc + logger.warning( + "Gripper-detect attempt %d/%d failed during initialize: %s", + attempt, + max_attempts, + exc, + ) + if attempt < max_attempts: + await asyncio.sleep(0.5) + continue + break + + if state == GripperDetectionState.DETECTED: + break + if state == GripperDetectionState.NOT_DETECTED and expected and attempt < max_attempts: + logger.warning( + "Gripper-detect attempt %d/%d returned not detected during initialize; retrying.", + attempt, + max_attempts, + ) + await asyncio.sleep(0.5) + continue + break + + self._gripper_present = state != GripperDetectionState.NOT_DETECTED + if state == GripperDetectionState.DETECTED: + logger.info("Gripper detected") + elif state == GripperDetectionState.NOT_DETECTED: + if not expected: + logger.info("No gripper detected") + return + if self._infer_gripper_present_from_homed_axes(): + self._gripper_present = True + return + message = ( + "The gripper was not detected on the robot during initialization.\n\n" + "Retry checks for the gripper again.\n" + "Ignore continues assuming the gripper is installed and homes G and Zg anyway.\n" + "Abort cancels initialization." + ) + self._operator_prompt = { + "kind": "initialize_detect_gripper", + "title": "Confirm Gripper Detection", + "message": message, + "choices": ["retry", "ignore", "abort"], + } + raise RuntimeError(message) + else: + if last_error is not None and expected: + message = ( + "The gripper could not be detected reliably during initialization.\n\n" + "Retry checks for the gripper again.\n" + "Ignore continues assuming the gripper is installed and homes G and Zg anyway.\n" + "Abort cancels initialization." + ) + self._operator_prompt = { + "kind": "initialize_detect_gripper", + "title": "Confirm Gripper Detection", + "message": message, + "choices": ["retry", "ignore", "abort"], + } + raise RuntimeError(message) + logger.warning("Gripper detection inconclusive") + + async def _get_unique_value(self) -> None: + logger.info("Skipping unique-value check; on Darwin it is a connectivity validation only") + + async def _detect_head(self) -> None: + if not self._check_head_on_init(): + logger.info("Skipping head detection on init per profile setting") + return + temporarily_reenable_w = False + is_darwin = isinstance(self._ctrl, DarwinController) + should_manage_w = is_darwin and not head_type_is_pintool(self._head_type()) + try: + if should_manage_w: + assert isinstance(self._ctrl, DarwinController) + try: + if self._ctrl.is_motor_enabled("w"): + self._ctrl.disable_motor("w") + temporarily_reenable_w = True + await asyncio.sleep(1.0) + except Exception as exc: + logger.debug("Skipping Darwin W-axis disable during head detection: %s", exc) + + if self._ctrl.detect_smart_head(): + head_code = self._ctrl.read_smart_head_type() + logger.info("Smart head detected, type code=%d", head_code) + else: + adc_value = self._ctrl.read_head_adc() + logger.info("Resistor-based head detection, ADC=%d", adc_value) + finally: + if temporarily_reenable_w: + try: + self._ctrl.enable_motor("w") + except Exception as exc: + logger.warning("Could not re-enable Darwin W axis after head detection: %s", exc) + + async def _read_home_registers(self) -> None: + logger.info("Reading axis homed state before initialize...") + + # The firmware reads config registers (0x03 x5, 0x00) and all + # home_complete registers from the firmware before any homing. These + # reads may be required to put the firmware into a "ready for homing" + # state. + if isinstance(self._ctrl, Agile7612Controller): + for _ in range(5): + try: + self._ctrl._agile_7612_ext_read(0x03, "x") + except Exception: + pass + try: + self._ctrl._agile_7612_ext_read(0x00, "x") + except Exception: + pass + + # Read home_complete registers from firmware (all 6) + for axis in ("x", "y", "z", "w", "g", "zg"): + try: + reg = _home_reg_register(axis) + self._ctrl._agile_7612_agile_read(reg, axis) + except Exception: + pass + + axes_to_check: List[Axis] = ["x", "y", "z", "w"] + if self._gripper_present: + axes_to_check.extend(["g", "zg"]) + for axis in axes_to_check: + try: + self._homed_on_entry[axis] = bool(self._ctrl.is_axis_homed(axis)) + except Exception as exc: + logger.debug( + "Could not read homed state for %s during initialize: %s", axis_display_name(axis), exc + ) + self._homed_on_entry[axis] = False + + async def _reset_faults(self) -> None: + """Reset faults on all axes before homing. + + The firmware resets X, Y, Z, G, and Zg during init. G/Zg faults must be + cleared before gripper-axis motion or homing will be rejected. + """ + all_axes: List[Axis] = ["x", "y", "z"] + if self._should_home_w_axis(): + all_axes.append("w") + if self._gripper_present: + all_axes.extend(["g", "zg"]) + logger.info("Resetting faults on axes: %s", [axis_display_name(a) for a in all_axes]) + self._ctrl.reset_faults(all_axes) + + async def _move_z_to_safe_position(self) -> None: + if not self._any_axes_need_home(): + logger.info("All initialize axes were already homed on entry; skipping safe-Z retract") + return + if not self._homed_on_entry.get("z", False): + return + safe_z = float(self._config.safety.z_safe_position) + current_z = float(self._ctrl.get_position("z")) + if current_z <= safe_z: + return + logger.info("Z already homed; moving to safe position %.3f mm before initialize...", safe_z) + self._ctrl.move([AxisMoveInfo(axis="z", position=safe_z)], wait=True) + + async def _home_z(self) -> None: + if self._homed_on_entry.get("z", False): + logger.info("Skipping Z homing because Z was already homed on entry") + return + logger.info("Homing Z axis...") + self._ctrl.home_axes(["z"]) + + async def _handle_plate_in_gripper(self) -> None: + if not self._gripper_axes_need_home(): + return + has_plate = bool(self._ctrl.is_plate_in_gripper()) + if not has_plate: + return + warning = ( + "There appears to be a plate present in, or in front of, the gripper plate sensor.\n\n" + "Retry checks the sensor again.\n" + "Ignore continues to gripper-axis homing.\n" + "Abort cancels initialization." + ) + if isinstance(self._ctrl, DarwinController): + try: + if self._axis_needs_home("g"): + warning += ( + "\n\nIf a plate is currently held by the gripper, remove it before G homing. " + "On DARWIN, failed G commutation can clamp harder on the plate." + ) + else: + warning += "\n\nAny plate currently held by the gripper will be dropped." + except Exception: + warning += "\n\nIf a plate is currently held by the gripper, remove it before continuing." + self._operator_prompt = { + "kind": "initialize_plate_in_gripper", + "title": "Plate Detected In Gripper", + "message": warning, + "choices": ["retry", "ignore", "abort"], + } + raise RuntimeError(warning) + + async def _home_g(self) -> None: + if not self._gripper_present: + logger.info("Skipping G-axis homing because no gripper is detected") + return + if not self._axis_needs_home("g"): + logger.info("Skipping G-axis homing because G was already homed on entry") + return + if isinstance(self._ctrl, DarwinController): + widest_open = self._widest_gripper_open_position() + logger.info( + "Moving G to the furthest-open initialize position (%.3f mm) before G home...", + widest_open, + ) + try: + self._ctrl.move([AxisMoveInfo(axis="g", position=widest_open)], wait=True) + except Exception as exc: + logger.warning( + "Could not move G to the furthest-open initialize position before homing: %s", exc + ) + try: + logger.info("Retrying G pre-home move at the standard open position (0.000 mm)...") + self._ctrl.move([AxisMoveInfo(axis="g", position=OPEN_GRIPPER_POSITION)], wait=True) + except Exception as fallback_exc: + if self._plate_in_gripper_ignored: + logger.warning( + "Could not move G open before G home after operator ignored the " + "plate-sensor warning; continuing to DARWIN G homing anyway: %s", + fallback_exc, + ) + else: + raise RuntimeError( + "Could not open gripper wide enough to finish initialization" + ) from fallback_exc + else: + logger.info("Skipping pre-home G open move (controller handles pre-move internally)") + logger.info("Homing G axis...") + self._ctrl.home_axes(["g"]) + try: + self._ctrl.disable_motor("g") + except Exception as exc: + logger.debug("Ignoring G-axis disable failure after homing: %s", exc) + + async def _home_zg(self) -> None: + if not self._gripper_present: + logger.info("Skipping Zg-axis homing because no gripper is detected") + return + if self._homed_on_entry.get("zg", False): + logger.info("Skipping Zg-axis homing because Zg was already homed on entry") + return + logger.info("Homing Zg axis...") + self._ctrl.home_axes(["zg"]) + + async def _move_zg_to_nesting(self) -> None: + if not self._gripper_present: + return + if not self._gripper_axes_need_home(): + logger.info("Skipping Zg nesting move because gripper axes were already homed on entry") + return + logger.info("Moving Zg to nesting position (%.3f mm)...", _GRIPPER_RECESS_DEPTH) + self._ctrl.move([AxisMoveInfo(axis="zg", position=_GRIPPER_RECESS_DEPTH)], wait=True) + + async def _prompt_home_w(self) -> None: + if ( + not self._should_prompt_home_w_axis() + or self._w_prompt_acknowledged + or not self._axis_needs_home("w") + ): + return + message = ( + "Please verify that it is safe to home the W-axis (the aspirate/dispense axis).\n\n" + "If there is fluid in the tips, you may want to home W manually over a waste position.\n\n" + "Retry continues with W homing.\n" + "Ignore leaves W unhomed.\n" + "Abort cancels initialization." + ) + self._operator_prompt = { + "kind": "initialize_home_w_axis", + "title": "Confirm W-Axis Home", + "message": message, + "choices": ["retry", "ignore", "abort"], + } + raise RuntimeError(message) + + async def _home_xy(self) -> None: + xy_axes: Tuple[Axis, Axis] = ("x", "y") + axes_to_home: List[Axis] = [axis for axis in xy_axes if self._axis_needs_home(axis)] + if not axes_to_home: + logger.info("Skipping X/Y homing because both axes were already homed on entry") + return + logger.info("Homing %s...", " and ".join(axis_label(axis) for axis in axes_to_home)) + self._ctrl.home_axes(axes_to_home) + + async def _home_w(self) -> None: + if self._skip_w_home: + logger.info("Skipping W-axis homing per operator choice") + return + if not self._should_home_w_axis(): + logger.info("Skipping W-axis homing per profile setting") + return + if self._homed_on_entry.get("w", False): + logger.info("Skipping W-axis homing because W was already homed on entry") + return + logger.info("Homing W axis...") + self._ctrl.home_axes(["w"]) + current_w = float(self._ctrl.get_position("w")) + if abs(current_w) > AXIS_EPSILON: + logger.info( + "Parking W at 0.0 uL after homing (current %.3f uL)...", + current_w, + ) + self._ctrl.move([AxisMoveInfo(axis="w", position=0.0)], wait=True) + + async def _set_light_idle(self) -> None: + self._ctrl.set_light( + LightCommandData( + light=LightColor.GREEN, + period_ms=0, + duty_cycle=1.0, + ) + ) + + async def _finish(self) -> None: + logger.info("Initialization complete") + + +class HomeTask(StateMachineTask): + """Home one or more axes with a safe Z retract first.""" + + def __init__( + self, + controller: BravoController, + config: BravoMachineConfig, + axes: "list[Axis]", + safe_z_position: float = Z_SAFE, + force: bool = False, + ) -> None: + """Initialize the task. + + Args: + controller: The controller to operate. + config: The machine configuration to home against. + axes: The axes to home. + safe_z_position: The Z position to retract to before homing. + force: Re-home an axis that already reports itself homed. + """ + super().__init__("Home") + self._force = force + self._ctrl = controller + self._config = config + self._axes = axes + self._safe_z_position = safe_z_position + self._use_gripper_safe_state = "g" in axes or "zg" in axes + + def get_steps(self) -> "list[tuple[str, Callable[[], Awaitable[None]]]]": + """Return this task's steps, in execution order.""" + return [ + ("safe_z_retract", self._safe_z_retract), + ("prepare_gripper_safe_state", self._prepare_gripper_safe_state), + ("home_requested_axes", self._home_requested_axes), + ("park_homed_axes", self._park_homed_axes), + ("finalize_gripper_safe_state", self._finalize_gripper_safe_state), + ("verify_homed", self._verify_homed), + ] + + async def _safe_z_retract(self) -> None: + if not self._ctrl.is_axis_homed("z"): + logger.info("Z not homed -- skipping safe Z retract") + return + logger.info("Retracting Z to safe position (%.1f mm)...", self._safe_z_position) + self._ctrl.move( + [AxisMoveInfo(axis="z", position=self._safe_z_position)], + wait=True, + ) + + async def _prepare_gripper_safe_state(self) -> None: + if not self._use_gripper_safe_state: + logger.info("Skipping pre-home gripper safe state; gripper axes are not part of this home") + return + if not self._ctrl.is_axis_homed("g") or not self._ctrl.is_axis_homed("zg"): + logger.info("G/Zg not homed -- skipping pre-home gripper safe state") + return + task = DockGripperTask( + self._ctrl, self._config, force_if_plate_detected=True, task_name="HomeDock" + ) + await task._check_plate_sensor() + await task._open_gripper() + await task._move_zg_to_nesting() + + async def _home_requested_axes(self) -> None: + # Vertical clearance before lateral motion. The pre-home Z retract and + # gripper dock above are skipped when those axes are not yet homed -- + # i.e. on a cold start, precisely when the head could be anywhere -- + # so the ordering here is the actual guarantee, not a nicety. + ordered = safe_home_order(self._axes) + names = ", ".join(axis_label(a) for a in ordered) + logger.info("Homing axes: %s", names) + self._ctrl.home_axes(ordered, force=self._force) + + async def _park_homed_axes(self) -> None: + if not self._axes: + return + park_moves: "list[AxisMoveInfo]" = [] + for axis in self._axes: + target = float(self._ctrl.get_park_position(axis)) + park_moves.append(AxisMoveInfo(axis=axis, position=target)) + logger.info( + "Moving homed axes to park positions: %s", + ", ".join(f"{axis_display_name(move.axis)}={move.position:.3f}" for move in park_moves), + ) + self._ctrl.move(park_moves, wait=True) + + async def _finalize_gripper_safe_state(self) -> None: + if not self._use_gripper_safe_state: + return + task = DockGripperTask( + self._ctrl, self._config, force_if_plate_detected=True, task_name="HomeDock" + ) + await task._check_plate_sensor() + await task._open_gripper() + await task._move_zg_to_nesting() + + async def _verify_homed(self) -> None: + for axis in self._axes: + if not self._ctrl.is_axis_homed(axis): + raise RuntimeError(f"{axis_label(axis)} failed to home") + logger.info("All requested axes verified homed") + + +class DockGripperTask(StateMachineTask): + """Open the gripper and move Zg to the recessed nesting position.""" + + def __init__( + self, + controller: BravoController, + config: BravoMachineConfig, + *, + force_if_plate_detected: bool = True, + task_name: str = "DockGripper", + ) -> None: + """Initialize the task. + + Args: + controller: The controller to operate. + config: The machine configuration to dock against. + force_if_plate_detected: Dock even if the plate sensor reports a + plate present, rather than raising. + task_name: The task's display name. + """ + super().__init__(task_name) + self._ctrl = controller + self._config = config + self._force_if_plate_detected = force_if_plate_detected + self._plate_detected = False + self._g_target = OPEN_GRIPPER_POSITION + self._zg_target = self._resolve_zg_target() + + def _resolve_zg_target(self) -> float: + # Docking/nesting uses the absolute recessed Zg position even when the + # configured axis range does not include that negative value. Do not + # clamp to the configured min/max here. + return _GRIPPER_RECESS_DEPTH + + def get_steps(self) -> "list[tuple[str, Callable[[], Awaitable[None]]]]": + """Return this task's steps, in execution order.""" + return [ + ("check_plate_sensor", self._check_plate_sensor), + ("open_gripper", self._open_gripper), + ("move_zg_to_nesting", self._move_zg_to_nesting), + ("verify_gripper_docked", self._verify_gripper_docked), + ] + + async def _check_plate_sensor(self) -> None: + try: + self._plate_detected = bool(self._ctrl.is_plate_in_gripper()) + except Exception as exc: + logger.warning("Failed to read gripper plate sensor during dock: %s", exc) + self._plate_detected = False + return + if self._plate_detected and not self._force_if_plate_detected: + raise RuntimeError("Cannot dock gripper while a plate is detected in the gripper") + if self._plate_detected: + logger.warning("Dock Gripper: plate sensor active, forcing dock per configuration") + + async def _open_gripper(self) -> None: + logger.info("Dock Gripper: opening gripper to G=%.3f...", self._g_target) + self._ctrl.open_gripper() + + async def _move_zg_to_nesting(self) -> None: + zg_velocity = 0.0 + zg_acceleration = 0.0 + zg_cfg = self._config.axes.get("zg") + if zg_cfg is not None and "safe" in zg_cfg.speeds: + speed = zg_cfg.speeds["safe"] + zg_velocity = float(speed.velocity) + zg_acceleration = float(speed.acceleration) + logger.info("Dock Gripper: moving Zg to %.3f...", self._zg_target) + self._ctrl.move( + [ + AxisMoveInfo( + axis="zg", position=self._zg_target, velocity=zg_velocity, acceleration=zg_acceleration + ) + ], + wait=True, + ) + + async def _verify_gripper_docked(self) -> None: + g_actual = float(self._ctrl.get_position("g")) + zg_actual = float(self._ctrl.get_position("zg")) + if abs(g_actual - self._g_target) > _GRIPPER_OPEN_TOLERANCE_MM: + raise RuntimeError( + "Gripper failed to open to safe position. " + f"Target was {self._g_target:.3f} and actual position was {g_actual:.3f}." + ) + if abs(zg_actual - self._zg_target) > 0.5: + raise RuntimeError( + "Gripper failed to reach nesting position. " + f"Target was {self._zg_target:.3f} and actual position was {zg_actual:.3f}." + ) + logger.info("Dock Gripper complete: G=%.3f Zg=%.3f", g_actual, zg_actual) + + +class MoveToLocationTask(StateMachineTask): + """Move the head to a deck location using teachpoints.""" + + def __init__( + self, + controller: BravoController, + teachpoints: Teachpoints, + location: int, + safe_z_position: float = Z_SAFE, + approach_height: float = 0.0, + only_move_z: bool = False, + speed_profiles: Optional[Dict[Axis, Tuple[float, float]]] = None, + ) -> None: + """Initialize the task. + + Args: + controller: The controller to operate. + teachpoints: The deck teachpoints to move against. + location: The deck location to move to. + safe_z_position: The Z position to retract to before any lateral move. + approach_height: Millimetres to stop above the teachpoint's Z before + the final lowering move. ``0`` lowers straight to the teachpoint. + only_move_z: Move only Z (to ``safe_z_position``), skipping the + lateral move and the teachpoint-Z lowering. + speed_profiles: Per-axis ``(velocity, acceleration)`` overrides. An + axis not present uses the controller's current speed setting. + """ + super().__init__(f"MoveToLocation_{location}") + self._ctrl = controller + self._tp = teachpoints + self._location = location + self._safe_z_position = safe_z_position + self._approach_height = approach_height + self._only_move_z = only_move_z + self._speed_profiles = speed_profiles or {} + + def _move_info(self, axis: Axis, position: float) -> AxisMoveInfo: + velocity, acceleration = self._speed_profiles.get(axis, (0.0, 0.0)) + return AxisMoveInfo( + axis=axis, + position=position, + velocity=velocity, + acceleration=acceleration, + ) + + def get_steps(self) -> "list[tuple[str, Callable[[], Awaitable[None]]]]": + """Return this task's steps, in execution order.""" + steps: "list[tuple[str, Callable[[], Awaitable[None]]]]" = [ + ("safe_z_retract", self._safe_z_retract), + ] + if not self._only_move_z: + steps.append(("move_xy_to_teachpoint", self._move_xy)) + if not self._only_move_z or self._safe_z_position != self._target_z(): + steps.append(("lower_z_to_teachpoint", self._lower_z)) + return steps + + async def _safe_z_retract(self) -> None: + logger.info("Retracting Z to safe position...") + self._ctrl.move( + [self._move_info("z", self._safe_z_position)], + wait=True, + ) + + async def _move_xy(self) -> None: + x = self._tp.get_teachpoint(self._location, "x") + y = self._tp.get_teachpoint(self._location, "y") + logger.info("Moving XY to location %d (%.2f, %.2f)...", self._location, x, y) + self._ctrl.move( + [ + self._move_info("x", x), + self._move_info("y", y), + ], + wait=True, + ) + + async def _lower_z(self) -> None: + z = self._target_z() + logger.info("Lowering Z to %.2f mm...", z) + self._ctrl.move( + [self._move_info("z", z)], + wait=True, + ) + + def _target_z(self) -> float: + if self._only_move_z: + return self._safe_z_position + z = self._tp.get_teachpoint(self._location, "z") + return z - self._approach_height if self._approach_height > 0 else z + + +class AspirateTask(StateMachineTask): + """Aspirate a volume at a deck location.""" + + def __init__( + self, + controller: BravoController, + teachpoints: Teachpoints, + location: int, + volume: float, + pre_aspirate_volume: float = 0.0, + post_aspirate_volume: float = 0.0, + distance_from_bottom: float = 1.0, + safe_z_position: float = Z_SAFE, + labware: Optional[Labware] = None, + head_type: Optional[HeadType] = None, + head_mode: Optional[HeadMode] = None, + plate_selection: Optional[PlateSelection] = None, + dynamic_tip_extension: float = 0.0, + tip_touch: bool = False, + liquid_class: Optional[Dict[str, Any]] = None, + pipette_technique: Optional[Dict[str, Any]] = None, + deck: Optional[DeckState] = None, + teach_tip_length_mm: Optional[float] = None, + attached_tip_length_mm: Optional[float] = None, + tips_on_head: bool = False, + ) -> None: + """Initialize the task. + + Args: + controller: The controller to operate. + teachpoints: The deck teachpoints to move against. + location: The deck location to aspirate at. + volume: The volume to aspirate, in microlitres. + pre_aspirate_volume: An air-gap volume drawn above the labware's top + face before descending into liquid, in microlitres. ``0`` skips + the pre-aspirate phase entirely. + post_aspirate_volume: An air-gap volume drawn after retracting above + the labware's top face, in microlitres. ``0`` skips the + post-aspirate phase entirely. + distance_from_bottom: Clearance above the well bottom to aspirate + at, in millimetres. + safe_z_position: The Z position to retract to before and after the + operation. + labware: The labware at the target location. + head_type: The installed head type. Falls back to the controller's + tracked head type when omitted. + head_mode: The active head mode/subset. + plate_selection: The target well's anchor cell. + dynamic_tip_extension: Millimetres the head lowers per corrected + microlitre aspirated, applied alongside the aspirate move. ``0`` + disables it. + tip_touch: Whether to touch the tip against the well wall at four + points after aspirating. + liquid_class: Per-operation motion parameters (velocities, volume + correction, post-delay), keyed by operation name. + pipette_technique: Swirl-technique parameters applied while + entering/exiting the well. + deck: The deck state, for neighbor-clearance checking. ``None`` + skips that check. + teach_tip_length_mm: The tip length the location's teachpoint was + taught with. + attached_tip_length_mm: The currently attached tip's measured length. + tips_on_head: Whether tips are currently on the head. + """ + super().__init__(f"Aspirate_{location}") + self._ctrl = controller + self._tp = teachpoints + self._location = location + self._volume = volume + self._pre_aspirate = pre_aspirate_volume + self._post_aspirate = post_aspirate_volume + self._distance_from_bottom = distance_from_bottom + self._safe_z_position = safe_z_position + self._labware = labware + self._head_type = head_type + self._head_mode = head_mode + self._plate_selection = plate_selection + self._dynamic_tip_extension = max(0.0, float(dynamic_tip_extension)) + self._tip_touch = bool(tip_touch) + self._liquid_class = liquid_class + self._pipette_technique = pipette_technique + self._deck = deck + self._teach_tip_length_mm = teach_tip_length_mm + self._attached_tip_length_mm = attached_tip_length_mm + self._tips_on_head = bool(tips_on_head) + self._live_status: Dict[str, Any] = { + "task": "aspirate", + "location": self._location, + } + self._geometry_cache: Optional[LiquidZGeometry] = None + + def status_payload(self) -> dict: + """Return the task's live status, including its current Z geometry.""" + payload = dict(self._live_status) + try: + payload.update(_liquid_geometry_status_payload(self._geometry())) + except Exception as exc: + payload.setdefault("geometry_error", str(exc)) + base = super().status_payload() + if "operator_prompt" not in payload and base.get("operator_prompt"): + payload["operator_prompt"] = base["operator_prompt"] + return payload + + def _effective_head_type(self) -> HeadType: + return self._head_type or self._ctrl.get_head_type() + + def _geometry(self) -> LiquidZGeometry: + if self._geometry_cache is None: + self._geometry_cache = _build_liquid_z_geometry( + teachpoints=self._tp, + location=self._location, + labware=self._labware, + head_type=self._effective_head_type(), + teach_tip_length_mm=self._teach_tip_length_mm, + attached_tip_length_mm=self._attached_tip_length_mm, + tips_on_head=self._tips_on_head, + distance_from_bottom_mm=self._distance_from_bottom, + ) + return self._geometry_cache + + def _update_status(self, step_name: str, **extra: Any) -> None: + payload: Dict[str, Any] = { + "task": "aspirate", + "location": self._location, + "step_name": step_name, + } + payload.update(_liquid_geometry_status_payload(self._geometry())) + payload.update(extra) + self._live_status = payload + + def get_steps(self) -> "list[tuple[str, Callable[[], Awaitable[None]]]]": + """Return this task's steps, in execution order.""" + return [ + ("safe_z_retract", self._safe_z_retract), + ("move_to_location", self._move_to_location), + ("lower_to_plate_top", self._lower_to_plate_top), + ("pre_aspirate", self._pre_aspirate_step), + ("lower_to_liquid", self._lower_to_liquid), + ("aspirate_volume", self._aspirate_volume), + ("raise_to_plate_top", self._raise_to_plate_top), + ("post_aspirate", self._post_aspirate_step), + ("tip_touch", self._tip_touch_step), + ("retract_z", self._retract_z), + ] + + async def _safe_z_retract(self) -> None: + self._update_status("safe_z_retract") + self._ctrl.move( + [_axis_move(self._ctrl, "z", self._safe_z_position)], + wait=True, + ) + + async def _move_to_location(self) -> None: + self._update_status("move_to_location") + x, y = self._well_xy() + _assert_neighbor_clearance( + command_name="Aspirate", + teachpoints=self._tp, + deck=self._deck, + head_type=self._head_type, + head_mode=self._head_mode, + target_location=self._location, + target_x=x, + target_y=y, + allowed_top_plane_mm=self._target_top_plane(), + ) + logger.info("Moving to location %d for aspiration...", self._location) + self._ctrl.move( + [ + _axis_move(self._ctrl, "x", x), + _axis_move(self._ctrl, "y", y), + ], + wait=True, + ) + + async def _lower_to_plate_top(self) -> None: + if self._pre_aspirate <= 0: + return + self._update_status("lower_to_plate_top") + await self._z_move(self._geometry().top_plane_head_z, phase="enter") + + async def _pre_aspirate_step(self) -> None: + if self._pre_aspirate <= 0: + return + self._update_status("pre_aspirate", pre_aspirate_volume_ul=self._pre_aspirate) + logger.info("Pre-aspirating %.2f uL (air)...", self._pre_aspirate) + current_w = self._ctrl.get_position("w") + self._ctrl.move( + [ + self._w_move( + current_w + _w_axis_motion_value(self._ctrl, self._corrected_volume(self._pre_aspirate)), + operation="aspirate", + ) + ], + wait=True, + ) + + async def _lower_to_liquid(self) -> None: + self._update_status("lower_to_liquid") + await self._z_move(self._target_z(), phase="enter") + + async def _aspirate_volume(self) -> None: + volume = self._corrected_volume(self._volume) + self._update_status("aspirate_volume", commanded_volume_ul=volume) + logger.info("Aspirating %.2f uL...", self._volume) + current_w = self._ctrl.get_position("w") + z_moves: List[AxisMoveInfo] = [] + if self._dynamic_tip_extension > 0 and volume > 0: + current_z = self._ctrl.get_position("z") + z_moves.append(_axis_move(self._ctrl, "z", current_z - self._dynamic_tip_extension)) + self._ctrl.move( + [ + self._w_move(current_w + _w_axis_motion_value(self._ctrl, volume), operation="aspirate"), + *z_moves, + ], + wait=True, + ) + await self._post_delay("aspirate") + + async def _raise_to_plate_top(self) -> None: + if self._post_aspirate <= 0: + return + self._update_status("raise_to_plate_top") + await self._z_move(self._geometry().top_plane_head_z, phase="exit") + + async def _post_aspirate_step(self) -> None: + if self._post_aspirate <= 0: + return + self._update_status("post_aspirate", post_aspirate_volume_ul=self._post_aspirate) + logger.info("Post-aspirating %.2f uL (air)...", self._post_aspirate) + current_w = self._ctrl.get_position("w") + self._ctrl.move( + [ + self._w_move( + current_w + _w_axis_motion_value(self._ctrl, self._corrected_volume(self._post_aspirate)), + operation="aspirate", + ) + ], + wait=True, + ) + + async def _tip_touch_step(self) -> None: + if not self._tip_touch: + return + self._update_status("tip_touch") + await self._perform_tip_touch() + + async def _retract_z(self) -> None: + self._update_status("retract_z") + await self._z_move(self._safe_z_position, phase="exit") + + def _target_z(self) -> float: + return self._geometry().target_head_z + + def _target_top_plane(self) -> float: + tip_length = self._attached_tip_length_mm or 0.0 + if self._deck is not None: + return ( + float(self._deck.get_height(self._location)) + tip_length - _NEIGHBOR_CLEARANCE_SAFETY_MM + ) + return ( + float(self._labware.height if self._labware is not None else 0.0) + + tip_length + - _NEIGHBOR_CLEARANCE_SAFETY_MM + ) + + def _well_xy(self) -> Tuple[float, float]: + teach_x = self._tp.get_teachpoint(self._location, "x") + teach_y = self._tp.get_teachpoint(self._location, "y") + if self._labware is None or self._plate_selection is None or self._head_mode is None: + return teach_x, teach_y + offset_x, offset_y = well_center_offset_from_teachpoint_mm( + self._labware.metadata, + row=int(self._plate_selection.row), + col=int(self._plate_selection.col), + ) + # Distinct from _effective_head_type(): the well-position offset uses + # the 96-channel head geometry whenever no head type is supplied here, + # regardless of which head the controller actually has installed. + head_type = self._head_type or "96_d_70" + head_offset_x, head_offset_y = head_mode_offsets_mm(head_type, self._head_mode) + return teach_x + offset_x - head_offset_x, teach_y + offset_y - head_offset_y + + def _operation_config(self, operation: str) -> Dict[str, Any]: + if not self._liquid_class: + return {} + return dict(self._liquid_class.get(operation, {}) or {}) + + def _corrected_volume(self, volume: float) -> float: + equation = dict((self._liquid_class or {}).get("equation", {}) or {}) + control_points = list(equation.get("control_points") or []) + if control_points: + return max(0.0, _interpolate_control_points(control_points, volume)) + coefficients = list(equation.get("coefficients") or [0.0, 1.0]) + return max(0.0, _evaluate_volume_polynomial(coefficients, volume)) + + def _w_move(self, position_ul: float, *, operation: str) -> AxisMoveInfo: + cfg = self._operation_config(operation) + return _axis_move( + self._ctrl, + "w", + position_ul, + velocity=float(cfg.get("w_velocity_ul_s") or 0.0), + acceleration=float(cfg.get("w_acceleration_ul_s2") or 0.0), + ) + + async def _post_delay(self, operation: str) -> None: + cfg = self._operation_config(operation) + delay = float(cfg.get("post_delay_ms") or 0) / 1000.0 + if delay > 0: + await asyncio.sleep(delay) + + async def _z_move(self, target_z: float, *, phase: str) -> None: + cfg = self._operation_config("aspirate") + key_phase = "in" if phase == "enter" else "out" + velocity = float(cfg.get(f"z_{key_phase}_velocity_mm_s") or 0.0) + acceleration = float(cfg.get(f"z_{key_phase}_acceleration_mm_s2") or 0.0) + swirl_before = ( + phase == "enter" + and self._technique_enabled("aspirate") + and self._technique_phase_allows("enter") + ) + swirl_after = ( + phase == "exit" + and self._technique_enabled("aspirate") + and self._technique_phase_allows("exit") + ) + if swirl_before: + await self._execute_swirl(target_z, phase=phase) + return + if swirl_after: + await self._execute_swirl(float(self._ctrl.get_position("z")), phase=phase) + self._move_z_profiled(target_z, velocity=velocity, acceleration=acceleration, phase=phase) + + def _move_z_profiled( + self, target_z: float, *, velocity: float, acceleration: float, phase: str + ) -> None: + _move_liquid_z_profiled( + self._ctrl, + top_plane_head_z=self._geometry().top_plane_head_z, + target_z=target_z, + velocity=velocity, + acceleration=acceleration, + phase=phase, + ) + + def _technique_enabled(self, operation: str) -> bool: + if not self._pipette_technique: + return False + return bool(self._pipette_technique.get(f"apply_on_{operation}", False)) + + def _technique_phase_allows(self, phase: str) -> bool: + z_phase = str((self._pipette_technique or {}).get("z_phase") or "both") + return z_phase == "both" or z_phase == phase + + def _safe_swirl_radius_mm(self) -> float: + requested = float((self._pipette_technique or {}).get("radius_mm") or 0.0) + if requested <= 0: + return 0.0 + diameter = ( + float((self._labware.metadata or {}).get("well_diameter_mm") or 0.0) if self._labware else 0.0 + ) + if diameter > 0: + return max(0.0, min(requested, max(0.0, diameter / 2.0 - 0.25))) + return min(requested, 0.5) + + async def _execute_swirl(self, target_z: float, *, phase: str) -> None: + radius = self._safe_swirl_radius_mm() + cfg = self._operation_config("aspirate") + key_phase = "in" if phase == "enter" else "out" + velocity = float(cfg.get(f"z_{key_phase}_velocity_mm_s") or 0.0) + acceleration = float(cfg.get(f"z_{key_phase}_acceleration_mm_s2") or 0.0) + self._move_z_profiled(target_z, velocity=velocity, acceleration=acceleration, phase=phase) + if radius <= 0: + return + x_center, y_center = self._well_xy() + segments = max(4, int((self._pipette_technique or {}).get("segments") or 12)) + clockwise = bool((self._pipette_technique or {}).get("clockwise", True)) + for segment in range(segments): + fraction = (segment + 1) / segments + angle = (2.0 * math.pi * fraction) * (-1.0 if clockwise else 1.0) + self._ctrl.move( + [ + _axis_move(self._ctrl, "x", x_center + math.cos(angle) * radius), + _axis_move(self._ctrl, "y", y_center + math.sin(angle) * radius), + ], + wait=True, + ) + delay = _simulation_motion_delay(self._ctrl) + if delay > 0: + await asyncio.sleep(delay) + self._ctrl.move( + [ + _axis_move(self._ctrl, "x", x_center), + _axis_move(self._ctrl, "y", y_center), + ], + wait=True, + ) + + def _tip_touch_radius_mm(self) -> float: + diameter = ( + float((self._labware.metadata or {}).get("well_diameter_mm") or 0.0) if self._labware else 0.0 + ) + if diameter > 0: + return max(0.25, diameter / 2.0 - 0.5) + return 0.5 + + async def _perform_tip_touch(self) -> None: + x_center, y_center = self._well_xy() + radius = self._tip_touch_radius_mm() + for dx, dy in ((radius, 0.0), (0.0, radius), (-radius, 0.0), (0.0, -radius)): + self._ctrl.move( + [ + _axis_move(self._ctrl, "x", x_center + dx), + _axis_move(self._ctrl, "y", y_center + dy), + ], + wait=True, + ) + self._ctrl.move( + [ + _axis_move(self._ctrl, "x", x_center), + _axis_move(self._ctrl, "y", y_center), + ], + wait=True, + ) + + +class DispenseTask(StateMachineTask): + """Dispense a volume at a deck location.""" + + def __init__( + self, + controller: BravoController, + teachpoints: Teachpoints, + location: int, + volume: float, + blowout_volume: float = 0.0, + distance_from_bottom: float = 1.0, + safe_z_position: float = Z_SAFE, + labware: Optional[Labware] = None, + head_type: Optional[HeadType] = None, + head_mode: Optional[HeadMode] = None, + plate_selection: Optional[PlateSelection] = None, + empty_tips: bool = False, + dynamic_tip_retraction: float = 0.0, + tip_touch: bool = False, + liquid_class: Optional[Dict[str, Any]] = None, + pipette_technique: Optional[Dict[str, Any]] = None, + deck: Optional[DeckState] = None, + teach_tip_length_mm: Optional[float] = None, + attached_tip_length_mm: Optional[float] = None, + tips_on_head: bool = False, + ) -> None: + """Initialize the task. + + Args: + controller: The controller to operate. + teachpoints: The deck teachpoints to move against. + location: The deck location to dispense at. + volume: The volume to dispense, in microlitres. + blowout_volume: An extra volume dispensed beyond ``volume`` to clear + the tip, in microlitres. + distance_from_bottom: Clearance above the well bottom to dispense + at, in millimetres. + safe_z_position: The Z position to retract to before and after the + operation. + labware: The labware at the target location. + head_type: The installed head type. Falls back to the controller's + tracked head type when omitted. + head_mode: The active head mode/subset. + plate_selection: The target well's anchor cell. + empty_tips: Dispense the tip's entire remaining contents (drives W + to 0) instead of a fixed volume. + dynamic_tip_retraction: Millimetres the head raises per corrected + microlitre dispensed, applied alongside the dispense move. ``0`` + disables it. + tip_touch: Whether to touch the tip against the well wall at four + points after dispensing. + liquid_class: Per-operation motion parameters (velocities, volume + correction, post-delay), keyed by operation name. + pipette_technique: Swirl-technique parameters applied while + entering/exiting the well. + deck: The deck state, for neighbor-clearance checking. ``None`` + skips that check. + teach_tip_length_mm: The tip length the location's teachpoint was + taught with. + attached_tip_length_mm: The currently attached tip's measured length. + tips_on_head: Whether tips are currently on the head. + """ + super().__init__(f"Dispense_{location}") + self._ctrl = controller + self._tp = teachpoints + self._location = location + self._volume = volume + self._blowout = blowout_volume + self._distance_from_bottom = distance_from_bottom + self._safe_z_position = safe_z_position + self._labware = labware + self._head_type = head_type + self._head_mode = head_mode + self._plate_selection = plate_selection + self._empty_tips = bool(empty_tips) + self._dynamic_tip_retraction = max(0.0, float(dynamic_tip_retraction)) + self._tip_touch = bool(tip_touch) + self._liquid_class = liquid_class + self._pipette_technique = pipette_technique + self._deck = deck + self._teach_tip_length_mm = teach_tip_length_mm + self._attached_tip_length_mm = attached_tip_length_mm + self._tips_on_head = bool(tips_on_head) + self._live_status: Dict[str, Any] = { + "task": "dispense", + "location": self._location, + } + self._geometry_cache: Optional[LiquidZGeometry] = None + + def status_payload(self) -> dict: + """Return the task's live status, including its current Z geometry.""" + payload = dict(self._live_status) + try: + payload.update(_liquid_geometry_status_payload(self._geometry())) + except Exception as exc: + payload.setdefault("geometry_error", str(exc)) + base = super().status_payload() + if "operator_prompt" not in payload and base.get("operator_prompt"): + payload["operator_prompt"] = base["operator_prompt"] + return payload + + def _effective_head_type(self) -> HeadType: + return self._head_type or self._ctrl.get_head_type() + + def _geometry(self) -> LiquidZGeometry: + if self._geometry_cache is None: + self._geometry_cache = _build_liquid_z_geometry( + teachpoints=self._tp, + location=self._location, + labware=self._labware, + head_type=self._effective_head_type(), + teach_tip_length_mm=self._teach_tip_length_mm, + attached_tip_length_mm=self._attached_tip_length_mm, + tips_on_head=self._tips_on_head, + distance_from_bottom_mm=self._distance_from_bottom, + ) + return self._geometry_cache + + def _update_status(self, step_name: str, **extra: Any) -> None: + payload: Dict[str, Any] = { + "task": "dispense", + "location": self._location, + "step_name": step_name, + } + payload.update(_liquid_geometry_status_payload(self._geometry())) + payload.update(extra) + self._live_status = payload + + def get_steps(self) -> "list[tuple[str, Callable[[], Awaitable[None]]]]": + """Return this task's steps, in execution order.""" + return [ + ("safe_z_retract", self._safe_z_retract), + ("move_to_location", self._move_to_location), + ("lower_to_liquid", self._lower_to_liquid), + ("dispense_volume", self._dispense_volume), + ("tip_touch", self._tip_touch_step), + ("retract_z", self._retract_z), + ] + + async def _safe_z_retract(self) -> None: + self._update_status("safe_z_retract") + self._ctrl.move( + [_axis_move(self._ctrl, "z", self._safe_z_position)], + wait=True, + ) + + async def _move_to_location(self) -> None: + self._update_status("move_to_location") + x, y = self._well_xy() + _assert_neighbor_clearance( + command_name="Dispense", + teachpoints=self._tp, + deck=self._deck, + head_type=self._head_type, + head_mode=self._head_mode, + target_location=self._location, + target_x=x, + target_y=y, + allowed_top_plane_mm=self._target_top_plane(), + ) + logger.info("Moving to location %d for dispensing...", self._location) + self._ctrl.move( + [ + _axis_move(self._ctrl, "x", x), + _axis_move(self._ctrl, "y", y), + ], + wait=True, + ) + + async def _lower_to_liquid(self) -> None: + self._update_status("lower_to_liquid") + await self._z_move(self._target_z(), phase="enter") + + async def _dispense_volume(self) -> None: + current_w = self._ctrl.get_position("w") + target_w = self._target_w_after_dispense(current_w) + dispensed_ul = max(0.0, current_w - target_w) + self._update_status("dispense_volume", dispensed_volume_ul=dispensed_ul) + logger.info( + "Emptying tips by dispensing %.2f uL..." if self._empty_tips else "Dispensing %.2f uL...", + dispensed_ul, + ) + z_moves: List[AxisMoveInfo] = [] + if self._dynamic_tip_retraction > 0 and dispensed_ul > 0: + current_z = self._ctrl.get_position("z") + z_moves.append( + _axis_move(self._ctrl, "z", current_z + self._dynamic_tip_retraction * dispensed_ul) + ) + self._ctrl.move([self._w_move(target_w, operation="dispense"), *z_moves], wait=True) + await self._post_delay("dispense") + + async def _tip_touch_step(self) -> None: + if not self._tip_touch: + return + self._update_status("tip_touch") + await self._perform_tip_touch() + + async def _retract_z(self) -> None: + self._update_status("retract_z") + await self._z_move(self._safe_z_position, phase="exit") + + def _target_z(self) -> float: + return self._geometry().target_head_z + + def _target_top_plane(self) -> float: + tip_length = self._attached_tip_length_mm or 0.0 + if self._deck is not None: + return ( + float(self._deck.get_height(self._location)) + tip_length - _NEIGHBOR_CLEARANCE_SAFETY_MM + ) + return ( + float(self._labware.height if self._labware is not None else 0.0) + + tip_length + - _NEIGHBOR_CLEARANCE_SAFETY_MM + ) + + def _well_xy(self) -> Tuple[float, float]: + teach_x = self._tp.get_teachpoint(self._location, "x") + teach_y = self._tp.get_teachpoint(self._location, "y") + if self._labware is None or self._plate_selection is None or self._head_mode is None: + return teach_x, teach_y + offset_x, offset_y = well_center_offset_from_teachpoint_mm( + self._labware.metadata, + row=int(self._plate_selection.row), + col=int(self._plate_selection.col), + ) + # Distinct from _effective_head_type(): the well-position offset uses + # the 96-channel head geometry whenever no head type is supplied here, + # regardless of which head the controller actually has installed. + head_type = self._head_type or "96_d_70" + head_offset_x, head_offset_y = head_mode_offsets_mm(head_type, self._head_mode) + return teach_x + offset_x - head_offset_x, teach_y + offset_y - head_offset_y + + def _operation_config(self, operation: str) -> Dict[str, Any]: + if not self._liquid_class: + return {} + return dict(self._liquid_class.get(operation, {}) or {}) + + def _corrected_volume(self, volume: float) -> float: + equation = dict((self._liquid_class or {}).get("equation", {}) or {}) + control_points = list(equation.get("control_points") or []) + if control_points: + return max(0.0, _interpolate_control_points(control_points, volume)) + coefficients = list(equation.get("coefficients") or [0.0, 1.0]) + return max(0.0, _evaluate_volume_polynomial(coefficients, volume)) + + def _w_move(self, position_ul: float, *, operation: str) -> AxisMoveInfo: + cfg = self._operation_config(operation) + return _axis_move( + self._ctrl, + "w", + position_ul, + velocity=float(cfg.get("w_velocity_ul_s") or 0.0), + acceleration=float(cfg.get("w_acceleration_ul_s2") or 0.0), + ) + + async def _post_delay(self, operation: str) -> None: + cfg = self._operation_config(operation) + delay = float(cfg.get("post_delay_ms") or 0) / 1000.0 + if delay > 0: + await asyncio.sleep(delay) + + async def _z_move(self, target_z: float, *, phase: str) -> None: + cfg = self._operation_config("dispense") + key_phase = "in" if phase == "enter" else "out" + velocity = float(cfg.get(f"z_{key_phase}_velocity_mm_s") or 0.0) + acceleration = float(cfg.get(f"z_{key_phase}_acceleration_mm_s2") or 0.0) + swirl_before = ( + phase == "enter" + and self._technique_enabled("dispense") + and self._technique_phase_allows("enter") + ) + swirl_after = ( + phase == "exit" + and self._technique_enabled("dispense") + and self._technique_phase_allows("exit") + ) + if swirl_before: + await self._execute_swirl(target_z, phase=phase) + return + if swirl_after: + await self._execute_swirl(float(self._ctrl.get_position("z")), phase=phase) + self._move_z_profiled(target_z, velocity=velocity, acceleration=acceleration, phase=phase) + + def _move_z_profiled( + self, target_z: float, *, velocity: float, acceleration: float, phase: str + ) -> None: + _move_liquid_z_profiled( + self._ctrl, + top_plane_head_z=self._geometry().top_plane_head_z, + target_z=target_z, + velocity=velocity, + acceleration=acceleration, + phase=phase, + ) + + def _technique_enabled(self, operation: str) -> bool: + if not self._pipette_technique: + return False + return bool(self._pipette_technique.get(f"apply_on_{operation}", False)) + + def _technique_phase_allows(self, phase: str) -> bool: + z_phase = str((self._pipette_technique or {}).get("z_phase") or "both") + return z_phase == "both" or z_phase == phase + + def _safe_swirl_radius_mm(self) -> float: + requested = float((self._pipette_technique or {}).get("radius_mm") or 0.0) + if requested <= 0: + return 0.0 + diameter = ( + float((self._labware.metadata or {}).get("well_diameter_mm") or 0.0) if self._labware else 0.0 + ) + if diameter > 0: + return max(0.0, min(requested, max(0.0, diameter / 2.0 - 0.25))) + return min(requested, 0.5) + + async def _execute_swirl(self, target_z: float, *, phase: str) -> None: + radius = self._safe_swirl_radius_mm() + cfg = self._operation_config("dispense") + key_phase = "in" if phase == "enter" else "out" + velocity = float(cfg.get(f"z_{key_phase}_velocity_mm_s") or 0.0) + acceleration = float(cfg.get(f"z_{key_phase}_acceleration_mm_s2") or 0.0) + self._move_z_profiled(target_z, velocity=velocity, acceleration=acceleration, phase=phase) + if radius <= 0: + return + x_center, y_center = self._well_xy() + segments = max(4, int((self._pipette_technique or {}).get("segments") or 12)) + clockwise = bool((self._pipette_technique or {}).get("clockwise", True)) + for segment in range(segments): + fraction = (segment + 1) / segments + angle = (2.0 * math.pi * fraction) * (-1.0 if clockwise else 1.0) + self._ctrl.move( + [ + _axis_move(self._ctrl, "x", x_center + math.cos(angle) * radius), + _axis_move(self._ctrl, "y", y_center + math.sin(angle) * radius), + ], + wait=True, + ) + delay = _simulation_motion_delay(self._ctrl) + if delay > 0: + await asyncio.sleep(delay) + self._ctrl.move( + [ + _axis_move(self._ctrl, "x", x_center), + _axis_move(self._ctrl, "y", y_center), + ], + wait=True, + ) + + def _target_w_after_dispense(self, current_w: float) -> float: + if self._empty_tips: + return 0.0 + total = self._corrected_volume(self._volume + self._blowout) + return max(0.0, current_w - _w_axis_motion_value(self._ctrl, total)) + + def _tip_touch_radius_mm(self) -> float: + diameter = ( + float((self._labware.metadata or {}).get("well_diameter_mm") or 0.0) if self._labware else 0.0 + ) + if diameter > 0: + return max(0.25, diameter / 2.0 - 0.5) + return 0.5 + + async def _perform_tip_touch(self) -> None: + x_center, y_center = self._well_xy() + radius = self._tip_touch_radius_mm() + for dx, dy in ((radius, 0.0), (0.0, radius), (-radius, 0.0), (0.0, -radius)): + self._ctrl.move( + [ + _axis_move(self._ctrl, "x", x_center + dx), + _axis_move(self._ctrl, "y", y_center + dy), + ], + wait=True, + ) + self._ctrl.move( + [ + _axis_move(self._ctrl, "x", x_center), + _axis_move(self._ctrl, "y", y_center), + ], + wait=True, + ) + + +class MixTask(StateMachineTask): + """Mix liquid at a deck location using repeated aspirate and dispense strokes.""" + + def __init__( + self, + controller: BravoController, + teachpoints: Teachpoints, + location: int, + volume: float, + pre_aspirate_volume: float = 0.0, + blowout_volume: float = 0.0, + mix_cycles: int = 3, + aspirate_distance: float = 1.0, + dispense_distance: float = 1.0, + dispense_at_different_distance: bool = False, + safe_z_position: float = Z_SAFE, + labware: Optional[Labware] = None, + head_type: Optional[HeadType] = None, + head_mode: Optional[HeadMode] = None, + plate_selection: Optional[PlateSelection] = None, + dynamic_tip_extension: float = 0.0, + tip_touch: bool = False, + liquid_class: Optional[Dict[str, Any]] = None, + pipette_technique: Optional[Dict[str, Any]] = None, + deck: Optional[DeckState] = None, + teach_tip_length_mm: Optional[float] = None, + attached_tip_length_mm: Optional[float] = None, + tips_on_head: bool = False, + ) -> None: + """Initialize the task. + + Args: + controller: The controller to operate. + teachpoints: The deck teachpoints to move against. + location: The deck location to mix at. + volume: The volume drawn and expelled on each mix stroke, in + microlitres. + pre_aspirate_volume: An extra air-gap volume drawn along with + ``volume`` on every aspirate stroke, in microlitres. + blowout_volume: An extra volume dispensed beyond ``volume`` (plus + ``pre_aspirate_volume``) on every dispense stroke, in microlitres. + mix_cycles: Number of aspirate/dispense strokes to perform. + aspirate_distance: Clearance above the well bottom for the aspirate + stroke, in millimetres. + dispense_distance: Clearance above the well bottom for the dispense + stroke, in millimetres, when ``dispense_at_different_distance``. + dispense_at_different_distance: Move to ``dispense_distance`` before + each dispense stroke instead of staying at ``aspirate_distance``. + safe_z_position: The Z position to retract to before and after the + operation. + labware: The labware at the target location. + head_type: The installed head type. Falls back to the controller's + tracked head type when omitted. + head_mode: The active head mode/subset. + plate_selection: The target well's anchor cell. + dynamic_tip_extension: Millimetres the head lowers per corrected + microlitre aspirated on each stroke. ``0`` disables it. + tip_touch: Whether to touch the tip against the well wall at four + points after the final stroke. + liquid_class: Per-operation motion parameters (velocities, volume + correction, post-delay), keyed by operation name. + pipette_technique: Swirl-technique parameters (not applied to mix + strokes themselves; retained for parity with Aspirate/Dispense's + radius/well-diameter tip-touch sizing). + deck: The deck state, for neighbor-clearance checking. ``None`` + skips that check. + teach_tip_length_mm: The tip length the location's teachpoint was + taught with. + attached_tip_length_mm: The currently attached tip's measured length. + tips_on_head: Whether tips are currently on the head. + """ + super().__init__(f"Mix_{location}") + self._ctrl = controller + self._tp = teachpoints + self._location = location + self._volume = float(volume) + self._pre_aspirate = float(pre_aspirate_volume) + self._blowout = float(blowout_volume) + self._mix_cycles = max(1, int(mix_cycles)) + self._aspirate_distance = float(aspirate_distance) + self._dispense_distance = float(dispense_distance) + self._dispense_at_different_distance = bool(dispense_at_different_distance) + self._safe_z_position = safe_z_position + self._labware = labware + self._head_type = head_type + self._head_mode = head_mode + self._plate_selection = plate_selection + self._dynamic_tip_extension = max(0.0, float(dynamic_tip_extension)) + self._tip_touch = bool(tip_touch) + self._liquid_class = liquid_class + self._pipette_technique = pipette_technique + self._deck = deck + self._teach_tip_length_mm = teach_tip_length_mm + self._attached_tip_length_mm = attached_tip_length_mm + self._tips_on_head = bool(tips_on_head) + self._live_status: Dict[str, Any] = { + "task": "mix", + "location": self._location, + } + self._geometry_cache: Dict[float, LiquidZGeometry] = {} + + def status_payload(self) -> dict: + """Return the task's live status, including its current Z geometry.""" + payload = dict(self._live_status) + try: + distance = float(payload.get("distance_from_bottom_mm", self._aspirate_distance)) + payload.update(_liquid_geometry_status_payload(self._geometry(distance))) + except Exception as exc: + payload.setdefault("geometry_error", str(exc)) + base = super().status_payload() + if "operator_prompt" not in payload and base.get("operator_prompt"): + payload["operator_prompt"] = base["operator_prompt"] + return payload + + def _effective_head_type(self) -> HeadType: + return self._head_type or self._ctrl.get_head_type() + + def _geometry(self, distance_from_bottom: float) -> LiquidZGeometry: + key = float(distance_from_bottom) + geometry = self._geometry_cache.get(key) + if geometry is None: + geometry = _build_liquid_z_geometry( + teachpoints=self._tp, + location=self._location, + labware=self._labware, + head_type=self._effective_head_type(), + teach_tip_length_mm=self._teach_tip_length_mm, + attached_tip_length_mm=self._attached_tip_length_mm, + tips_on_head=self._tips_on_head, + distance_from_bottom_mm=key, + ) + self._geometry_cache[key] = geometry + return geometry + + def _top_plane_head_z(self) -> float: + return self._geometry(self._aspirate_distance).top_plane_head_z + + def _update_status( + self, step_name: str, *, distance_from_bottom_mm: Optional[float] = None, **extra: Any + ) -> None: + distance = float( + self._aspirate_distance if distance_from_bottom_mm is None else distance_from_bottom_mm + ) + payload: Dict[str, Any] = { + "task": "mix", + "location": self._location, + "step_name": step_name, + "distance_from_bottom_mm": distance, + } + payload.update(_liquid_geometry_status_payload(self._geometry(distance))) + payload.update(extra) + self._live_status = payload + + def get_steps(self) -> "list[tuple[str, Callable[[], Awaitable[None]]]]": + """Return this task's steps, in execution order.""" + return [ + ("safe_z_retract", self._safe_z_retract), + ("move_to_location", self._move_to_location), + ("mix_cycles", self._mix_cycles_step), + ("tip_touch", self._tip_touch_step), + ("retract_z", self._retract_z), + ] + + async def _safe_z_retract(self) -> None: + self._update_status("safe_z_retract") + self._ctrl.move([_axis_move(self._ctrl, "z", self._safe_z_position)], wait=True) + + async def _move_to_location(self) -> None: + self._update_status("move_to_location") + x, y = self._well_xy() + _assert_neighbor_clearance( + command_name="Mix", + teachpoints=self._tp, + deck=self._deck, + head_type=self._head_type, + head_mode=self._head_mode, + target_location=self._location, + target_x=x, + target_y=y, + allowed_top_plane_mm=self._target_top_plane(), + ) + logger.info("Moving to location %d for mixing...", self._location) + self._ctrl.move( + [_axis_move(self._ctrl, "x", x), _axis_move(self._ctrl, "y", y)], + wait=True, + ) + + async def _mix_cycles_step(self) -> None: + for cycle_index in range(self._mix_cycles): + logger.info("Mix cycle %d/%d...", cycle_index + 1, self._mix_cycles) + aspirate_z = self._target_z(self._aspirate_distance) + self._update_status( + "mix_cycle", + cycle_index=cycle_index + 1, + cycle_count=self._mix_cycles, + operation="aspirate", + distance_from_bottom_mm=self._aspirate_distance, + ) + await self._z_move( + aspirate_z, + operation="aspirate", + phase="enter", + distance_from_bottom=self._aspirate_distance, + ) + await self._aspirate_once() + dispense_z = self._target_z( + self._dispense_distance if self._dispense_at_different_distance else self._aspirate_distance + ) + if abs(dispense_z - self._ctrl.get_position("z")) > 1e-6: + dispense_distance = ( + self._dispense_distance + if self._dispense_at_different_distance + else self._aspirate_distance + ) + self._update_status( + "mix_cycle", + cycle_index=cycle_index + 1, + cycle_count=self._mix_cycles, + operation="dispense", + distance_from_bottom_mm=dispense_distance, + ) + await self._z_move( + dispense_z, + operation="dispense", + phase="enter", + distance_from_bottom=dispense_distance, + ) + await self._dispense_once() + + async def _aspirate_once(self) -> None: + total = self._corrected_volume(self._pre_aspirate + self._volume) + current_w = self._ctrl.get_position("w") + z_moves: List[AxisMoveInfo] = [] + if self._dynamic_tip_extension > 0 and total > 0: + current_z = self._ctrl.get_position("z") + z_moves.append(_axis_move(self._ctrl, "z", current_z - self._dynamic_tip_extension * total)) + self._ctrl.move( + [ + self._w_move(current_w + _w_axis_motion_value(self._ctrl, total), operation="aspirate"), + *z_moves, + ], + wait=True, + ) + await self._post_delay("aspirate") + + async def _dispense_once(self) -> None: + current_w = self._ctrl.get_position("w") + total = self._corrected_volume(self._pre_aspirate + self._volume + self._blowout) + target_w = max(0.0, current_w - _w_axis_motion_value(self._ctrl, total)) + self._ctrl.move([self._w_move(target_w, operation="dispense")], wait=True) + await self._post_delay("dispense") + + async def _tip_touch_step(self) -> None: + if not self._tip_touch: + return + self._update_status("tip_touch") + await self._perform_tip_touch() + + async def _retract_z(self) -> None: + retract_distance = ( + self._dispense_distance if self._dispense_at_different_distance else self._aspirate_distance + ) + self._update_status("retract_z", distance_from_bottom_mm=retract_distance) + await self._z_move( + self._safe_z_position, + operation="dispense", + phase="exit", + distance_from_bottom=retract_distance, + ) + + def _target_z(self, distance_from_bottom: float) -> float: + return self._geometry(distance_from_bottom).target_head_z + + def _target_top_plane(self) -> float: + tip_length = self._attached_tip_length_mm or 0.0 + if self._deck is not None: + return ( + float(self._deck.get_height(self._location)) + tip_length - _NEIGHBOR_CLEARANCE_SAFETY_MM + ) + return ( + float(self._labware.height if self._labware is not None else 0.0) + + tip_length + - _NEIGHBOR_CLEARANCE_SAFETY_MM + ) + + def _well_xy(self) -> Tuple[float, float]: + teach_x = self._tp.get_teachpoint(self._location, "x") + teach_y = self._tp.get_teachpoint(self._location, "y") + if self._labware is None or self._plate_selection is None or self._head_mode is None: + return teach_x, teach_y + offset_x, offset_y = well_center_offset_from_teachpoint_mm( + self._labware.metadata, + row=int(self._plate_selection.row), + col=int(self._plate_selection.col), + ) + # Distinct from _effective_head_type(): the well-position offset uses + # the 96-channel head geometry whenever no head type is supplied here, + # regardless of which head the controller actually has installed. + head_type = self._head_type or "96_d_70" + head_offset_x, head_offset_y = head_mode_offsets_mm(head_type, self._head_mode) + return teach_x + offset_x - head_offset_x, teach_y + offset_y - head_offset_y + + def _operation_config(self, operation: str) -> Dict[str, Any]: + if not self._liquid_class: + return {} + return dict(self._liquid_class.get(operation, {}) or {}) + + def _corrected_volume(self, volume: float) -> float: + equation = dict((self._liquid_class or {}).get("equation", {}) or {}) + control_points = list(equation.get("control_points") or []) + if control_points: + return max(0.0, _interpolate_control_points(control_points, volume)) + coefficients = list(equation.get("coefficients") or [0.0, 1.0]) + return max(0.0, _evaluate_volume_polynomial(coefficients, volume)) + + def _w_move(self, position_ul: float, *, operation: str) -> AxisMoveInfo: + cfg = self._operation_config(operation) + return _axis_move( + self._ctrl, + "w", + position_ul, + velocity=float(cfg.get("w_velocity_ul_s") or 0.0), + acceleration=float(cfg.get("w_acceleration_ul_s2") or 0.0), + ) + + async def _post_delay(self, operation: str) -> None: + cfg = self._operation_config(operation) + delay = float(cfg.get("post_delay_ms") or 0) / 1000.0 + if delay > 0: + await asyncio.sleep(delay) + + async def _z_move( + self, + target_z: float, + *, + operation: str, + phase: str, + distance_from_bottom: float, + ) -> None: + cfg = self._operation_config(operation) + key_phase = "in" if phase == "enter" else "out" + velocity = float(cfg.get(f"z_{key_phase}_velocity_mm_s") or 0.0) + acceleration = float(cfg.get(f"z_{key_phase}_acceleration_mm_s2") or 0.0) + _move_liquid_z_profiled( + self._ctrl, + top_plane_head_z=self._geometry(distance_from_bottom).top_plane_head_z, + target_z=target_z, + velocity=velocity, + acceleration=acceleration, + phase=phase, + ) + + def _tip_touch_radius_mm(self) -> float: + diameter = ( + float((self._labware.metadata or {}).get("well_diameter_mm") or 0.0) if self._labware else 0.0 + ) + if diameter > 0: + return max(0.25, diameter / 2.0 - 0.5) + return 0.5 + + async def _perform_tip_touch(self) -> None: + x_center, y_center = self._well_xy() + radius = self._tip_touch_radius_mm() + for dx, dy in ((radius, 0.0), (0.0, radius), (-radius, 0.0), (0.0, -radius)): + self._ctrl.move( + [ + _axis_move(self._ctrl, "x", x_center + dx), + _axis_move(self._ctrl, "y", y_center + dy), + ], + wait=True, + ) + self._ctrl.move( + [ + _axis_move(self._ctrl, "x", x_center), + _axis_move(self._ctrl, "y", y_center), + ], + wait=True, + ) + + +class TipsOnTask(StateMachineTask): + """Pick up tips from a tip box location using the standard Z math.""" + + def __init__( + self, + controller: BravoController, + teachpoints: Teachpoints, + config: BravoMachineConfig, + labware: Labware, + head_mode: HeadMode, + tip_selection: TipSelection, + tip_location: int, + tip_length_mm: float, + safe_z_position: float = Z_SAFE, + deck: Optional[DeckState] = None, + tip_offsets: Optional[ResolvedTipOffsets] = None, + ) -> None: + """Initialize the task. + + Args: + controller: The controller to operate. + teachpoints: The deck teachpoints to move against. + config: The machine configuration to pick tips up against. + labware: The tip box labware at the target location. + head_mode: The active head mode/subset picking up tips. + tip_selection: The tip box anchor cell to pick up from. + tip_location: The deck location of the tip box. + tip_length_mm: The length of the tip being picked up, in millimetres. + safe_z_position: The Z position to retract to before and after the + operation. + deck: The deck state, for neighbor-clearance checking. ``None`` + skips that check. + tip_offsets: Resolved (head, tip box) Tips On overrides. Falls back + to the machine configuration's safety defaults when omitted. + """ + super().__init__(f"TipsOn_{tip_location}") + self._ctrl = controller + self._tp = teachpoints + self._config = config + self._labware = labware + self._head_mode = head_mode + self._tip_selection = tip_selection + self._tip_location = tip_location + self._safe_z_position = safe_z_position + self._tip_length = float(tip_length_mm) + self._tip_offsets = _tip_offsets_or_default(config, tip_offsets) + self._live_status: Dict[str, object] = {} + self._deck = deck + self._operator_prompt: Optional[dict] = None + + def status_payload(self) -> dict: + """Return the task's live status, including any pending operator prompt.""" + payload = dict(self._live_status) + if self.status == TaskStatus.FAILED and self._operator_prompt: + payload["operator_prompt"] = dict(self._operator_prompt) + return payload + + def on_error_action(self, action: ErrorAction) -> None: + """Clear the pending operator prompt once the operator has responded.""" + self._operator_prompt = None + + def get_steps(self) -> "list[tuple[str, Callable[[], Awaitable[None]]]]": + """Return this task's steps, in execution order.""" + return [ + ("safe_z_retract", self._safe_z_retract), + ("ensure_w_zero", self._ensure_w_zero), + ("move_to_tip_location", self._move_to_tip_location), + ("clear_axis_faults", self._clear_axis_faults), + ("lower_z_to_tips", self._lower_z_to_tips), + ("tip_press_dwell", self._tip_press_dwell), + ("retract_z", self._retract_z), + ] + + async def _safe_z_retract(self) -> None: + self._log_step("safe_z_retract", transfer_stage="source") + self._ctrl.move( + [AxisMoveInfo(axis="z", position=self._safe_z_position)], + wait=True, + ) + + async def _ensure_w_zero(self) -> None: + self._log_step("ensure_w_zero", transfer_stage="source") + current_w = float(self._ctrl.get_position("w")) + if abs(current_w) <= 1e-6: + logger.info("W already at 0.0 uL before tips on.") + return + logger.warning("Resetting W to 0.0 uL before tips on (current %.3f)...", current_w) + self._ctrl.move( + [AxisMoveInfo(axis="w", position=0.0, velocity=0.0, acceleration=0.0)], + wait=True, + ) + + async def _move_to_tip_location(self) -> None: + self._log_step("move_to_tip_location", transfer_stage="source") + x, y = self._tip_xy() + _assert_neighbor_clearance( + command_name="Tips On", + teachpoints=self._tp, + deck=self._deck, + head_type=self._config.head.head_type, + head_mode=self._head_mode, + target_location=self._tip_location, + target_x=x, + target_y=y, + allowed_top_plane_mm=self._target_top_plane(), + ) + self._log_tip_geometry(x, y) + logger.info( + "Moving to tip location %d at subset-adjusted XY (%.3f, %.3f)...", + self._tip_location, + x, + y, + ) + self._ctrl.move( + [ + AxisMoveInfo(axis="x", position=x), + AxisMoveInfo(axis="y", position=y), + ], + wait=True, + ) + + async def _clear_axis_faults(self) -> None: + self._log_step("clear_axis_faults", transfer_stage="source") + try: + axes: List[Axis] = ["x", "y", "z", "w"] + logger.warning( + "Clearing axis faults before tips on press: %s", + ", ".join(axis_display_name(a) for a in axes), + ) + self._ctrl.reset_faults(axes) + except Exception as exc: + logger.warning("Could not clear axis faults before tips on: %s", exc) + if isinstance(self._ctrl, DarwinController): + try: + logger.warning( + "Cycling W motor enable on DARWIN before tips on press to clear any " + "lingering Z/W node fault." + ) + enabled_before = None + try: + enabled_before = self._ctrl.is_motor_enabled("w") + except Exception as exc: + logger.debug("Could not query W motor state before DARWIN tips on press: %s", exc) + try: + self._ctrl.disable_motor("w") + await asyncio.sleep(0.05) + except Exception as exc: + logger.debug( + "Could not disable W before DARWIN tips on press (enabled_before=%s): %s", + enabled_before, + exc, + ) + self._ctrl.enable_motor("w") + except Exception as exc: + logger.warning("Could not recycle W motor enable before DARWIN tips on press: %s", exc) + + async def _lower_z_to_tips(self) -> None: + self._log_step("lower_z_to_tips", transfer_stage="source") + z = self._tips_on_position() + self._tip_offsets.tips_on_z_offset + tolerance = self._tip_offsets.tips_on_jog_tolerance + try: + peak_current = self._tip_press_current() + logger.warning( + "Tips On: %d channels, peak_current=%.3fA, Z target=%.3f (z_offset=%.3f, " + "press tolerance=%.3f mm via %s, head_mode=%s)", + self._num_channels(), + peak_current, + z, + self._tip_offsets.tips_on_z_offset, + tolerance, + self._tip_offsets.source, + self._head_mode.subset_type, + ) + if isinstance(self._ctrl, Agile7612Controller): + self._ctrl.tip_force_jog("z", peak_current, z) + else: + self._ctrl.jog( + JogParams( + axis="z", + velocity=25.0, + acceleration=250.0, + max_position=z, + tolerance=tolerance, + peak_current=peak_current, + ) + ) + except Exception as exc: + await self._recover_to_safe_z_after_press_failure() + message = self._tips_on_failure_message(exc) + self._operator_prompt = { + "kind": "tips_on_no_resistance", + "title": "Tips On failed", + "message": ( + message + "\n\nRetry re-attempts the press.\n" + "Ignore marks tips as on and continues " + "(useful when testing without hardware).\n" + "Abort stops the workflow." + ), + "choices": ["retry", "ignore", "abort"], + "location": self._tip_location, + } + raise RuntimeError(message) from exc + + async def _tip_press_dwell(self) -> None: + self._log_step("tip_press_dwell", transfer_stage="source") + dwell = float(self._config.safety.tip_press_dwell) + if dwell > 0: + logger.info("Dwelling after tips on for %.3f s...", dwell) + await asyncio.sleep(dwell) + + async def _retract_z(self) -> None: + self._log_step("retract_z", transfer_stage="mounted") + logger.info("Retracting Z after tip pickup...") + self._ctrl.move( + [AxisMoveInfo(axis="z", position=self._safe_z_position)], + wait=True, + ) + + async def _recover_to_safe_z_after_press_failure(self) -> None: + try: + logger.warning( + "Tips On press failed; retracting Z to safe position %.3f at current X/Y...", + self._safe_z_position, + ) + self._ctrl.move( + [AxisMoveInfo(axis="z", position=self._safe_z_position)], + wait=True, + ) + except Exception as retract_exc: + logger.error("Could not retract Z after Tips On failure: %s", retract_exc) + + @staticmethod + def _tips_on_failure_message(exc: Exception) -> str: + message = str(exc) + # Matches the wording emitted by both the force-jog sequence + # ("Exceeded destination on Z." / "Unable to reach destination on Z + # within tolerance.") and the older "on the Z axis ..." phrasing. + indicators = ( + "Exceeded destination", + "within tolerance", + ) + if any(indicator in message for indicator in indicators): + return ( + "Tips On did not encounter the expected tip resistance before reaching the press " + "limit. The tipbox may be missing, the selected tips may be absent, or the " + "teachpoint/box height is incorrect. The head was retracted to safe Z." + ) + return message + + def _log_step(self, name: str, *, transfer_stage: str) -> None: + self._live_status = { + "task": "tips_on", + "step": name, + "transfer_stage": transfer_stage, + "location": self._tip_location, + "head_mode": self._head_mode.to_dict(), + "tip_selection": self._tip_selection.to_dict(), + } + + def _tips_on_position(self) -> float: + return self._deck_surface_z() - self._labware.height + + def _target_top_plane(self) -> float: + if self._deck is not None: + return float(self._deck.get_height(self._tip_location)) + return float(self._labware.height) + + def _deck_surface_z(self) -> float: + teach_z = self._tp.get_teachpoint(self._tip_location, "z") + teach_tip_length = float(self._config.head.teach_tip_length_mm or 0.0) + return teach_z + teach_tip_length + + def _num_channels(self) -> int: + return max(1, int(self._head_mode.row_count) * int(self._head_mode.column_count)) + + def _tip_press_current(self) -> float: + profile_limits = self._config.current_limits or {} + table_key = ( + "LT" if self._config.head.head_type in {"8_d_lt", "96_d_200", "96_d_200_s2"} else "ST" + ) + table = _normalize_tip_current_table(profile_limits.get(table_key)) + if not table: + table = LT_TIP_CURRENT_TABLE if table_key == "LT" else ST_TIP_CURRENT_TABLE + return float(interpolate_tip_current(table, self._num_channels())) + + def _tip_xy(self) -> Tuple[float, float]: + teach_x = self._tp.get_teachpoint(self._tip_location, "x") + teach_y = self._tp.get_teachpoint(self._tip_location, "y") + head_offset_x, head_offset_y = head_mode_offsets_mm( + self._config.head.head_type, self._head_mode + ) + tipbox_offset_x, tipbox_offset_y = self._tipbox_selection_anchor_offset() + return teach_x + tipbox_offset_x - head_offset_x, teach_y + tipbox_offset_y - head_offset_y + + def _tipbox_selection_anchor_offset(self) -> Tuple[float, float]: + rows, cols = _tipbox_rows_cols(self._labware.metadata or {}) + if rows <= 0 or cols <= 0: + return 0.0, 0.0 + return well_center_offset_from_teachpoint_mm( + self._labware.metadata, + row=int(self._tip_selection.row), + col=int(self._tip_selection.col), + ) + + def _log_tip_geometry(self, target_x: float, target_y: float) -> None: + rows, cols = _tipbox_rows_cols(self._labware.metadata or {}) + (head_row_start, head_row_stop), (head_col_start, head_col_stop) = head_selected_ranges( + self._config.head.head_type, + self._head_mode, + ) + head_anchor_row, head_anchor_col = head_anchor_cell( + self._config.head.head_type, self._head_mode + ) + (tip_row_start, tip_row_stop), (tip_col_start, tip_col_stop) = selected_anchor_ranges( + rows, + cols, + self._tip_selection, + ) + tip_anchor_row, tip_anchor_col = tipbox_anchor_cell(self._tip_selection) + head_offset_x, head_offset_y = head_mode_offsets_mm( + self._config.head.head_type, self._head_mode + ) + tipbox_offset_x, tipbox_offset_y = self._tipbox_selection_anchor_offset() + logger.warning( + ( + "Tips On geometry: head subset=%s %dx%d config=%s head_rows=%d-%d head_cols=%d-%d " + "head_anchor=(r%d,c%d) head_offset=(%.3f, %.3f) | " + "tipbox_rows=%d-%d tipbox_cols=%d-%d mirror=%s tipbox_anchor=(r%d,c%d) " + "tipbox_offset=(%.3f, %.3f) | target_xy=(%.3f, %.3f)" + ), + self._head_mode.subset_type, + self._head_mode.row_count, + self._head_mode.column_count, + self._head_mode.subset_config, + head_row_start + 1, + head_row_stop, + head_col_start + 1, + head_col_stop, + head_anchor_row + 1, + head_anchor_col + 1, + head_offset_x, + head_offset_y, + tip_row_start + 1, + tip_row_stop, + tip_col_start + 1, + tip_col_stop, + self._tip_selection.mirror_corner, + tip_anchor_row + 1, + tip_anchor_col + 1, + tipbox_offset_x, + tipbox_offset_y, + target_x, + target_y, + ) + + +class TipsOffTask(StateMachineTask): + """Eject tips at a tip box or tip trash location using the standard offsets.""" + + def __init__( + self, + controller: BravoController, + teachpoints: Teachpoints, + config: BravoMachineConfig, + labware: Labware, + head_mode: HeadMode, + tip_selection: Optional[TipSelection], + tip_location: int, + attached_tip_length_mm: float, + safe_z_position: float = Z_SAFE, + deck: Optional[DeckState] = None, + tips_are_tracked: bool = True, + tip_offsets: Optional[ResolvedTipOffsets] = None, + ) -> None: + """Initialize the task. + + Args: + controller: The controller to operate. + teachpoints: The deck teachpoints to move against. + config: The machine configuration to eject tips against. + labware: The tip box or trash labware at the target location. + head_mode: The active head mode/subset ejecting tips. + tip_selection: The tip box anchor cell to return tips to, or ``None`` + for a trash location. + tip_location: The deck location to eject at. + attached_tip_length_mm: The currently attached tip's measured length. + safe_z_position: The Z position to retract to before and after the + operation. + deck: The deck state, for neighbor-clearance checking. ``None`` + skips that check. + tips_are_tracked: Whether the software currently believes tips are on + the head. ``False`` prompts for operator confirmation before + ejecting. + tip_offsets: Resolved (head, tip box) Tips Off overrides. Falls back + to the machine configuration's safety defaults when omitted. + """ + super().__init__("TipsOff") + self._ctrl = controller + self._tp = teachpoints + self._config = config + self._labware = labware + self._head_mode = head_mode + self._tip_selection = tip_selection + self._tip_location = tip_location + self._attached_tip_length_mm = attached_tip_length_mm + self._safe_z_position = safe_z_position + self._tip_offsets = _tip_offsets_or_default(config, tip_offsets) + self._live_status: Dict[str, object] = {} + self._deck = deck + self._tips_are_tracked = tips_are_tracked + + def status_payload(self) -> dict: + """Return the task's live status, including any pending operator prompt.""" + payload = dict(self._live_status) + base = super().status_payload() + if "operator_prompt" not in payload and base.get("operator_prompt"): + payload["operator_prompt"] = base["operator_prompt"] + return payload + + def get_steps(self) -> "list[tuple[str, Callable[[], Awaitable[None]]]]": + """Return this task's steps, in execution order.""" + return [ + ("validate_tips", self._validate_tips), + ("safe_z_retract", self._safe_z_retract), + ("move_to_eject_location", self._move_to_eject_location), + ("tip_touch", self._tip_touch), + ("eject_tips", self._eject_tips), + ("retract_z", self._retract_z), + ] + + async def _validate_tips(self) -> None: + if self._tips_are_tracked: + return + self._operator_prompt = { + "kind": "tips_off_no_tips_tracked", + "title": "No tips detected", + "message": ( + "The software does not think tips are currently on the head. " + "This can happen after a power cycle.\n\n" + "Retry re-checks tip state.\n" + "Ignore proceeds with tip ejection anyway.\n" + "Abort stops the workflow." + ), + "choices": ["retry", "ignore", "abort"], + "location": self._tip_location, + } + raise RuntimeError("No tips are currently tracked on the head") + + async def _tip_touch(self) -> None: + """Bump the tips sideways against the tip box wall to knock off droplets. + + Moves X left by the configured distance, then back to the original + position. + """ + if not self._config.safety.enable_tips_off_tip_touch: + return + self._log_step("tip_touch", transfer_stage="mounted") + current_x = self._ctrl.get_position("x") + x_cfg = self._config.axes.get("x") + ticks_per_mm = float(x_cfg.ticks_per_eng_unit) if x_cfg is not None else 314.96 + bump_mm = ( + float(self._config.safety.tips_off_tip_touch_distance) / ticks_per_mm + if ticks_per_mm > 0 + else 1.0 + ) + bump_target = current_x - bump_mm + logger.info( + "Tips-off tip touch: X %.3f -> %.3f (bump %.3f mm) -> %.3f", + current_x, + bump_target, + bump_mm, + current_x, + ) + self._ctrl.move([AxisMoveInfo(axis="x", position=bump_target)], wait=True) + self._ctrl.move([AxisMoveInfo(axis="x", position=current_x)], wait=True) + + async def _safe_z_retract(self) -> None: + self._log_step("safe_z_retract", transfer_stage="mounted") + self._ctrl.move( + [AxisMoveInfo(axis="z", position=self._safe_z_position)], + wait=True, + ) + + async def _move_to_eject_location(self) -> None: + self._log_step("move_to_eject_location", transfer_stage="mounted") + x, y = self._tip_xy() + _assert_neighbor_clearance( + command_name="Tips Off", + teachpoints=self._tp, + deck=self._deck, + head_type=self._config.head.head_type, + head_mode=self._head_mode, + target_location=self._tip_location, + target_x=x, + target_y=y, + allowed_top_plane_mm=self._target_top_plane(), + ) + logger.info("Moving to tip-eject position (%.2f, %.2f)...", x, y) + self._ctrl.move( + [ + AxisMoveInfo(axis="x", position=x), + AxisMoveInfo(axis="y", position=y), + ], + wait=True, + ) + + async def _eject_tips(self) -> None: + self._log_step("eject_tips", transfer_stage="mounted") + teach_z = self._tp.get_teachpoint(self._tip_location, "z") + teach_tip_length = float(self._config.head.teach_tip_length_mm or 0.0) + deck_surface_z = self._deck_surface_z() + box_top_z = self._tips_on_position() + z = self._tips_off_position() + w_position = self._tip_offsets.tips_off_w_position + # AxisConfig carries no separate "safe" velocity/acceleration fields; + # this move always runs at the controller's current Z setting. + z_velocity = 0.0 + z_acceleration = 0.0 + logger.info( + "Moving Z for tips off: teach Z %.3f, teach tip %.3f, deck surface %.3f, box top " + "%.3f, eject offset %.3f (via %s), target %.3f, safe vel %.3f, safe acc %.3f...", + teach_z, + teach_tip_length, + deck_surface_z, + box_top_z, + self._tip_offsets.tips_off_z_offset, + self._tip_offsets.source, + z, + z_velocity, + z_acceleration, + ) + logger.warning("Calculated Tips Off Z target: %.3f", z) + self._ctrl.move( + [AxisMoveInfo(axis="z", position=z, velocity=z_velocity, acceleration=z_acceleration)], + wait=True, + ) + logger.info("Ejecting tips (W -> %.1f)...", w_position) + self._ctrl.move( + [AxisMoveInfo(axis="w", position=w_position)], + wait=True, + ) + logger.info("Resetting W after tips off...") + self._ctrl.move( + [AxisMoveInfo(axis="w", position=0.0)], + wait=True, + ) + + async def _retract_z(self) -> None: + self._log_step( + "retract_z", transfer_stage="returned" if self._tip_selection is not None else "discarded" + ) + logger.info("Retracting Z after tip ejection...") + self._ctrl.move( + [AxisMoveInfo(axis="z", position=self._safe_z_position)], + wait=True, + ) + + def _log_step(self, name: str, *, transfer_stage: str) -> None: + self._live_status = { + "task": "tips_off", + "step": name, + "transfer_stage": transfer_stage, + "location": self._tip_location, + "head_mode": self._head_mode.to_dict(), + "tip_selection": None if self._tip_selection is None else self._tip_selection.to_dict(), + } + + def _tips_on_position(self) -> float: + return self._deck_surface_z() - self._labware.height + + def _target_top_plane(self) -> float: + tip_length = self._attached_tip_length_mm or 0.0 + if self._deck is not None: + return ( + float(self._deck.get_height(self._tip_location)) + + tip_length + - _NEIGHBOR_CLEARANCE_SAFETY_MM + ) + return float(self._labware.height) + tip_length - _NEIGHBOR_CLEARANCE_SAFETY_MM + + def _deck_surface_z(self) -> float: + teach_z = self._tp.get_teachpoint(self._tip_location, "z") + teach_tip_length = float(self._config.head.teach_tip_length_mm or 0.0) + return teach_z + teach_tip_length + + def _tips_off_position(self) -> float: + tips_on_position = self._tips_on_position() + return tips_on_position - float(self._tip_offsets.tips_off_z_offset) + + def _tip_xy(self) -> Tuple[float, float]: + teach_x = self._tp.get_teachpoint(self._tip_location, "x") + teach_y = self._tp.get_teachpoint(self._tip_location, "y") + head_offset_x, head_offset_y = head_mode_offsets_mm( + self._config.head.head_type, self._head_mode + ) + tipbox_offset_x, tipbox_offset_y = self._tipbox_selection_anchor_offset() + return teach_x + tipbox_offset_x - head_offset_x, teach_y + tipbox_offset_y - head_offset_y + + def _tipbox_selection_anchor_offset(self) -> Tuple[float, float]: + if self._tip_selection is None: + return 0.0, 0.0 + rows, cols = _tipbox_rows_cols(self._labware.metadata or {}) + if rows <= 0 or cols <= 0: + return 0.0, 0.0 + return well_center_offset_from_teachpoint_mm( + self._labware.metadata, + row=int(self._tip_selection.row), + col=int(self._tip_selection.col), + ) + + +class PickPlaceTask(StateMachineTask): + """Move a plate from one deck location to another using the gripper.""" + + def __init__( + self, + controller: BravoController, + teachpoints: Teachpoints, + config: BravoMachineConfig, + deck: DeckState, + from_location: int, + to_location: int, + speed: SpeedLevel = "med", + plate_already_gripped: bool = False, + ) -> None: + """Initialize the task. + + Args: + controller: The controller to operate. + teachpoints: The deck teachpoints to move against. + config: The machine configuration to move against. + deck: The deck state, updated on a successful place. + from_location: The deck location to pick up from. + to_location: The deck location to place at. + speed: The speed profile for XYZ/Zg motion. + plate_already_gripped: Construct the task as if its own grip step had + already run successfully. For a caller that split a pick and a + place across two separate task instances (see + :mod:`~..bravo`'s gripper_pick/gripper_place), the plate was + physically gripped by the earlier instance; this lets the later + one's place-side steps (which otherwise skip themselves absent a + verified grip) run without re-gripping an already-held plate. + """ + super().__init__(f"PickPlace_{from_location}_{to_location}") + self._ctrl = controller + self._tp = teachpoints + self._config = config + self._deck = deck + self._from_location = from_location + self._to_location = to_location + self._speed = speed + self._live_status: Dict[str, Any] = {} + self._grip_attempts = 1 if plate_already_gripped else 0 + self._plate_pick_verified = plate_already_gripped + self._force_continue_after_pickup_failure = False + self._source_labware = self._get_source_labware() + # The gripper physically engages ONE plate's flanges. For an ordinary + # pickup this is the top plate (same as _source_labware). For a + # mounted group -- a filter plate locked onto a collection plate via + # can_mount/can_be_mounted flags -- the fingers must engage the + # BOTTOM plate's flanges so the whole locked unit lifts together. + # Gripping the top plate lifts only the top, leaving the bottom + # behind (the failure mode the mount feature is designed to avoid). + self._engage_plate = self._get_engage_plate() + self._positions = self._calculate_positions() + self._log_plan() + + def _get_source_labware(self) -> Labware: + source = self._deck.get_stack(self._from_location).top + if source is None: + raise RuntimeError(f"No labware assigned to location {self._from_location}") + return source + + def _get_engage_plate(self) -> Labware: + """Return which plate's flanges the gripper physically grips. + + Defaults to :attr:`_source_labware` (top of stack) for ordinary + pickups. If the top is flagged :attr:`~..deck.labware.Labware.is_mounted`, + returns the BOTTOM of the mounted group -- the plate that the gripper + must engage in order to lift the whole locked unit. + """ + stack = self._deck.get_stack(self._from_location) + group = stack.mounted_group_from_top() + return group[-1] if group else self._source_labware + + def _move_info(self, axis: Axis, position: float) -> AxisMoveInfo: + cfg = self._config.axes.get(axis) + if cfg and self._speed in cfg.speeds: + speed = cfg.speeds[self._speed] + return AxisMoveInfo( + axis=axis, position=position, velocity=speed.velocity, acceleration=speed.acceleration + ) + return AxisMoveInfo(axis=axis, position=position) + + def _gripper_y_offset(self) -> float: + _, head_y_offset = _gripper_head_offsets(self._config.head.head_type) + return self._config.gripper.y_offset + head_y_offset + + def _position_or_none(self, axis: Axis) -> Optional[float]: + try: + return float(self._ctrl.get_position(axis)) + except Exception: + return None + + def _gripper_is_open(self) -> bool: + g_pos = self._position_or_none("g") + return g_pos is not None and abs(g_pos - OPEN_GRIPPER_POSITION) <= _GRIPPER_OPEN_TOLERANCE_MM + + def _snapshot(self) -> Dict[str, Any]: + """Return the axis positions and telemetry a pickup-verification check reads. + + Telemetry is always empty here: only a controller-specific rich state + read could populate it, and this reads only the standard interface's + ``get_position``. Pickup verification depends solely on the G-axis + position, which this always reports correctly. + """ + return { + "positions": { + "X": self._position_or_none("x"), + "Y": self._position_or_none("y"), + "Z": self._position_or_none("z"), + "Zg": self._position_or_none("zg"), + "G": self._position_or_none("g"), + }, + "telemetry": {}, + } + + def _live_snapshot(self, *, force_refresh: bool = False) -> Dict[str, Any]: + return self._snapshot() + + def _fmt_snapshot(self, values: Dict[str, Any]) -> str: + parts = [] + for axis in ("X", "Y", "Z", "Zg", "G"): + value = values[axis] + parts.append(f"{axis}={value:.3f}" if value is not None else f"{axis}=n/a") + return " ".join(parts) + + def _fmt_telemetry(self, telemetry: Dict[str, Any]) -> str: + parts: List[str] = [] + for axis in ("X", "Y", "Z", "W", "G", "Zg"): + axis_data = telemetry.get(axis) + if not axis_data: + continue + axis_parts: List[str] = [] + for key in ( + "measured_current", + "peak_current", + "last_peak_current_percent", + "last_force_percent", + "current_position_error", + "position_error_max", + "velocity_limit", + "acceleration_limit", + ): + value = axis_data.get(key) + if isinstance(value, (int, float)): + axis_parts.append(f"{key}={float(value):.3f}") + for key in ("enabled", "initialized", "is_moving"): + value = axis_data.get(key) + if isinstance(value, bool): + axis_parts.append(f"{key}={str(value).lower()}") + last_command = axis_data.get("last_command") + if isinstance(last_command, dict): + mode = last_command.get("mode") + if isinstance(mode, str): + axis_parts.append(f"cmd={mode}") + for key in ("position", "target_position"): + value = last_command.get(key) + if isinstance(value, (int, float)): + axis_parts.append(f"{key}={float(value):.3f}") + if axis_parts: + parts.append(f"{axis}[{' '.join(axis_parts)}]") + return " ".join(parts) + + def _log_step(self, name: str, *, targets: Optional[Dict[str, float]] = None) -> None: + snapshot = self._snapshot() + positions = snapshot["positions"] + telemetry = snapshot["telemetry"] + self._live_status = { + "task": "pick_place", + "step": name, + "from_location": self._from_location, + "to_location": self._to_location, + "labware": self._source_labware.metadata or {"name": self._source_labware.name}, + "positions": positions, + "telemetry": telemetry, + "targets": dict(targets or {}), + } + logger.info("PickPlace %s current=%s", name, self._fmt_snapshot(positions)) + telemetry_text = self._fmt_telemetry(telemetry) + if telemetry_text: + logger.info("PickPlace %s telemetry=%s", name, telemetry_text) + if targets: + target_text = " ".join(f"{axis}={value:.3f}" for axis, value in targets.items()) + logger.info("PickPlace %s target=%s", name, target_text) + + def status_payload(self) -> dict: + """Return the task's live status, merging in any pending operator prompt.""" + payload = dict(self._live_status) + # Merges the base-class operator_prompt (set either by a step or + # synthesized by the engine on failure) so any step failure in a + # PickPlace surfaces the generic Retry/Ignore/Abort modal, even if + # the step didn't build one into _live_status itself. + base = super().status_payload() + if "operator_prompt" not in payload and base.get("operator_prompt"): + payload["operator_prompt"] = base["operator_prompt"] + return payload + + def _axis_telemetry_value( + self, telemetry: Dict[str, Any], axis: str, key: str + ) -> Optional[float]: + axis_data = telemetry.get(axis) + if not isinstance(axis_data, dict): + return None + value = axis_data.get(key) + if isinstance(value, (int, float)): + return float(value) + return None + + def _verify_plate_pickup( + self, + before_snapshot: Dict[str, Any], + after_snapshot: Dict[str, Any], + ) -> Tuple[bool, Dict[str, object]]: + grip_position_after = after_snapshot["positions"].get("G") + g_rule_failed = ( + isinstance(grip_position_after, (int, float)) + and float(grip_position_after) >= _PICKUP_FAILURE_G_THRESHOLD_MM + ) + sensor_detected: Optional[bool] = None + if not bool(self._config.safety.ignore_plate_sensor): + try: + sensor_detected = bool(self._ctrl.is_plate_in_gripper()) + except Exception as exc: + logger.debug("Plate-present sensor unavailable during pick verification: %s", exc) + + measured_before = self._axis_telemetry_value( + before_snapshot["telemetry"], "G", "measured_current" + ) + measured_after = self._axis_telemetry_value( + after_snapshot["telemetry"], "G", "measured_current" + ) + peak_after = self._axis_telemetry_value( + after_snapshot["telemetry"], "G", "last_peak_current_percent" + ) + force_after = self._axis_telemetry_value(after_snapshot["telemetry"], "G", "last_force_percent") + + current_delta = None + if measured_before is not None and measured_after is not None: + current_delta = abs(measured_after - measured_before) + + current_detected = any( + ( + peak_after is not None and peak_after >= 0.05, + force_after is not None and force_after >= 5.0, + current_delta is not None and current_delta >= 0.02, + ) + ) + success = not g_rule_failed + details = { + "sensor_detected": sensor_detected, + "current_detected": current_detected, + "g_rule_failed": g_rule_failed, + "grip_position_after": grip_position_after, + "measured_current_before": measured_before, + "measured_current_after": measured_after, + "current_delta": current_delta, + "peak_current_after": peak_after, + "force_percent_after": force_after, + "attempt": self._grip_attempts, + } + return success, details + + def on_error_action(self, action: ErrorAction) -> None: + """Apply the operator's choice for the step that just failed.""" + if ( + action == ErrorAction.IGNORE + and self.error is not None + and self.error.step_name == "grip_plate" + ): + self._force_continue_after_pickup_failure = True + self._plate_pick_verified = True + self._live_status.update( + { + "pickup_verification": { + **dict(self._live_status.get("pickup_verification") or {}), + "forced_continue": True, + }, + "operator_prompt": None, + } + ) + elif action == ErrorAction.RETRY: + self._force_continue_after_pickup_failure = False + self._plate_pick_verified = False + elif action == ErrorAction.ABORT: + self._force_continue_after_pickup_failure = False + + def _log_plan(self) -> None: + plan = self.debug_plan() + logger.info( + "PickPlace plan from=%d to=%d head=%s teach_tip_capacity=%.1f teach_tip_length=%.3f " + "labware=%s plate_height=%.3f stack_height=%.3f gripper_offset=%.3f " + "source_teach_z=%.3f source_top_z=%.3f source_grip_plane_z=%.3f " + "dest_teach_z=%.3f dest_top_z=%.3f", + plan["from_location"], + plan["to_location"], + plan["head_type"], + plan["teach_tip_capacity"], + plan["teach_tip_length_mm"], + plan["labware_name"], + plan["plate_height_mm"], + plan["stack_height_mm"], + plan["gripper_offset_mm"], + plan["source_teach_z"], + plan["source_top_z"], + plan["source_grip_plane_z"], + plan["dest_teach_z"], + plan["dest_top_z"], + ) + logger.info( + "PickPlace solved pick(Z=%.3f,Zg=%.3f) carry(Z=%.3f,Zg=%.3f) place(Z=%.3f,Zg=%.3f)", + self._positions.pick_z, + self._positions.pick_zg, + self._positions.carry_z, + self._positions.carry_zg, + self._positions.place_z, + self._positions.place_zg, + ) + + def debug_plan(self) -> Dict[str, Union[float, int, str]]: + """Return the geometry inputs and solved targets behind this task's plan. + + Returns: + A dict of every measurement and intermediate value + :meth:`_calculate_positions` used, for diagnostics. + """ + tip_length = self._tip_length_for_pick_place() + source_tp_z = self._tp.get_teachpoint(self._from_location, "z") + dest_tp_z = self._tp.get_teachpoint(self._to_location, "z") + source_complete_height = self._current_complete_height(self._from_location) + dest_complete_height = self._current_complete_height(self._to_location) + source_support_height = self._source_pick_support_height() + dest_support_height = self._destination_place_support_height() + return { + "from_location": self._from_location, + "to_location": self._to_location, + "head_type": self._config.head.head_type, + "teach_tip_id": str(self._config.head.teach_tip_id or ""), + "teach_tip_capacity": float(self._config.head.teach_tip_capacity or 0.0), + "teach_tip_length_mm": tip_length, + "labware_name": self._source_labware.name, + "plate_height_mm": self._source_labware.height, + "stack_height_mm": self._source_labware.stack_height, + # The gripper engages _engage_plate's flanges -- for an ordinary + # pickup this IS _source_labware, but for a mounted-group pickup + # the engage plate is the BOTTOM of the group and its + # gripper_offset is what drives pick_z. Logs both so mount-pair + # diagnostics are readable. + "gripper_offset_mm": self._engage_plate.gripper_offset, + "engage_plate_name": self._engage_plate.name, + "mounted_group_size": len(self._deck.get_stack(self._from_location).mounted_group_from_top()), + "source_pick_height_mm": source_complete_height, + "dest_stack_height_mm": dest_complete_height, + "source_support_height_mm": source_support_height, + "dest_support_height_mm": dest_support_height, + "source_teach_z": source_tp_z, + "source_top_z": source_tp_z - source_complete_height, + # Gripper offset semantics are bottom-up: it is the distance from + # the labware bottom up to the gripper contact plane. + "source_grip_plane_z": source_tp_z + - (source_support_height + self._engage_plate.gripper_offset), + "dest_teach_z": dest_tp_z, + "dest_top_z": dest_tp_z - dest_complete_height, + "pick_z": self._positions.pick_z, + "pick_zg": self._positions.pick_zg, + "carry_z": self._positions.carry_z, + "carry_zg": self._positions.carry_zg, + "place_z": self._positions.place_z, + "place_zg": self._positions.place_zg, + } + + def _axis_range(self, axis: Axis) -> Tuple[float, float]: + cfg = self._config.axes.get(axis) + if cfg is None: + raise RuntimeError(f"Missing axis config for {axis_display_name(axis)}") + return cfg.range.min_pos, cfg.range.max_pos + + def _clamp(self, value: float, axis: Axis) -> float: + min_pos, max_pos = self._axis_range(axis) + return max(min_pos, min(max_pos, value)) + + def _get_current_z(self) -> float: + z_min, _ = self._axis_range("z") + try: + current = self._ctrl.get_position("z") + except Exception: + current = z_min + return max(current, z_min) + + def _tip_length_for_pick_place(self) -> float: + head_type = self._config.head.head_type + stored_length = self._config.head.teach_tip_length_mm + if stored_length is not None: + return float(stored_length) + # Pick/place solves against the default taught tip reference for the + # active head, not against the currently-mounted physical tips. + if head_type_is_fixed(head_type): + return 35.78 + 26.1 + if head_type_is_assaymap(head_type): + return 4.71 + default_capacity = float( + self._config.head.teach_tip_capacity or self._config.head.default_tip_capacity or 0.0 + ) + tip_ref = self._config.head.teach_tip_id or self._config.head.default_tip_id or default_capacity + tip_length = get_tip_length_mm(head_type, tip_ref) + if tip_length is None: + raise RuntimeError(f"Teach tip length is not configured for {head_type} with {tip_ref}.") + return tip_length + + def _gripper_pad_reference_zg(self, tip_length: float) -> float: + """Return the Zg at which the gripper bottom sits in this location's plate-pad plane. + + The configuration stores one bench measurement -- Zg when the pad is + touching, and the length of the tip that was installed for it. Any + other taught tip shifts that reference by the length delta, keeping + the same physical plane. The two calibration values are a pair; + neither is meaningful on its own, which is why they are not derived + from the head's current tip. + """ + g = self._config.gripper + return g.pad_zg_reference_mm + (tip_length - g.pad_reference_tip_length_mm) + + def _solve_pick_or_place( + self, location: int, stack_height: float, gripper_offset: float + ) -> Tuple[float, float]: + z_min, _ = self._axis_range("z") + _, zg_max = self._axis_range("zg") + zg_max = min(zg_max, _PLATE_HANDLING_ZG_MAX) + z_current = self._get_current_z() + z_teachpoint = self._tp.get_teachpoint(location, "z") + tip_length = self._tip_length_for_pick_place() + if head_type_is_disposable(self._config.head.head_type): + new_zg = ( + z_teachpoint + - z_current + + self._gripper_pad_reference_zg(tip_length) + - gripper_offset + - stack_height + ) + else: + new_zg = ( + z_teachpoint + - z_current + + tip_length + - GRIPPER_THICKNESS + - GRIPPER_TO_BASE_OF_HEAD_GAP + - gripper_offset + - stack_height + + _LENGTH_DIFFERENCE_96_TO_384 + ) + safe_zg = self._clamp(self._config.axes["zg"].range.min_pos, "zg") + + if new_zg > zg_max: + z = z_current + new_zg - zg_max + zg = zg_max + elif new_zg < safe_zg: + z = z_current + new_zg - safe_zg + if z < z_min and (safe_zg + z) >= safe_zg: + safe_zg += z + z = z_min + zg = safe_zg + else: + z = z_current + zg = new_zg + + return self._clamp(z, "z"), self._clamp(zg, "zg") + + def _safe_carry_zg(self, labware: Labware) -> float: + safe = 5.0 + labware.height - labware.gripper_offset - GRIPPER_THICKNESS + return self._clamp(safe, "zg") + + def _head_protrusion_below_head(self) -> float: + # Mirrors behavior for gripper moves with no tips mounted. + return _NO_TIPS_HEAD_PROTRUSION_MM + + def _adjust_for_head_clearance( + self, + safe_z: float, + safe_zg: float, + labware: Labware, + *, + effective_height: Optional[float] = None, + ) -> Tuple[float, float]: + # ``effective_height`` lets callers override labware.height when the + # carried assembly is taller than a single plate -- notably a + # mounted group where the gripper engages the bottom plate but a + # whole second plate rides on top. Defaults to labware.height so + # existing callers (Delid/Relid/single-plate pickups) keep their + # prior behavior. + carried_top_height = labware.height if effective_height is None else float(effective_height) + interference = ( + carried_top_height + + self._head_protrusion_below_head() + + Z_CLEARANCE + - safe_zg + - labware.gripper_offset + - GRIPPER_THICKNESS + ) + if interference <= 0: + return self._clamp(safe_z, "z"), self._clamp(safe_zg, "zg") + + z_min, _ = self._axis_range("z") + z_and_zg = safe_z + safe_zg + adjusted_z = safe_z - interference + if adjusted_z < z_min: + adjusted_z = z_min + adjusted_zg = z_and_zg - adjusted_z + return self._clamp(adjusted_z, "z"), self._clamp(adjusted_zg, "zg") + + def _current_location_height(self, location: int) -> float: + return self._deck.get_location_height(location) + + def _current_complete_height(self, location: int) -> float: + return self._deck.get_height(location) + + def _source_pick_support_height(self) -> float: + # For a mounted-group pickup, the gripper engages the BOTTOM of the + # group, so "support" is whatever is below that bottom plate -- not + # what's below the visible top. Degrades to get_location_height for + # ordinary single-plate pickups. + return self._deck.get_stack(self._from_location).get_support_height_below_group() + + def _carried_assembly_effective_height(self) -> float: + """Return the distance from the engage plate's base to the top of the carried assembly. + + This is what drives head-clearance during carry. For a single-plate + pickup: just the engage plate's own ``height``. For a mounted group: + the engage plate contributes its ``stack_height`` (how much it + supports the next level), every mid-layer contributes its + ``stack_height``, and the topmost plate contributes its full + ``height``. Matches the geometry the plate stack would present to + the head as it travels. + """ + stack = self._deck.get_stack(self._from_location) + group = stack.mounted_group_from_top() + if len(group) <= 1: + return self._engage_plate.height + # group is top-first; iterate bottom-to-top so the final (top) plate + # contributes its full height, everything else its + # stacking-surface-to-next-layer height. + reversed_group = list(reversed(group)) + total = 0.0 + for i, plate in enumerate(reversed_group): + if i == len(reversed_group) - 1: + total += float(plate.height) + else: + total += float(plate.stack_height or plate.height) + return total + + def _destination_place_support_height(self) -> float: + return self._deck.get_stacking_height(self._to_location) + + def _obstacle_height_between_locations(self) -> float: + region = DeckLayout.get_region([self._from_location], [self._to_location]) + blockers = [loc for loc in region if loc not in {self._from_location, self._to_location}] + if not blockers: + return 0.0 + return max(self._deck.get_height(loc) for loc in blockers) + + def _calculate_positions(self) -> PickPlacePositions: + source_height = self._source_pick_support_height() + dest_height = self._destination_place_support_height() + final_place_height = dest_height + # All pick/place/carry Z positions are computed relative to the plate + # the gripper actually grips (_engage_plate). For ordinary stacks + # this is the top plate; for a mounted pair it's the bottom plate of + # the locked group. Using the engage plate's gripper_offset puts the + # fingers at the right flange height regardless of how tall the + # mounted group above it is. + engage_offset = self._engage_plate.gripper_offset + pick_z, pick_zg = self._solve_pick_or_place( + self._from_location, + source_height, + engage_offset, + ) + place_z, place_zg = self._solve_pick_or_place( + self._to_location, + final_place_height, + engage_offset, + ) + + obstacle_height = self._obstacle_height_between_locations() + carry_stack = max(source_height, final_place_height, obstacle_height) + Z_CLEARANCE + carry_z, carry_zg = self._solve_pick_or_place( + self._from_location, + carry_stack, + engage_offset, + ) + carry_z = self._clamp(carry_z, "z") + carry_zg = self._clamp(carry_zg, "zg") + + # For a mounted-pair carry, the gripper engages the bottom plate but + # a whole second plate rides on top -- passes the combined group + # height so the head-clearance check accounts for the full assembly + # instead of just the engage plate. + carry_z, carry_zg = self._adjust_for_head_clearance( + carry_z, + carry_zg, + self._engage_plate, + effective_height=self._carried_assembly_effective_height(), + ) + + return PickPlacePositions( + pick_z=pick_z, + pick_zg=pick_zg, + carry_z=carry_z, + carry_zg=carry_zg, + place_z=place_z, + place_zg=place_zg, + ) + + def get_steps(self) -> "list[tuple[str, Callable[[], Awaitable[None]]]]": + """Return this task's steps, in execution order.""" + return [ + ("move_to_safe_pick_start", self._move_to_safe_pick_start), + ("move_gripper_to_nesting", self._move_gripper_to_nesting), + ("move_xy_to_pick", self._move_xy_to_pick), + ("move_to_pick_height", self._move_to_pick_height), + ("grip_plate", self._grip_plate), + ("move_to_carry_height", self._move_to_carry_height), + ("move_xy_to_place", self._move_xy_to_place), + ("move_to_place_height", self._move_to_place_height), + ("release_plate", self._release_plate), + ("return_gripper_to_nesting", self._return_gripper_to_nesting), + ] + + async def _move_to_safe_pick_start(self) -> None: + self._log_step("move_to_safe_pick_start", targets={"Z": self._config.safety.z_safe_position}) + await asyncio.to_thread( + self._ctrl.move, + [self._move_info("z", self._config.safety.z_safe_position)], + True, + ) + if self._gripper_is_open(): + self._log_step( + "move_to_safe_pick_start_open_gripper_skipped", targets={"G": OPEN_GRIPPER_POSITION} + ) + else: + self._log_step("move_to_safe_pick_start_open_gripper", targets={"G": OPEN_GRIPPER_POSITION}) + await asyncio.to_thread(self._ctrl.open_gripper) + self._log_step("move_to_safe_pick_start_complete") + + async def _move_gripper_to_nesting(self) -> None: + self._log_step("move_gripper_to_nesting", targets={"Zg": _GRIPPER_RECESS_DEPTH}) + await asyncio.to_thread( + self._ctrl.move, + [self._move_info("zg", _GRIPPER_RECESS_DEPTH)], + True, + ) + self._log_step("move_gripper_to_nesting_complete") + + async def _move_xy_to_pick(self) -> None: + x = self._tp.get_teachpoint(self._from_location, "x") + y = self._tp.get_teachpoint(self._from_location, "y") + self._gripper_y_offset() + self._log_step("move_xy_to_pick", targets={"X": x, "Y": y}) + await asyncio.to_thread( + self._ctrl.move, + [ + self._move_info("x", x), + self._move_info("y", y), + ], + True, + ) + self._log_step("move_xy_to_pick_complete") + + async def _move_to_pick_height(self) -> None: + self._log_step( + "move_to_pick_height", + targets={"Z": self._positions.pick_z, "Zg": self._positions.pick_zg}, + ) + moves = [ + self._move_info("z", self._positions.pick_z), + self._move_info("zg", self._positions.pick_zg), + ] + await asyncio.to_thread(self._ctrl.move, moves, True) + self._log_step("move_to_pick_height_complete") + + async def _grip_plate(self) -> None: + grip_speed = self._speed if self._speed != "slow" else "med" + if self._grip_attempts > 0: + self._log_step("reopen_gripper_for_retry", targets={"G": OPEN_GRIPPER_POSITION}) + await asyncio.to_thread(self._ctrl.open_gripper) + before_snapshot = self._snapshot() + self._plate_pick_verified = False + self._log_step("grip_plate", targets={"G": _PICK_PLACE_GRIP_TARGET}) + await asyncio.to_thread(self._ctrl.grip, grip_speed, _PICK_PLACE_GRIP_TARGET) + self._grip_attempts += 1 + after_snapshot = self._snapshot() + verified, verification = self._verify_plate_pickup(before_snapshot, after_snapshot) + if not verified: + before_snapshot = self._live_snapshot(force_refresh=False) + after_snapshot = self._live_snapshot(force_refresh=True) + verified, verification = self._verify_plate_pickup(before_snapshot, after_snapshot) + self._live_status.update( + { + "pickup_verification": verification, + "operator_prompt": None + if verified + else { + "kind": "pickup_verification_failed", + "title": "Plate pickup not detected", + "message": ( + "The gripper closed, but the post-grip G position indicates the plate was not " + "picked up." + ), + "choices": ["retry", "ignore", "abort"], + }, + } + ) + if not verified: + raise RuntimeError("Plate pickup not detected after gripper close") + self._plate_pick_verified = True + self._force_continue_after_pickup_failure = False + self._log_step("grip_plate_complete") + + async def _move_to_carry_height(self) -> None: + self._log_step( + "move_to_carry_height", + targets={"Z": self._positions.carry_z, "Zg": self._positions.carry_zg}, + ) + await asyncio.to_thread( + self._ctrl.move, + [ + self._move_info("z", self._positions.carry_z), + self._move_info("zg", self._positions.carry_zg), + ], + True, + ) + self._log_step("move_to_carry_height_complete") + + async def _move_xy_to_place(self) -> None: + if not self._plate_pick_verified and not self._force_continue_after_pickup_failure: + self._log_step("move_xy_to_place_skipped_missing_plate") + return + x = self._tp.get_teachpoint(self._to_location, "x") + y = self._tp.get_teachpoint(self._to_location, "y") + self._gripper_y_offset() + self._log_step("move_xy_to_place", targets={"X": x, "Y": y}) + await asyncio.to_thread( + self._ctrl.move, + [ + self._move_info("x", x), + self._move_info("y", y), + ], + True, + ) + self._log_step("move_xy_to_place_complete") + + async def _move_to_place_height(self) -> None: + if not self._plate_pick_verified and not self._force_continue_after_pickup_failure: + self._log_step("move_to_place_height_skipped_missing_plate") + return + current_z = await asyncio.to_thread(self._ctrl.get_position, "z") + current_zg = await asyncio.to_thread(self._ctrl.get_position, "zg") + target_z = self._positions.place_z + target_zg = self._positions.place_zg + z_min, _ = self._axis_range("z") + move_z_first = (current_z - target_z) > (z_min - AXIS_EPSILON) and (current_zg - target_zg) < 0 + self._log_step("move_to_place_height", targets={"Z": target_z, "Zg": target_zg}) + if abs(target_z - z_min) <= AXIS_EPSILON or move_z_first: + await asyncio.to_thread(self._ctrl.move, [self._move_info("z", target_z)], True) + await asyncio.to_thread( + self._ctrl.move, + [ + self._move_info("z", target_z), + self._move_info("zg", target_zg), + ], + True, + ) + self._log_step("move_to_place_height_complete") + + async def _release_plate(self) -> None: + if not self._plate_pick_verified and not self._force_continue_after_pickup_failure: + self._log_step("release_plate_skipped_missing_plate", targets={"G": OPEN_GRIPPER_POSITION}) + await asyncio.to_thread(self._ctrl.open_gripper) + self._log_step("release_plate_skipped_missing_plate_complete") + return + self._log_step("release_plate", targets={"G": OPEN_GRIPPER_POSITION}) + await asyncio.to_thread(self._ctrl.open_gripper) + self._log_step("release_plate_complete") + # DeckState update respects mount semantics: if the top plate at the + # source was flagged ``is_mounted``, the physically locked plate + # beneath it moves with us. For a plain stack this degrades to a + # single-item move (identical to a single-remove/single-add). + group = self._deck.remove_mounted_group(self._from_location) + self._deck.add_mounted_group(self._to_location, group) + # Diagnostic only on multi-plate moves. Logged directly via + # logger.info rather than _log_step because the latter expects + # targets to be axis-number pairs (it formats every value with + # :.3f) and any non-numeric field there crashes the step. + if len(group) > 1: + logger.info( + "PickPlace mounted-group move: %d plates from %d->%d (%s)", + len(group), + self._from_location, + self._to_location, + ", ".join(lw.name for lw in group), + ) + + async def _return_gripper_to_nesting(self) -> None: + self._log_step("return_gripper_to_nesting", targets={"Zg": _GRIPPER_RECESS_DEPTH}) + await asyncio.to_thread( + self._ctrl.move, + [self._move_info("zg", _GRIPPER_RECESS_DEPTH)], + True, + ) + self._log_step("return_gripper_to_nesting_complete") + + +class GripperTeachMoveTask(PickPlaceTask): + """Position the gripper over a location so its Y alignment can be judged. + + Subclasses :class:`PickPlaceTask` deliberately: the Y offset, grip plane, + and Z/Zg solve are the same arithmetic a real pick uses, so teaching + against a different calculation would calibrate against something the + robot never does. This runs only the approach half of a pick -- it + positions and stops. It never closes the gripper, so no plate is lifted. + + ``approach_height`` backs the gripper off above the grip plane. Zg extends + downward, so clearance means a smaller Zg. + """ + + def __init__( + self, + controller: BravoController, + teachpoints: Teachpoints, + config: BravoMachineConfig, + deck: DeckState, + location: int, + approach_height: float = 0.0, + speed: SpeedLevel = "med", + ) -> None: + """Initialize the task. + + Args: + controller: The controller to operate. + teachpoints: The deck teachpoints to move against. + config: The machine configuration to move against. + deck: The deck state, read to find the plate to align over. + location: The deck location to teach. + approach_height: Millimetres to stop above the grip plane. + speed: The speed profile for XYZ/Zg motion. + """ + # from == to: only ever runs the pick-side steps. + PickPlaceTask.__init__( + self, + controller, + teachpoints, + config, + deck, + from_location=location, + to_location=location, + speed=speed, + ) + self.name = f"GripperTeachMove_{location}" + self._approach_height = max(0.0, float(approach_height or 0.0)) + + def get_steps(self) -> "list[tuple[str, Callable[[], Awaitable[None]]]]": + """Return this task's steps, in execution order.""" + return [ + ("move_to_safe_pick_start", self._move_to_safe_pick_start), + ("move_gripper_to_nesting", self._move_gripper_to_nesting), + ("move_xy_to_pick", self._move_xy_to_pick), + ("move_to_teach_height", self._move_to_teach_height), + ] + + async def _move_to_teach_height(self) -> None: + """Descend to the grip plane, held short by ``approach_height``.""" + zg = self._positions.pick_zg - self._approach_height + self._log_step( + "move_to_teach_height", + targets={"Z": self._positions.pick_z, "Zg": zg}, + ) + logger.info( + "Gripper teach: location %d at Z=%.3f Zg=%.3f (grip plane %.3f, " + "clearance %.2f mm), gripper Y offset in use %.3f mm", + self._from_location, + self._positions.pick_z, + zg, + self._positions.pick_zg, + self._approach_height, + self._gripper_y_offset(), + ) + await asyncio.to_thread( + self._ctrl.move, + [ + self._move_info("z", self._positions.pick_z), + self._move_info("zg", zg), + ], + True, + ) + self._log_step("move_to_teach_height_complete") + + +class DelidPlateTask(PickPlaceTask): + """Remove a lid from a lidded plate and place that lid at another location.""" + + def __init__( + self, + controller: BravoController, + teachpoints: Teachpoints, + config: BravoMachineConfig, + deck: DeckState, + plate_location: int, + lid_destination: int, + speed: SpeedLevel = "med", + ) -> None: + """Initialize the task. + + Args: + controller: The controller to operate. + teachpoints: The deck teachpoints to move against. + config: The machine configuration to move against. + deck: The deck state, updated on a successful delid. + plate_location: The deck location of the lidded plate. + lid_destination: The deck location to place the removed lid at. + speed: The speed profile for XYZ/Zg motion. + + Raises: + RuntimeError: If the plate at ``plate_location`` has no lid. + """ + StateMachineTask.__init__(self, f"DelidPlate_{plate_location}_{lid_destination}") + self._ctrl = controller + self._tp = teachpoints + self._config = config + self._deck = deck + self._from_location = plate_location + self._to_location = lid_destination + self._speed = speed + self._live_status: Dict[str, Any] = {} + self._grip_attempts = 0 + self._plate_pick_verified = False + self._force_continue_after_pickup_failure = False + self._source_plate = self._get_source_labware() + if not self._source_plate.is_lidded: + raise RuntimeError(f"No lid is present on the plate at location {plate_location}") + self._source_labware = self._build_lid_labware(self._source_plate) + self._engage_plate = self._source_labware + self._lid_gripper_offset = self._resolve_lid_gripper_offset(self._source_plate) + self._pick_gripper_offset = self._lid_gripper_offset + self._lid_resting_height( + self._source_plate + ) + self._place_gripper_offset = self._lid_gripper_offset + self._positions = self._calculate_positions() + self._log_plan() + + @staticmethod + def _lid_resting_height(plate: Labware) -> float: + return float((plate.metadata or {}).get("lid_resting_height_mm") or 0.0) + + @staticmethod + def _lid_height(plate: Labware) -> float: + return lid_thickness_mm(plate.metadata) + + @classmethod + def _resolve_lid_gripper_offset(cls, plate: Labware) -> float: + return lid_gripper_offset_mm( + plate.metadata, + fallback_gripper_offset_mm=float(plate.gripper_offset or 0.0), + label=plate.name, + ) + + def _build_lid_labware(self, plate: Labware) -> Labware: + lid = synthesize_lid_labware(plate) + lid.metadata["lid_height_mm"] = lid.height + return lid + + def _build_unlidded_plate(self) -> Labware: + metadata = dict(self._source_plate.metadata or {}) + metadata["is_lidded"] = False + metadata.pop("generated_lid", None) + height = float( + metadata.get("base_height_mm") or metadata.get("height_mm") or self._source_plate.height + ) + metadata["height_mm"] = height + metadata["total_height_mm"] = height + stack_height = float(metadata.get("stack_height_mm") or height) + return Labware( + id=self._source_plate.id, + definition_id=self._source_plate.definition_id, + name=self._source_plate.name, + height=height, + width=self._source_plate.width, + length=self._source_plate.length, + labware_type=self._source_plate.labware_type, + gripper_offset=self._source_plate.gripper_offset, + stack_height=stack_height, + is_lidded=False, + is_sealed=self._source_plate.is_sealed, + wells=self._source_plate.wells, + metadata=metadata, + ) + + def _calculate_positions(self) -> PickPlacePositions: + source_support_height = self._source_pick_support_height() + destination_support_height = self._destination_place_support_height() + + pick_z, pick_zg = self._solve_pick_or_place( + self._from_location, + source_support_height, + self._pick_gripper_offset, + ) + place_z, place_zg = self._solve_pick_or_place( + self._to_location, + destination_support_height, + self._place_gripper_offset, + ) + + obstacle_height = self._obstacle_height_between_locations() + carry_stack = ( + max( + self._current_complete_height(self._from_location), + destination_support_height, + obstacle_height, + ) + + Z_CLEARANCE + ) + carry_z, carry_zg = self._solve_pick_or_place( + self._from_location, + carry_stack, + self._place_gripper_offset, + ) + carry_z = self._clamp(carry_z, "z") + carry_zg = self._clamp(carry_zg, "zg") + carry_z, carry_zg = self._adjust_for_head_clearance(carry_z, carry_zg, self._source_labware) + + return PickPlacePositions( + pick_z=pick_z, + pick_zg=pick_zg, + carry_z=carry_z, + carry_zg=carry_zg, + place_z=place_z, + place_zg=place_zg, + ) + + async def _grip_plate(self) -> None: + grip_speed = self._speed if self._speed != "slow" else "med" + if self._grip_attempts > 0: + self._log_step("reopen_gripper_for_retry", targets={"G": OPEN_GRIPPER_POSITION}) + await asyncio.to_thread(self._ctrl.open_gripper) + before_snapshot = self._snapshot() + self._plate_pick_verified = False + self._log_step("grip_lid", targets={"G": _PICK_PLACE_GRIP_TARGET}) + await asyncio.to_thread(self._ctrl.grip, grip_speed, _PICK_PLACE_GRIP_TARGET, True) + self._grip_attempts += 1 + after_snapshot = self._snapshot() + verified, verification = self._verify_plate_pickup(before_snapshot, after_snapshot) + if not verified: + before_snapshot = self._live_snapshot(force_refresh=False) + after_snapshot = self._live_snapshot(force_refresh=True) + verified, verification = self._verify_plate_pickup(before_snapshot, after_snapshot) + self._live_status.update( + { + "pickup_verification": verification, + "operator_prompt": None + if verified + else { + "kind": "pickup_verification_failed", + "title": "Lid pickup not detected", + "message": ( + "The gripper closed, but the post-grip G position indicates the lid was not picked up." + ), + "choices": ["retry", "ignore", "abort"], + }, + } + ) + if not verified: + raise RuntimeError("Lid pickup not detected after gripper close") + self._plate_pick_verified = True + self._force_continue_after_pickup_failure = False + self._log_step("grip_lid_complete") + + async def _release_plate(self) -> None: + if not self._plate_pick_verified and not self._force_continue_after_pickup_failure: + self._log_step("release_lid_skipped_missing_lid", targets={"G": OPEN_GRIPPER_POSITION}) + await asyncio.to_thread(self._ctrl.open_gripper) + self._log_step("release_lid_skipped_missing_lid_complete") + return + self._log_step("release_lid", targets={"G": OPEN_GRIPPER_POSITION}) + await asyncio.to_thread(self._ctrl.open_gripper) + self._log_step("release_lid_complete") + removed = self._deck.remove(self._from_location) + if removed is not self._source_plate: + logger.warning("Delid source stack changed during task; using runtime top labware state") + self._deck.add(self._from_location, self._build_unlidded_plate()) + self._deck.add(self._to_location, self._build_lid_labware(self._source_plate)) + + +class RelidPlateTask(PickPlaceTask): + """Pick up a standalone lid and place it back onto a compatible plate.""" + + def __init__( + self, + controller: BravoController, + teachpoints: Teachpoints, + config: BravoMachineConfig, + deck: DeckState, + lid_location: int, + plate_location: int, + speed: SpeedLevel = "med", + ) -> None: + """Initialize the task. + + Args: + controller: The controller to operate. + teachpoints: The deck teachpoints to move against. + config: The machine configuration to move against. + deck: The deck state, updated on a successful relid. + lid_location: The deck location of the standalone lid. + plate_location: The deck location of the plate to relid. + speed: The speed profile for XYZ/Zg motion. + + Raises: + RuntimeError: If the source is not a standalone lid, the destination + already has a lid, is sealed, or does not support a lid. + """ + StateMachineTask.__init__(self, f"RelidPlate_{lid_location}_{plate_location}") + self._ctrl = controller + self._tp = teachpoints + self._config = config + self._deck = deck + self._from_location = lid_location + self._to_location = plate_location + self._speed = speed + self._live_status: Dict[str, Any] = {} + self._grip_attempts = 0 + self._plate_pick_verified = False + self._force_continue_after_pickup_failure = False + self._source_lid = self._get_source_labware() + self._destination_plate = self._get_destination_plate() + if not self._is_lid(self._source_lid): + raise RuntimeError(f"No standalone lid is present at location {lid_location}") + if self._destination_plate.is_lidded: + raise RuntimeError(f"Plate at location {plate_location} already has a lid") + if self._destination_plate.is_sealed: + raise RuntimeError(f"Plate at location {plate_location} is sealed and cannot be relidded") + can_have_lid = bool((self._destination_plate.metadata or {}).get("can_have_lid", False)) + if not can_have_lid: + raise RuntimeError( + f"Labware at location {plate_location} does not support lids: " + f"{self._destination_plate.name}" + ) + self._source_labware = self._source_lid + self._engage_plate = self._source_lid + self._pick_gripper_offset = max(0.0, float(self._source_lid.gripper_offset or 0.0)) + self._place_gripper_offset = DelidPlateTask._resolve_lid_gripper_offset( + self._destination_plate + ) + DelidPlateTask._lid_resting_height(self._destination_plate) + self._positions = self._calculate_positions() + self._log_plan() + + @staticmethod + def _is_lid(labware: Labware) -> bool: + metadata = labware.metadata or {} + base_class = str(metadata.get("base_class") or "").lower() + kind = str(metadata.get("kind") or labware.labware_type or "").lower() + return base_class == "lid" or kind == "lid" + + def _get_destination_plate(self) -> Labware: + top = self._deck.get_stack(self._to_location).top + if top is None: + raise RuntimeError(f"No labware is present at location {self._to_location}") + if self._is_lid(top): + raise RuntimeError( + f"Destination location {self._to_location} must contain a plate, not a lid" + ) + return top + + def _destination_place_support_height(self) -> float: + # Relid geometry places relative to the visible top plate at the + # destination, then adds lid_resting_height into the gripper offset. + return self._current_location_height(self._to_location) + + def _calculate_positions(self) -> PickPlacePositions: + source_support_height = self._source_pick_support_height() + destination_support_height = self._destination_place_support_height() + + pick_z, pick_zg = self._solve_pick_or_place( + self._from_location, + source_support_height, + self._pick_gripper_offset, + ) + place_z, place_zg = self._solve_pick_or_place( + self._to_location, + destination_support_height, + self._place_gripper_offset, + ) + + obstacle_height = self._obstacle_height_between_locations() + # Relid carries a lid over the visible top of the destination plate, so + # the carry height must clear the destination's full top height, not + # just the support plane under that plate. + carry_stack = ( + max( + self._current_complete_height(self._from_location), + self._current_complete_height(self._to_location), + obstacle_height, + ) + + Z_CLEARANCE + ) + carry_z, carry_zg = self._solve_pick_or_place( + self._from_location, + carry_stack, + self._place_gripper_offset, + ) + carry_z = self._clamp(carry_z, "z") + carry_zg = self._clamp(carry_zg, "zg") + carry_z, carry_zg = self._adjust_for_head_clearance(carry_z, carry_zg, self._source_labware) + + return PickPlacePositions( + pick_z=pick_z, + pick_zg=pick_zg, + carry_z=carry_z, + carry_zg=carry_zg, + place_z=place_z, + place_zg=place_zg, + ) + + def _build_lidded_plate(self, plate: Labware) -> Labware: + metadata = dict(plate.metadata or {}) + metadata["is_lidded"] = True + metadata["is_sealed"] = bool(plate.is_sealed) + height = float( + metadata.get("lidded_height_mm") or metadata.get("total_height_mm") or plate.height + ) + stack_height = float( + metadata.get("lidded_stack_height_mm") or metadata.get("stack_height_mm") or height + ) + metadata["height_mm"] = height + metadata["stack_height_mm"] = stack_height + metadata["total_height_mm"] = height + generated_lid = generated_lid_metadata(metadata) + if generated_lid is not None: + metadata["generated_lid"] = generated_lid + return Labware( + id=plate.id, + definition_id=plate.definition_id, + name=plate.name, + height=height, + width=plate.width, + length=plate.length, + labware_type=plate.labware_type, + gripper_offset=plate.gripper_offset, + stack_height=stack_height, + is_lidded=True, + is_sealed=plate.is_sealed, + wells=plate.wells, + metadata=metadata, + ) + + async def _grip_plate(self) -> None: + grip_speed = self._speed if self._speed != "slow" else "med" + if self._grip_attempts > 0: + self._log_step("reopen_gripper_for_retry", targets={"G": OPEN_GRIPPER_POSITION}) + await asyncio.to_thread(self._ctrl.open_gripper) + before_snapshot = self._snapshot() + self._plate_pick_verified = False + self._log_step("grip_lid", targets={"G": _PICK_PLACE_GRIP_TARGET}) + await asyncio.to_thread(self._ctrl.grip, grip_speed, _PICK_PLACE_GRIP_TARGET, True) + self._grip_attempts += 1 + after_snapshot = self._snapshot() + verified, verification = self._verify_plate_pickup(before_snapshot, after_snapshot) + if not verified: + before_snapshot = self._live_snapshot(force_refresh=False) + after_snapshot = self._live_snapshot(force_refresh=True) + verified, verification = self._verify_plate_pickup(before_snapshot, after_snapshot) + self._live_status.update( + { + "pickup_verification": verification, + "operator_prompt": None + if verified + else { + "kind": "pickup_verification_failed", + "title": "Lid pickup not detected", + "message": ( + "The gripper closed, but the post-grip G position indicates the lid was not picked up." + ), + "choices": ["retry", "ignore", "abort"], + }, + } + ) + if not verified: + raise RuntimeError("Lid pickup not detected after gripper close") + self._plate_pick_verified = True + self._force_continue_after_pickup_failure = False + self._log_step("grip_lid_complete") + + async def _release_plate(self) -> None: + if not self._plate_pick_verified and not self._force_continue_after_pickup_failure: + self._log_step("release_lid_skipped_missing_lid", targets={"G": OPEN_GRIPPER_POSITION}) + await asyncio.to_thread(self._ctrl.open_gripper) + self._log_step("release_lid_skipped_missing_lid_complete") + return + self._log_step("release_lid", targets={"G": OPEN_GRIPPER_POSITION}) + await asyncio.to_thread(self._ctrl.open_gripper) + self._log_step("release_lid_complete") + removed_lid = self._deck.remove(self._from_location) + if removed_lid is not self._source_lid: + logger.warning("Relid source stack changed during task; using runtime top lid state") + removed_plate = self._deck.remove(self._to_location) + if removed_plate is not self._destination_plate: + logger.warning("Relid destination stack changed during task; using runtime top plate state") + self._deck.add(self._to_location, self._build_lidded_plate(removed_plate)) + + +class ScanStackHeightTask(StateMachineTask): + """Scan a deck location with the gripper plate sensor to infer stack count.""" + + def __init__( + self, + controller: BravoController, + teachpoints: Teachpoints, + config: BravoMachineConfig, + deck: DeckState, + *, + location: int, + template_labware: Labware, + expected_count: Optional[int] = None, + ) -> None: + """Initialize the task. + + Args: + controller: The controller to operate. + teachpoints: The deck teachpoints to move against. + config: The machine configuration to scan against. + deck: The deck state, used only by the simulation short-circuit. + location: The deck location to scan. + template_labware: The labware type to measure the stack against + (its ``height``/``stack_height`` set the per-plate increment). + expected_count: If given, the scan raises an operator prompt when + the inferred count differs from this. + """ + super().__init__(name=f"ScanStackHeight_{location}") + self._ctrl = controller + self._tp = teachpoints + self._config = config + self._deck = deck + self._location = int(location) + self._template = template_labware + self._expected_count: Optional[int] = ( + int(expected_count) if expected_count is not None else None + ) + self._result: Dict[str, Any] = { + "status": "pending", + "location": self._location, + "configured_labware": self._template.name, + "used_manual_override": False, + } + self._scan_xy: Optional[Tuple[float, float]] = None + self._baseline_sum: Optional[float] = None + self._start_zg: Optional[float] = None + self._end_zg: Optional[float] = None + self._operator_prompt: Optional[Dict[str, Any]] = None + # Per-step status surfaced for a live viewport so it has motion + # waypoints to tween between during the scan. _live_status mirrors + # PickPlaceTask's pattern: {"task": ..., "step": ..., "targets": {...}}. + # Pure metadata -- no hardware impact. + self._live_status: Dict[str, Any] = { + "task": "scan_stack_height", + "step": None, + "location": self._location, + "targets": {}, + } + # Index of the scan step so on_error_action(RETRY) can re-scan rather + # than just re-run the validation with the same cached measurement. + self._scan_step_index = 3 + + def result_payload(self) -> Dict[str, Any]: + """Return the scan's result payload.""" + return dict(self._result) + + def status_payload(self) -> dict: + """Return the task's live status, merged with the current step/targets.""" + payload = dict(self._result) + # Merges in live step/targets so a live viewport can be driven from + # this even though Zg readback is frozen during the firmware-level + # scan command. + payload.update( + { + "task": self._live_status.get("task"), + "step": self._live_status.get("step"), + "targets": dict(self._live_status.get("targets") or {}), + } + ) + if self.status == TaskStatus.FAILED and self._operator_prompt: + payload["operator_prompt"] = dict(self._operator_prompt) + return payload + + def on_error_action(self, action: ErrorAction) -> None: + """Apply the operator's choice for the step that just failed. + + On RETRY, rewinds to the scan step so the measurement is retaken + before re-validating. Clears the prompt so a fresh mismatch can + populate it. + """ + if action == ErrorAction.RETRY: + self._current_step_index = self._scan_step_index + self._operator_prompt = None + elif action == ErrorAction.IGNORE: + self._operator_prompt = None + + def get_steps(self) -> "list[tuple[str, Callable[[], Awaitable[None]]]]": + """Return this task's steps, in execution order.""" + return [ + ("move_to_safe_start", self._move_to_safe_start), + ("move_xy_to_scan", self._move_xy_to_scan), + ("move_to_scan_start", self._move_to_scan_start), + ("scan_with_plate_sensor", self._scan_with_plate_sensor), + ("validate_expected_count", self._validate_expected_count), + ("return_gripper_to_nesting", self._return_gripper_to_nesting), + ] + + def _log(self, message: str) -> None: + logger.info("ScanStack %s", message) + + def _log_step( + self, + name: str, + *, + targets: Optional[Dict[str, float]] = None, + message: Optional[str] = None, + ) -> None: + """Record a scan step's motion targets for a live viewport to consume. + + Mirrors PickPlaceTask._log_step: a viewport lerps its joints toward + whatever axes appear in ``targets``. This is pure metadata -- the + actual hardware moves are still issued by the step body. + """ + self._live_status["step"] = name + self._live_status["targets"] = dict(targets or {}) + if message: + logger.info("ScanStack %s %s", name, message) + else: + logger.info("ScanStack %s", name) + if targets: + target_text = " ".join(f"{axis}={value:.3f}" for axis, value in targets.items()) + logger.info("ScanStack %s target=%s", name, target_text) + + def _gripper_y_offset(self) -> float: + return float(self._config.gripper.y_offset or 0.0) + + def _axis_range(self, axis: Axis) -> Tuple[float, float]: + cfg = self._config.axes.get(axis) + if cfg is None: + raise RuntimeError(f"Missing axis config for {axis_display_name(axis)}") + return cfg.range.min_pos, cfg.range.max_pos + + def _clamp(self, value: float, axis: Axis) -> float: + min_pos, max_pos = self._axis_range(axis) + return max(min_pos, min(max_pos, value)) + + def _get_current_z(self) -> float: + try: + return float(self._ctrl.get_position("z")) + except Exception: + return float(self._config.safety.z_safe_position) + + def _tip_length_for_pick_place(self) -> float: + stored_length = self._config.head.teach_tip_length_mm + if stored_length is not None: + return float(stored_length) + default_capacity = float( + self._config.head.teach_tip_capacity or self._config.head.default_tip_capacity or 0.0 + ) + tip_ref = self._config.head.teach_tip_id or self._config.head.default_tip_id or default_capacity + tip_length = get_tip_length_mm(self._config.head.head_type, tip_ref) + if tip_length is None: + raise RuntimeError(f"Teach tip length is not configured for {self._config.head.head_type}") + return tip_length + + def _gripper_pad_reference_zg(self, tip_length: float) -> float: + """Return the Zg for the plate-pad plane; see PickPlaceTask._gripper_pad_reference_zg.""" + g = self._config.gripper + return g.pad_zg_reference_mm + (tip_length - g.pad_reference_tip_length_mm) + + def _pad_plane_sum(self) -> float: + """Return the ``Z + Zg`` sum at the plate-pad plane -- the datum the scan measures against. + + Height above the pad is a function of ``Z + Zg`` alone, so this stays + valid wherever the gripper actually ends up. It deliberately does not + go through :meth:`_solve_pick_or_place`, which clamps both axes into + their travel ranges: right for a move target, but it distorts a + datum whenever the geometry saturates, and the distortion is silent. + + No gripper offset appears here. The scan senses where the top of the + stack is; where on a plate the jaws would grab it is a different + question and must not shift the measurement. + """ + z_teachpoint = self._tp.get_teachpoint(self._location, "z") + tip_length = self._tip_length_for_pick_place() + if head_type_is_disposable(self._config.head.head_type): + return z_teachpoint + self._gripper_pad_reference_zg(tip_length) + return ( + z_teachpoint + + tip_length + - GRIPPER_THICKNESS + - GRIPPER_TO_BASE_OF_HEAD_GAP + + _LENGTH_DIFFERENCE_96_TO_384 + ) + + def _solve_pick_or_place(self, stack_height: float, gripper_offset: float) -> Tuple[float, float]: + z_min, _ = self._axis_range("z") + _, zg_max = self._axis_range("zg") + zg_max = min(zg_max, _PLATE_HANDLING_ZG_MAX) + z_current = self._get_current_z() + z_teachpoint = self._tp.get_teachpoint(self._location, "z") + tip_length = self._tip_length_for_pick_place() + if head_type_is_disposable(self._config.head.head_type): + new_zg = ( + z_teachpoint + - z_current + + self._gripper_pad_reference_zg(tip_length) + - gripper_offset + - stack_height + ) + else: + new_zg = ( + z_teachpoint + - z_current + + tip_length + - GRIPPER_THICKNESS + - GRIPPER_TO_BASE_OF_HEAD_GAP + - gripper_offset + - stack_height + + _LENGTH_DIFFERENCE_96_TO_384 + ) + safe_zg = self._clamp(self._config.axes["zg"].range.min_pos, "zg") + if new_zg > zg_max: + z = z_current + new_zg - zg_max + zg = zg_max + elif new_zg < safe_zg: + z = z_current + new_zg - safe_zg + if z < z_min and (safe_zg + z) >= safe_zg: + safe_zg += z + z = z_min + zg = safe_zg + else: + z = z_current + zg = new_zg + return self._clamp(z, "z"), self._clamp(zg, "zg") + + async def _move_to_safe_start(self) -> None: + safe_z = float(self._config.safety.z_safe_position) + self._log_step( + "move_to_safe_start", + targets={"Z": safe_z, "Zg": _GRIPPER_RECESS_DEPTH, "G": OPEN_GRIPPER_POSITION}, + message=f"Z={safe_z:.3f}", + ) + await asyncio.to_thread(self._ctrl.move, [_axis_move(self._ctrl, "z", safe_z)], True) + await asyncio.to_thread(self._ctrl.open_gripper) + await asyncio.to_thread( + self._ctrl.move, [_axis_move(self._ctrl, "zg", _GRIPPER_RECESS_DEPTH)], True + ) + + async def _move_xy_to_scan(self) -> None: + x = self._tp.get_teachpoint(self._location, "x") + y = self._tp.get_teachpoint(self._location, "y") + self._gripper_y_offset() + self._scan_xy = (x, y) + self._log_step( + "move_xy_to_scan", + targets={"X": x, "Y": y}, + message=f"X={x:.3f} Y={y:.3f}", + ) + await asyncio.to_thread( + self._ctrl.move, + [_axis_move(self._ctrl, "x", x), _axis_move(self._ctrl, "y", y)], + True, + ) + + async def _move_to_scan_start(self) -> None: + # Two different things, kept deliberately separate. + # + # Where to PARK the gripper for the scan: the ordinary pick geometry, + # which is gripper-offset aware so the jaws start clear of the stack. + baseline_z, baseline_zg = self._solve_pick_or_place(0.0, float(self._template.gripper_offset)) + # What the reading is MEASURED AGAINST: the plate-pad plane. A + # gripper-offset-aware solve here would make every reading short by + # the offset and bias the inferred count differently for each + # labware, since the offset is labware-specific but the pad plane is + # not. + self._baseline_sum = self._pad_plane_sum() + self._start_zg = max( + _GRIPPER_RECESS_DEPTH, baseline_zg - float(self._config.safety.approach_height or 10.0) + ) + self._end_zg = min(self._axis_range("zg")[1], baseline_zg + 120.0) + self._log_step( + "move_to_scan_start", + targets={"Z": baseline_z, "Zg": float(self._start_zg)}, + message=( + f"Z={baseline_z:.3f} Zg={self._start_zg:.3f} " + f"pad_datum_sum={self._baseline_sum:.3f} end_zg={self._end_zg:.3f}" + ), + ) + await asyncio.to_thread( + self._ctrl.move, + [ + _axis_move(self._ctrl, "z", baseline_z), + _axis_move(self._ctrl, "zg", float(self._start_zg)), + ], + True, + ) + + async def _scan_with_plate_sensor(self) -> None: + # Simulation short-circuit: no real plate sensor, so relies on the + # virtual deck as ground truth. The physical scan path would see + # zero height (nothing in the air to trigger the sensor) and fall + # into manual_count_required, defeating the whole point of running a + # simulation. Synthesizes a scan result from the current stack depth + # instead. + if isinstance(self._ctrl, SimulationController): + stack = self._deck.get_stack(self._location) + live_count = len(stack) + stack_height = float(self._template.stack_height or self._template.height or 0.0) + plate_height = float(self._template.height or 0.0) + if stack_height <= 0.0: + raise RuntimeError( + f"Configured labware at location {self._location} has no stacking thickness" + ) + # Reconstructs measured_height the same way the physical path + # would have for this count, so downstream consumers see + # consistent units. + theoretical_height = _stacking_support_height_for_count(live_count, stack_height) + estimated_total_height = _stack_total_height_for_count(live_count, plate_height, stack_height) + self._result = { + "status": "completed", + "location": self._location, + "configured_labware": self._template.name, + # measured_height_mm is the top-of-stack height (includes the + # top plate's own height) to match the real-hardware path's + # contract; the support height is reported as + # theoretical_height_mm. + "measured_height_mm": estimated_total_height, + "raw_measured_height_mm": estimated_total_height, + "height_offset_mm": 0.0, + "stack_height_mm": stack_height, + "plate_height_mm": plate_height, + "inferred_count": live_count, + "theoretical_height_mm": theoretical_height, + "estimated_total_height_mm": estimated_total_height, + "rounded_stack_height_mm": round(theoretical_height), + "used_manual_override": False, + "baseline_sum_mm": self._baseline_sum, + "scan_start_zg_mm": self._start_zg, + "scan_end_zg_mm": self._end_zg, + "trigger_z_mm": None, + "trigger_zg_mm": None, + "simulated": True, + "message": ( + f"[simulation] Deck state reports {live_count} plate(s) at " + f"location {self._location}; stacking thickness " + f"{stack_height:.3f} mm." + ), + } + return + + # Publishes the scan's end waypoint so a live viewport can animate + # toward it during the firmware-blocking scan call. Zg readback is + # frozen during scan_stack_with_gripper on real hardware, so without + # this motion target the viewport sits still for the full scan + # duration. + # Always set by _move_to_scan_start, which runs immediately before + # this step in get_steps(). + assert self._start_zg is not None + assert self._end_zg is not None + self._log_step( + "scan_with_plate_sensor", + targets={"Zg": float(self._end_zg)}, + message=f"start_zg={self._start_zg:.3f} end_zg={self._end_zg:.3f}", + ) + transient = float(self._config.safety.plate_sensor_transient) + result = await asyncio.to_thread( + self._ctrl.scan_stack_with_gripper, + start_zg=float(self._start_zg), + end_zg=float(self._end_zg), + speed="slow", + transient=transient, + ) + scan_debug = { + "scan_mode": result.get("scan_mode"), + "scan_stop_strategy": result.get("stop_strategy"), + "scan_elapsed_ms": result.get("elapsed_ms"), + "scan_poll_count": result.get("poll_count"), + "scan_sensor_reads": result.get("sensor_reads"), + "scan_sensor_read_failures": result.get("sensor_read_failures"), + "scan_transient_ms": transient * 1000.0, + } + detected = bool(result.get("detected", False)) + if not detected: + self._result = { + "status": "manual_count_required", + "location": self._location, + "configured_labware": self._template.name, + "used_manual_override": False, + "message": ( + f"No plate detected during scan at location {self._location}. " + "Enter the number of stacked plates." + ), + } + self._result.update({key: value for key, value in scan_debug.items() if value is not None}) + return + + measured_height_raw = result.get("measured_height_mm") + if measured_height_raw is not None: + # The controller reported a height above the support surface + # directly, which is already the datum the count model wants. + measured_height_unadjusted = max(0.0, float(measured_height_raw)) + current_z = None + current_zg = None + sensor_correction = 0.0 + measured_height = measured_height_unadjusted + else: + current_z = float(self._ctrl.get_position("z")) + current_zg = float(self._ctrl.get_position("zg")) + # _baseline_sum is the plate-pad plane, so this is already the + # height of the sensor trigger point above the pad. The only + # thing left to remove is how far above the plate's top face the + # sensor fires, which is a fixed property of the gripper. + measured_height_unadjusted = max( + 0.0, float(self._baseline_sum or 0.0) - (current_z + current_zg) + ) + sensor_correction = -_SCAN_SENSOR_STANDOFF_MM + measured_height = max(0.0, measured_height_unadjusted + sensor_correction) + stack_height = float(self._template.stack_height or self._template.height or 0.0) + plate_height = float(self._template.height or 0.0) + if stack_height <= 0.0: + raise RuntimeError( + f"Configured labware at location {self._location} has no stacking thickness" + ) + # measured_height is the top-of-stack height above the support + # surface, so it includes the top plate's own height. Subtracting it + # recovers the support height before inferring the count -- otherwise + # a single tall plate (whose height ~ its stacking thickness) reads + # as a phantom 2nd plate. See _infer_stack_count_from_scan_height. + inferred_count = _infer_stack_count_from_scan_height( + measured_height, stack_height, plate_height + ) + theoretical_height = _stacking_support_height_for_count(inferred_count, stack_height) + estimated_total_height = _stack_total_height_for_count( + inferred_count, plate_height, stack_height + ) + rounded_stack_height = round(theoretical_height) + self._result = { + "status": "completed", + "location": self._location, + "configured_labware": self._template.name, + "measured_height_mm": measured_height, + "raw_measured_height_mm": measured_height_unadjusted, + "height_offset_mm": sensor_correction, + "sensor_standoff_mm": _SCAN_SENSOR_STANDOFF_MM, + "stack_height_mm": stack_height, + "plate_height_mm": plate_height, + "inferred_count": inferred_count, + "theoretical_height_mm": theoretical_height, + "estimated_total_height_mm": estimated_total_height, + "rounded_stack_height_mm": rounded_stack_height, + "used_manual_override": False, + "baseline_sum_mm": self._baseline_sum, + "scan_start_zg_mm": self._start_zg, + "scan_end_zg_mm": self._end_zg, + "trigger_z_mm": current_z, + "trigger_zg_mm": current_zg, + "message": ( + f"Measured scan height {measured_height:.3f} mm " + f"(raw {measured_height_unadjusted:.3f} mm above the pad, sensor " + f"standoff {_SCAN_SENSOR_STANDOFF_MM:.3f} mm); " + f"stacking thickness {stack_height:.3f} mm; " + f"inferred {inferred_count} plates; rounded stacking height {rounded_stack_height:.0f} mm." + ), + } + self._result.update({key: value for key, value in scan_debug.items() if value is not None}) + + async def _validate_expected_count(self) -> None: + # Skips entirely if no expectation was set on this task -- the + # caller left it unset, meaning "just report whatever was measured." + if self._expected_count is None: + return + inferred = int(self._result.get("inferred_count") or 0) + expected = int(self._expected_count) + if inferred == expected: + return + measured = float(self._result.get("measured_height_mm") or 0.0) + stack_height = float(self._result.get("stack_height_mm") or 0.0) + message = ( + f"Stack-count mismatch at location {self._location}.\n\n" + f"Expected {expected} plate(s) but measured {inferred} plate(s) " + f"(measured height {measured:.2f} mm, stacking thickness " + f"{stack_height:.2f} mm).\n\n" + "Retry re-scans the stack.\n" + "Ignore continues with the measured count.\n" + "Abort stops the workflow." + ) + self._operator_prompt = { + "kind": "scan_stack_height_mismatch", + "title": "Stack count mismatch", + "message": message, + "choices": ["retry", "ignore", "abort"], + "expected_count": expected, + "inferred_count": inferred, + "location": self._location, + } + # Also surfaces the mismatch on the result payload so downstream + # consumers can see it even after IGNORE. + self._result["expected_count"] = expected + self._result["count_mismatch"] = True + raise RuntimeError(message) + + async def _return_gripper_to_nesting(self) -> None: + safe_z = float(self._config.safety.z_safe_position) + self._log_step( + "return_gripper_to_nesting", + targets={"Zg": _GRIPPER_RECESS_DEPTH, "Z": safe_z}, + ) + await asyncio.to_thread( + self._ctrl.move, [_axis_move(self._ctrl, "zg", _GRIPPER_RECESS_DEPTH)], True + ) + await asyncio.to_thread(self._ctrl.move, [_axis_move(self._ctrl, "z", safe_z)], True) diff --git a/pylabrobot/agilent/bravo/state_machine/tasks_tests.py b/pylabrobot/agilent/bravo/state_machine/tasks_tests.py new file mode 100644 index 00000000000..36096fb7906 --- /dev/null +++ b/pylabrobot/agilent/bravo/state_machine/tasks_tests.py @@ -0,0 +1,429 @@ +"""Unit tests for module-level helpers in :mod:`.tasks`. + +Motion-sequencing behavior is covered by the golden-frame test modules in +this package; this module is for helpers whose contract is best pinned +directly rather than through a full task run. +""" + +from __future__ import annotations + +import asyncio +import unittest + +from ..config import BravoMachineConfig +from ..controllers.agile_7612 import Agile7612Controller +from ..controllers.simulation import SimulationController +from ..darwin.controller import DarwinController +from ..darwin.darwin_golden_frame_tests import FakeGeminiTransport +from ..deck.labware import DeckState, Labware +from ..deck.teachpoints import Teachpoints +from ..head_mode import TipSelection, normalize_head_mode +from ..protocol.v11_comm_tests import BufferedTransport +from .tasks import ( + AspirateTask, + PickPlaceTask, + TipsOffTask, + TipsOnTask, + _axis_move, + _infer_stack_count_from_scan_height, + _stack_total_height_for_count, + _stacking_support_height_for_count, + _w_axis_motion_value, +) + + +class AxisMovePositionIsNeverConvertedTests(unittest.TestCase): + """_axis_move cannot tell a volume from a park/offset position by looking + at a bare float, so it never converts ``position`` for any axis -- + including W. A caller that has a genuine volume converts it itself, + before combining it with a controller-native quantity (see + AspirateVolumeConversionTests below); a caller with a millimetre value + (a park position, a teachpoint, an offset-table entry) passes it straight + through. + """ + + def test_w_position_passes_through_unconverted_on_agile(self): + ctrl = SimulationController(head_type="96_d_70") + move = _axis_move(ctrl, "w", 50.0) + self.assertEqual(move.position, 50.0) + + def test_w_position_passes_through_unconverted_on_darwin(self): + ctrl = DarwinController(FakeGeminiTransport()) + ctrl.set_head_type("96_d_70") + move = _axis_move(ctrl, "w", -11.0) + self.assertEqual(move.position, -11.0) + + def test_non_w_position_is_also_never_converted(self): + ctrl = DarwinController(FakeGeminiTransport()) + ctrl.set_head_type("96_d_70") + move = _axis_move(ctrl, "z", 50.0, velocity=25.0, acceleration=250.0) + self.assertEqual(move.position, 50.0) + + +class AxisMoveWVelocityConversionTests(unittest.TestCase): + """Unlike position, a W-axis velocity/acceleration passed to _axis_move + is always a volume rate (every caller supplies it from a liquid class's + w_velocity_ul_s/w_acceleration_ul_s2 entries), so converting it here is + unambiguous and correct. + """ + + def test_w_velocity_and_acceleration_are_unconverted_on_agile(self): + ctrl = SimulationController(head_type="96_d_70") + move = _axis_move(ctrl, "w", 50.0, velocity=25.0, acceleration=250.0) + self.assertEqual(move.velocity, 25.0) + self.assertEqual(move.acceleration, 250.0) + + def test_w_velocity_and_acceleration_are_converted_on_darwin(self): + ctrl = DarwinController(FakeGeminiTransport()) + ctrl.set_head_type("96_d_70") + move = _axis_move(ctrl, "w", 50.0, velocity=25.0, acceleration=250.0) + self.assertNotEqual(move.velocity, 25.0) + self.assertNotEqual(move.acceleration, 250.0) + self.assertEqual(move.velocity, ctrl.ul_to_mm(25.0)) + self.assertEqual(move.acceleration, ctrl.ul_to_mm(250.0)) + + def test_non_w_axis_velocity_and_acceleration_are_never_converted(self): + ctrl = DarwinController(FakeGeminiTransport()) + ctrl.set_head_type("96_d_70") + move = _axis_move(ctrl, "z", 50.0, velocity=25.0, acceleration=250.0) + self.assertEqual(move.velocity, 25.0) + self.assertEqual(move.acceleration, 250.0) + + +class WAxisMotionValueTests(unittest.TestCase): + """_w_axis_motion_value is the one place a caller-known volume is + converted to a controller's native W unit -- callers combine its result + with a controller-native quantity (e.g. a current W position) themselves, + rather than handing an already-combined value back through _axis_move. + """ + + def test_matches_the_head_specific_ul_to_mm_factor(self): + # Hardcoded expected values, independent of ul_to_mm() itself. + darwin = DarwinController(FakeGeminiTransport()) + darwin.set_head_type("96_d_70") + self.assertAlmostEqual(_w_axis_motion_value(darwin, 50.0), 11.2, places=6) + + darwin.set_head_type("384_d_70") + self.assertAlmostEqual(_w_axis_motion_value(darwin, 50.0), 42.3, places=6) + + agile = SimulationController(head_type="96_d_70") + self.assertEqual(_w_axis_motion_value(agile, 50.0), 50.0) + + def test_falls_back_to_unconverted_when_conversion_fails(self): + # No head type has been set on this Darwin controller (still "unknown"), + # so ul_to_mm() has nothing to convert against and raises. + ctrl = DarwinController(FakeGeminiTransport()) + self.assertEqual(_w_axis_motion_value(ctrl, 50.0), 50.0) + + +class AspirateVolumeConversionTests(unittest.TestCase): + """AspirateTask._aspirate_volume combines a controller-native current W + position with a converted volume delta itself, then hands the combined, + already-native result to _axis_move -- which must not convert it again. + A regression that reintroduces position conversion inside _axis_move + would double-convert this on Darwin (identity on Agile, so a + SimulationController-based golden fixture cannot catch it). + """ + + def _run_aspirate_volume(self, ctrl, *, current_w: float): + original_get_position = ctrl.get_position + + def stub_get_position(axis): + if axis == "w": + return current_w + return original_get_position(axis) + + ctrl.get_position = stub_get_position # type: ignore[method-assign] + + moves: list = [] + + def recording_move(move_list, wait=True, timeout=30.0): + moves.extend(move_list) + + ctrl.move = recording_move # type: ignore[method-assign] + + task = AspirateTask( + ctrl, + _teachpoints(), + 3, + volume=50.0, + head_type="96_f_50", # fixed-tip: no attached/taught tip length needed + ) + asyncio.run(task._aspirate_volume()) + return [m for m in moves if m.axis == "w"][0] + + def test_agile_combines_current_position_and_volume_directly(self): + ctrl = SimulationController(head_type="96_d_70") + move = self._run_aspirate_volume(ctrl, current_w=10.0) + self.assertEqual(move.position, 10.0 + 50.0) + + def test_darwin_combines_current_position_and_converted_volume_once(self): + ctrl = DarwinController(FakeGeminiTransport()) + ctrl.set_head_type("96_d_70") + current_w_mm = 5.0 + move = self._run_aspirate_volume(ctrl, current_w=current_w_mm) + expected = current_w_mm + ctrl.ul_to_mm(50.0) + self.assertAlmostEqual(move.position, expected, places=6) + # A double conversion (the bug this test guards against) would apply + # ul_to_mm to the whole sum again, which is a different, smaller number + # for any factor other than 1.0. + self.assertNotAlmostEqual(move.position, ctrl.ul_to_mm(expected), places=6) + + +def _tipbox() -> Labware: + return Labware( + id="lw-tipbox96", + name="Test 96 Tip Box", + height=60.0, + width=85.5, + length=127.5, + wells=96, + metadata={"rows": 8, "cols": 12, "spacing_x_mm": 9.0, "spacing_y_mm": 9.0}, + ) + + +def _teachpoints() -> Teachpoints: + teachpoints = Teachpoints() + teachpoints.set_default_teachpoints("96_d_70") + return teachpoints + + +def _plate(name: str = "lw-plate") -> Labware: + return Labware( + id=name, + name=name, + height=14.0, + width=85.5, + length=127.5, + gripper_offset=2.0, + wells=96, + metadata={"rows": 8, "cols": 12, "spacing_x_mm": 9.0, "spacing_y_mm": 9.0}, + ) + + +class PickPlaceAlreadyGrippedTests(unittest.TestCase): + """plate_already_gripped seeds the state a completed grip leaves behind, + as a supported constructor argument rather than a caller poking + _plate_pick_verified/_grip_attempts directly. + """ + + def _deck_with_plate_at(self, location: int) -> DeckState: + deck = DeckState() + deck.set_single(location, _plate()) + return deck + + @staticmethod + def _config() -> BravoMachineConfig: + config = BravoMachineConfig() + config.head.teach_tip_length_mm = 26.1 + return config + + def test_default_construction_is_not_gripped(self): + task = PickPlaceTask( + SimulationController(), + _teachpoints(), + self._config(), + self._deck_with_plate_at(1), + 1, + 2, + ) + self.assertFalse(task._plate_pick_verified) + self.assertEqual(task._grip_attempts, 0) + + def test_plate_already_gripped_seeds_verified_state(self): + task = PickPlaceTask( + SimulationController(), + _teachpoints(), + self._config(), + self._deck_with_plate_at(1), + 1, + 2, + plate_already_gripped=True, + ) + self.assertTrue(task._plate_pick_verified) + self.assertEqual(task._grip_attempts, 1) + + def test_release_plate_moves_the_deck_group_only_when_already_gripped(self): + """_release_plate's deck-state update (remove from source, add to + destination) is gated on _plate_pick_verified; a task constructed + without plate_already_gripped takes the "skipped" branch and leaves + the deck alone, so this pins that plate_already_gripped is what makes + the transfer happen, not merely a status flag. + """ + deck = self._deck_with_plate_at(1) + task = PickPlaceTask( + SimulationController(), + _teachpoints(), + self._config(), + deck, + 1, + 2, + plate_already_gripped=True, + ) + asyncio.run(task._release_plate()) + self.assertIsNone(deck.get_stack(1).top) + self.assertIsNotNone(deck.get_stack(2).top) + + def test_release_plate_without_already_gripped_leaves_the_deck_alone(self): + deck = self._deck_with_plate_at(1) + task = PickPlaceTask( + SimulationController(), + _teachpoints(), + self._config(), + deck, + 1, + 2, + ) + asyncio.run(task._release_plate()) + self.assertIsNotNone(deck.get_stack(1).top) + self.assertIsNone(deck.get_stack(2).top) + + +class TipsOffEjectPositionIsNeverConvertedTests(unittest.TestCase): + """TipsOffTask._eject_tips drives W to + config.safety.tips_off_w_position -- a plunger park position in + millimetres (SafetyConfig.tips_off_w_position defaults to -11.0, a + negative value; no volume is ever negative), not a volume. It must reach + the controller unconverted on every generation, Darwin included. + """ + + def _run_eject(self, ctrl): + config = BravoMachineConfig() + config.head.head_type = "96_d_70" + config.head.teach_tip_length_mm = 26.1 + mode = normalize_head_mode("96_d_70", "all_barrels", None) + task = TipsOffTask( + ctrl, + _teachpoints(), + config, + _tipbox(), + mode, + TipSelection(location=3, row=0, col=0), + 3, + attached_tip_length_mm=26.1, + ) + # Intercepts move() entirely rather than delegating to the real + # implementation: this pins what _eject_tips() constructs and hands to + # the controller, independent of what a specific backend's move() then + # does with it (which needs a live transport/engine to exercise). + moves: list = [] + original_move = ctrl.move + + def recording_move(move_list, wait=True, timeout=30.0): + moves.extend(move_list) + + ctrl.move = recording_move # type: ignore[method-assign] + try: + asyncio.run(task._eject_tips()) + finally: + ctrl.move = original_move # type: ignore[method-assign] + return [m for m in moves if m.axis == "w"] + + def test_eject_w_target_is_unconverted_on_agile(self): + ctrl = SimulationController(head_type="96_d_70") + eject_move = self._run_eject(ctrl)[0] + self.assertEqual(eject_move.position, -11.0) # SafetyConfig.tips_off_w_position default + + def test_eject_w_target_is_also_unconverted_on_darwin(self): + ctrl = DarwinController(FakeGeminiTransport()) + ctrl.set_head_type("96_d_70") + eject_move = self._run_eject(ctrl)[0] + self.assertEqual(eject_move.position, -11.0) + self.assertNotEqual(eject_move.position, ctrl.ul_to_mm(-11.0)) + + +class TipsOnTipForceJogRoutingTests(unittest.TestCase): + """TipsOnTask._lower_z_to_tips presses with an Agile7612-specific + force-jog sequence when the controller supports it, and the generic + jog() otherwise. + """ + + def _run_lower_z(self, ctrl): + config = BravoMachineConfig() + config.head.head_type = "96_d_70" + config.head.teach_tip_length_mm = 26.1 + mode = normalize_head_mode("96_d_70", "all_barrels", None) + task = TipsOnTask( + ctrl, + _teachpoints(), + config, + _tipbox(), + mode, + TipSelection(location=3, row=0, col=0), + 3, + tip_length_mm=26.1, + ) + asyncio.run(task._lower_z_to_tips()) + + def test_agile_7612_controller_uses_tip_force_jog(self): + ctrl = Agile7612Controller(BufferedTransport()) + calls: list = [] + + def recording_tip_force_jog(axis, peak_current, max_position): + calls.append((axis, peak_current, max_position)) + return max_position + + ctrl.tip_force_jog = recording_tip_force_jog # type: ignore[method-assign] + self._run_lower_z(ctrl) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0][0], "z") + + def test_simulation_controller_uses_generic_jog(self): + ctrl = SimulationController(head_type="96_d_70") + calls: list = [] + original_jog = ctrl.jog + + def recording_jog(params): + calls.append(params) + return original_jog(params) + + ctrl.jog = recording_jog # type: ignore[method-assign] + self._run_lower_z(ctrl) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0].axis, "z") + + +if __name__ == "__main__": + unittest.main() + + +class StackHeightArithmeticTests(unittest.TestCase): + """Pins the stack-height/count helpers directly. + + ScanStackHeightTask's simulation-shortcut path derives its reported + count from the deck's own plate count, not from these helpers -- they + only feed informational height fields on the result, which a + golden-frame comparison (scoped to the ordered controller calls a task + issues) never inspects. A wrong increment here is invisible to every + golden fixture in this package regardless of which scenario exercises + it, so it needs a direct numeric pin instead. + """ + + def test_support_height_is_zero_for_zero_or_one_plate(self): + self.assertEqual(_stacking_support_height_for_count(0, 14.5), 0.0) + self.assertEqual(_stacking_support_height_for_count(1, 14.5), 0.0) + + def test_support_height_is_n_minus_one_times_thickness(self): + self.assertEqual(_stacking_support_height_for_count(3, 14.5), 2 * 14.5) + self.assertEqual(_stacking_support_height_for_count(5, 2.0), 4 * 2.0) + + def test_total_height_is_zero_for_zero_plates(self): + self.assertEqual(_stack_total_height_for_count(0, 14.5, 14.5), 0.0) + + def test_total_height_is_top_plate_plus_support(self): + # Top plate's own height (14.5) plus 2 supporting plates' worth of + # stacking thickness (2 * 14.5). + self.assertEqual(_stack_total_height_for_count(3, 14.5, 14.5), 14.5 + 2 * 14.5) + + def test_infer_count_of_a_single_plate_is_height_independent(self): + # A single plate of any height leaves ~0 support height and always + # resolves to 1, regardless of the plate's own height. + self.assertEqual(_infer_stack_count_from_scan_height(14.5, 14.5, top_plate_height_mm=14.5), 1) + self.assertEqual(_infer_stack_count_from_scan_height(60.0, 14.5, top_plate_height_mm=60.0), 1) + + def test_infer_count_rounds_to_the_nearest_plate(self): + # 3 plates of 14.5 mm: top-of-stack height = 14.5 + 2*14.5 = 43.5. + self.assertEqual(_infer_stack_count_from_scan_height(43.5, 14.5, top_plate_height_mm=14.5), 3) + + def test_infer_count_floors_at_one_for_zero_thickness(self): + self.assertEqual(_infer_stack_count_from_scan_height(50.0, 0.0), 1) diff --git a/pylabrobot/agilent/bravo/state_machine/testdata/task_golden_frames.json b/pylabrobot/agilent/bravo/state_machine/testdata/task_golden_frames.json new file mode 100644 index 00000000000..5c1b54eb3b0 --- /dev/null +++ b/pylabrobot/agilent/bravo/state_machine/testdata/task_golden_frames.json @@ -0,0 +1,9861 @@ +{ + "aspirate_task.aspirate_blocked_by_neighbor_clearance": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + } + ], + "status": "ABORTED" + }, + "aspirate_task.aspirate_full_disposable_with_pre_post_tip_touch": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 192.48, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 115.07300000000001, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_location" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "lower_to_plate_top" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 46.6, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_plate_top" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "pre_aspirate" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 5.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "pre_aspirate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "lower_to_liquid" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 56.46, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_liquid" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "aspirate_volume" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 55.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "aspirate_volume" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "raise_to_plate_top" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 46.6, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "raise_to_plate_top" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "post_aspirate" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 58.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "post_aspirate" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 195.41, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 115.07300000000001, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 192.48, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 118.00300000000001, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 189.54999999999998, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 115.07300000000001, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 192.48, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 112.143, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 192.48, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 115.07300000000001, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "retract_z" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + } + ], + "status": "COMPLETED" + }, + "aspirate_task.aspirate_headtype_fallback_probe": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 102.47999999999999, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 124.07300000000001, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_location" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "lower_to_liquid" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 45.5, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_liquid" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 55.36, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_liquid" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "aspirate_volume" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 20.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "aspirate_volume" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "retract_z" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 45.5, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + } + ], + "status": "COMPLETED" + }, + "aspirate_task.aspirate_partial_block_with_liquid_class_and_swirl": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 210.48, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 61.07300000000001, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_location" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "lower_to_liquid" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 45.5, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_liquid" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 100.0, + "axis": "z", + "position": 55.36, + "velocity": 10.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_liquid" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 210.48, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 60.07300000000001, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_liquid" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 209.48, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 61.07300000000001, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_liquid" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 210.48, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 62.07300000000001, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_liquid" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 211.48, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 61.07300000000001, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_liquid" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 210.48, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 61.07300000000001, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_liquid" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "aspirate_volume" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "aspirate_volume" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 250.0, + "axis": "w", + "position": 31.1, + "velocity": 25.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 55.31, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "aspirate_volume" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "retract_z" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 150.0, + "axis": "z", + "position": 45.5, + "velocity": 15.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + } + ], + "status": "COMPLETED" + }, + "aspirate_task.aspirate_simple_fixed_tip_no_labware": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 379.17, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 5.98, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_location" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "lower_to_liquid" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 60.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_liquid" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 59.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_liquid" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "aspirate_volume" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 50.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "aspirate_volume" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "retract_z" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + } + ], + "status": "COMPLETED" + }, + "delid_plate_task.delid_plate_basic": { + "calls": [ + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": -20.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 1000.0, + "axis": "x", + "position": 379.17, + "velocity": 200.0 + }, + { + "absolute": true, + "acceleration": 1000.0, + "axis": "y", + "position": 5.98, + "velocity": 200.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + }, + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": 54.5, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "grip_lid": true, + "position": 9.0, + "speed": "med" + }, + "method": "grip", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": {}, + "method": "is_plate_in_gripper", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": -1.8, + "velocity": 75.0 + }, + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": 21.8, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 1000.0, + "axis": "x", + "position": 379.17, + "velocity": 200.0 + }, + { + "absolute": true, + "acceleration": 1000.0, + "axis": "y", + "position": 115.07300000000001, + "velocity": 200.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_place_height" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + }, + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": 62.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "position": null + }, + "method": "open_gripper", + "step": "release_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": -20.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + } + ], + "status": "COMPLETED" + }, + "dispense_task.dispense_empty_tips": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 379.17, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 5.98, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_location" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "lower_to_liquid" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 60.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_liquid" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 59.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_liquid" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "dispense_volume" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "dispense_volume" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "retract_z" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + } + ], + "status": "COMPLETED" + }, + "dispense_task.dispense_simple": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 379.17, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 5.98, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_location" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "lower_to_liquid" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 60.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_liquid" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 59.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_liquid" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "dispense_volume" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "dispense_volume" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "retract_z" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + } + ], + "status": "COMPLETED" + }, + "dispense_task.dispense_with_dynamic_retraction_and_blowout_partial_block": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 120.47999999999999, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 133.073, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_location" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "lower_to_liquid" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 45.5, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_liquid" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 80.0, + "axis": "z", + "position": 55.36, + "velocity": 8.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_to_liquid" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "dispense_volume" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 200.0, + "axis": "w", + "position": 0.0, + "velocity": 20.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "dispense_volume" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 123.41, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 133.073, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 120.47999999999999, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 136.00300000000001, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 117.54999999999998, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 133.073, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 120.47999999999999, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 130.143, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 120.47999999999999, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 133.073, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "retract_z" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 45.5, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + } + ], + "status": "COMPLETED" + }, + "dock_gripper_task.dock_gripper_no_plate": { + "calls": [ + { + "args": {}, + "method": "is_plate_in_gripper", + "step": "check_plate_sensor" + }, + { + "args": { + "position": null + }, + "method": "open_gripper", + "step": "open_gripper" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 500.0, + "axis": "zg", + "position": -20.0, + "velocity": 50.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_zg_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "verify_gripper_docked" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "verify_gripper_docked" + } + ], + "status": "COMPLETED" + }, + "dock_gripper_task.dock_gripper_plate_detected_forced": { + "calls": [ + { + "args": {}, + "method": "is_plate_in_gripper", + "step": "check_plate_sensor" + }, + { + "args": { + "position": null + }, + "method": "open_gripper", + "step": "open_gripper" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 500.0, + "axis": "zg", + "position": -20.0, + "velocity": 50.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_zg_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "verify_gripper_docked" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "verify_gripper_docked" + } + ], + "status": "COMPLETED" + }, + "gripper_teach_move_task.gripper_teach_move_basic": { + "calls": [ + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": -20.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 1000.0, + "axis": "x", + "position": 379.17, + "velocity": 200.0 + }, + { + "absolute": true, + "acceleration": 1000.0, + "axis": "y", + "position": 5.98, + "velocity": 200.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_teach_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_teach_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_teach_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_teach_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_teach_height" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + }, + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": 57.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_teach_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_teach_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_teach_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_teach_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_teach_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_teach_height" + } + ], + "status": "COMPLETED" + }, + "home_task.home_all_forced_with_gripper_dock": { + "calls": [ + { + "args": { + "axis": "z" + }, + "method": "is_axis_homed", + "step": "safe_z_retract" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + }, + { + "args": { + "axis": "g" + }, + "method": "is_axis_homed", + "step": "prepare_gripper_safe_state" + }, + { + "args": { + "axis": "zg" + }, + "method": "is_axis_homed", + "step": "prepare_gripper_safe_state" + }, + { + "args": {}, + "method": "is_plate_in_gripper", + "step": "prepare_gripper_safe_state" + }, + { + "args": { + "position": null + }, + "method": "open_gripper", + "step": "prepare_gripper_safe_state" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 500.0, + "axis": "zg", + "position": -20.0, + "velocity": 50.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "prepare_gripper_safe_state" + }, + { + "args": { + "axes": [ + "z", + "zg", + "g", + "x", + "y", + "w" + ], + "force": true + }, + "method": "home_axes", + "step": "home_requested_axes" + }, + { + "args": { + "axis": "x" + }, + "method": "get_park_position", + "step": "park_homed_axes" + }, + { + "args": { + "axis": "y" + }, + "method": "get_park_position", + "step": "park_homed_axes" + }, + { + "args": { + "axis": "z" + }, + "method": "get_park_position", + "step": "park_homed_axes" + }, + { + "args": { + "axis": "w" + }, + "method": "get_park_position", + "step": "park_homed_axes" + }, + { + "args": { + "axis": "g" + }, + "method": "get_park_position", + "step": "park_homed_axes" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_park_position", + "step": "park_homed_axes" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 0.0, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 0.0, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 0.0, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "g", + "position": 0.0, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "zg", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "park_homed_axes" + }, + { + "args": {}, + "method": "is_plate_in_gripper", + "step": "finalize_gripper_safe_state" + }, + { + "args": { + "position": null + }, + "method": "open_gripper", + "step": "finalize_gripper_safe_state" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 500.0, + "axis": "zg", + "position": -20.0, + "velocity": 50.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "finalize_gripper_safe_state" + }, + { + "args": { + "axis": "x" + }, + "method": "is_axis_homed", + "step": "verify_homed" + }, + { + "args": { + "axis": "y" + }, + "method": "is_axis_homed", + "step": "verify_homed" + }, + { + "args": { + "axis": "z" + }, + "method": "is_axis_homed", + "step": "verify_homed" + }, + { + "args": { + "axis": "w" + }, + "method": "is_axis_homed", + "step": "verify_homed" + }, + { + "args": { + "axis": "g" + }, + "method": "is_axis_homed", + "step": "verify_homed" + }, + { + "args": { + "axis": "zg" + }, + "method": "is_axis_homed", + "step": "verify_homed" + } + ], + "status": "COMPLETED" + }, + "home_task.home_xyz_cold": { + "calls": [ + { + "args": { + "axis": "z" + }, + "method": "is_axis_homed", + "step": "safe_z_retract" + }, + { + "args": { + "axes": [ + "z", + "x", + "y" + ], + "force": false + }, + "method": "home_axes", + "step": "home_requested_axes" + }, + { + "args": { + "axis": "x" + }, + "method": "get_park_position", + "step": "park_homed_axes" + }, + { + "args": { + "axis": "y" + }, + "method": "get_park_position", + "step": "park_homed_axes" + }, + { + "args": { + "axis": "z" + }, + "method": "get_park_position", + "step": "park_homed_axes" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 0.0, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 0.0, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "park_homed_axes" + }, + { + "args": { + "axis": "x" + }, + "method": "is_axis_homed", + "step": "verify_homed" + }, + { + "args": { + "axis": "y" + }, + "method": "is_axis_homed", + "step": "verify_homed" + }, + { + "args": { + "axis": "z" + }, + "method": "is_axis_homed", + "step": "verify_homed" + } + ], + "status": "COMPLETED" + }, + "initialize_task.initialize_cold_start_no_gripper": { + "calls": [ + { + "args": {}, + "method": "ping", + "step": "ping_device" + }, + { + "args": {}, + "method": "clear_lights", + "step": "set_light_initializing" + }, + { + "args": { + "command": { + "duty_cycle": 0.8, + "light": "YELLOW", + "period_ms": 1000 + } + }, + "method": "set_light", + "step": "set_light_initializing" + }, + { + "args": {}, + "method": "get_firmware_version", + "step": "query_firmware" + }, + { + "args": {}, + "method": "detect_gripper", + "step": "detect_gripper" + }, + { + "args": {}, + "method": "detect_smart_head", + "step": "detect_head" + }, + { + "args": {}, + "method": "read_smart_head_type", + "step": "detect_head" + }, + { + "args": { + "axis": "x" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": { + "axis": "y" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": { + "axis": "z" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": { + "axis": "w" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": {}, + "method": "query_state", + "step": "check_interlock" + }, + { + "args": { + "command_id": "CLEAR_MOTOR_POWER_FAULT", + "data": "", + "timeout": 2.0 + }, + "method": "send_command", + "step": "clear_motor_power_fault" + }, + { + "args": { + "axes": [ + "x", + "y", + "z", + "w" + ] + }, + "method": "reset_faults", + "step": "reset_faults" + }, + { + "args": { + "axes": [ + "z" + ], + "force": false + }, + "method": "home_axes", + "step": "home_z" + }, + { + "args": { + "axes": [ + "w" + ], + "force": false + }, + "method": "home_axes", + "step": "home_w" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "home_w" + }, + { + "args": { + "axes": [ + "x", + "y" + ], + "force": false + }, + "method": "home_axes", + "step": "home_xy" + }, + { + "args": { + "command": { + "duty_cycle": 1.0, + "light": "GREEN", + "period_ms": 0 + } + }, + "method": "set_light", + "step": "set_light_idle" + } + ], + "status": "COMPLETED" + }, + "initialize_task.initialize_cold_start_with_gripper": { + "calls": [ + { + "args": {}, + "method": "ping", + "step": "ping_device" + }, + { + "args": {}, + "method": "clear_lights", + "step": "set_light_initializing" + }, + { + "args": { + "command": { + "duty_cycle": 0.8, + "light": "YELLOW", + "period_ms": 1000 + } + }, + "method": "set_light", + "step": "set_light_initializing" + }, + { + "args": {}, + "method": "get_firmware_version", + "step": "query_firmware" + }, + { + "args": {}, + "method": "detect_gripper", + "step": "detect_gripper" + }, + { + "args": {}, + "method": "detect_smart_head", + "step": "detect_head" + }, + { + "args": {}, + "method": "read_smart_head_type", + "step": "detect_head" + }, + { + "args": { + "axis": "x" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": { + "axis": "y" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": { + "axis": "z" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": { + "axis": "w" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": { + "axis": "g" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": { + "axis": "zg" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": {}, + "method": "query_state", + "step": "check_interlock" + }, + { + "args": { + "command_id": "CLEAR_MOTOR_POWER_FAULT", + "data": "", + "timeout": 2.0 + }, + "method": "send_command", + "step": "clear_motor_power_fault" + }, + { + "args": { + "axes": [ + "x", + "y", + "z", + "w", + "g", + "zg" + ] + }, + "method": "reset_faults", + "step": "reset_faults" + }, + { + "args": { + "axes": [ + "z" + ], + "force": false + }, + "method": "home_axes", + "step": "home_z" + }, + { + "args": {}, + "method": "is_plate_in_gripper", + "step": "handle_plate_in_gripper" + }, + { + "args": { + "axes": [ + "g" + ], + "force": false + }, + "method": "home_axes", + "step": "home_g" + }, + { + "args": { + "axis": "g" + }, + "method": "disable_motor", + "step": "home_g" + }, + { + "args": { + "axes": [ + "zg" + ], + "force": false + }, + "method": "home_axes", + "step": "home_zg" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "zg", + "position": -20.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_zg_to_nesting" + }, + { + "args": { + "axes": [ + "w" + ], + "force": false + }, + "method": "home_axes", + "step": "home_w" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "home_w" + }, + { + "args": { + "axes": [ + "x", + "y" + ], + "force": false + }, + "method": "home_axes", + "step": "home_xy" + }, + { + "args": { + "command": { + "duty_cycle": 1.0, + "light": "GREEN", + "period_ms": 0 + } + }, + "method": "set_light", + "step": "set_light_idle" + } + ], + "status": "COMPLETED" + }, + "initialize_task.initialize_partial_cold_start_w_only": { + "calls": [ + { + "args": {}, + "method": "ping", + "step": "ping_device" + }, + { + "args": {}, + "method": "clear_lights", + "step": "set_light_initializing" + }, + { + "args": { + "command": { + "duty_cycle": 0.8, + "light": "YELLOW", + "period_ms": 1000 + } + }, + "method": "set_light", + "step": "set_light_initializing" + }, + { + "args": {}, + "method": "get_firmware_version", + "step": "query_firmware" + }, + { + "args": {}, + "method": "detect_gripper", + "step": "detect_gripper" + }, + { + "args": {}, + "method": "detect_smart_head", + "step": "detect_head" + }, + { + "args": {}, + "method": "read_smart_head_type", + "step": "detect_head" + }, + { + "args": { + "axis": "x" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": { + "axis": "y" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": { + "axis": "z" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": { + "axis": "w" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": { + "axis": "g" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": { + "axis": "zg" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": {}, + "method": "query_state", + "step": "check_interlock" + }, + { + "args": { + "command_id": "CLEAR_MOTOR_POWER_FAULT", + "data": "", + "timeout": 2.0 + }, + "method": "send_command", + "step": "clear_motor_power_fault" + }, + { + "args": { + "axes": [ + "x", + "y", + "z", + "w", + "g", + "zg" + ] + }, + "method": "reset_faults", + "step": "reset_faults" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_z_to_safe_position" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_z_to_safe_position" + }, + { + "args": { + "axes": [ + "w" + ], + "force": false + }, + "method": "home_axes", + "step": "home_w" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "home_w" + }, + { + "args": { + "command": { + "duty_cycle": 1.0, + "light": "GREEN", + "period_ms": 0 + } + }, + "method": "set_light", + "step": "set_light_idle" + } + ], + "status": "COMPLETED" + }, + "initialize_task.initialize_warm_start_with_gripper": { + "calls": [ + { + "args": {}, + "method": "ping", + "step": "ping_device" + }, + { + "args": {}, + "method": "clear_lights", + "step": "set_light_initializing" + }, + { + "args": { + "command": { + "duty_cycle": 0.8, + "light": "YELLOW", + "period_ms": 1000 + } + }, + "method": "set_light", + "step": "set_light_initializing" + }, + { + "args": {}, + "method": "get_firmware_version", + "step": "query_firmware" + }, + { + "args": {}, + "method": "detect_gripper", + "step": "detect_gripper" + }, + { + "args": {}, + "method": "detect_smart_head", + "step": "detect_head" + }, + { + "args": {}, + "method": "read_smart_head_type", + "step": "detect_head" + }, + { + "args": { + "axis": "x" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": { + "axis": "y" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": { + "axis": "z" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": { + "axis": "w" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": { + "axis": "g" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": { + "axis": "zg" + }, + "method": "is_axis_homed", + "step": "read_home_registers" + }, + { + "args": {}, + "method": "query_state", + "step": "check_interlock" + }, + { + "args": { + "command_id": "CLEAR_MOTOR_POWER_FAULT", + "data": "", + "timeout": 2.0 + }, + "method": "send_command", + "step": "clear_motor_power_fault" + }, + { + "args": { + "axes": [ + "x", + "y", + "z", + "w", + "g", + "zg" + ] + }, + "method": "reset_faults", + "step": "reset_faults" + }, + { + "args": { + "command": { + "duty_cycle": 1.0, + "light": "GREEN", + "period_ms": 0 + } + }, + "method": "set_light", + "step": "set_light_idle" + } + ], + "status": "COMPLETED" + }, + "mix_task.mix_basic_same_distance": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 379.17, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 5.98, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_location" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 60.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 59.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 30.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 60.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 59.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 30.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "retract_z" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + } + ], + "status": "COMPLETED" + }, + "mix_task.mix_different_dispense_distance": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 192.48, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 115.07300000000001, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_location" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 45.1, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 54.96, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 27.0, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 54.15, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 51.96, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 54.96, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 27.0, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 54.15, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 51.96, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 54.96, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 27.0, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 54.15, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 51.96, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "mix_cycles" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 195.41, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 115.07300000000001, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 192.48, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 118.00300000000001, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 189.54999999999998, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 115.07300000000001, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 192.48, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 112.143, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 192.48, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 115.07300000000001, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "retract_z" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 45.1, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + } + ], + "status": "COMPLETED" + }, + "move_to_location_task.move_to_location_full_with_approach": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 379.17, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 5.98, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_xy_to_teachpoint" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 50.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_z_to_teachpoint" + } + ], + "status": "COMPLETED" + }, + "move_to_location_task.move_to_location_z_only": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + } + ], + "status": "COMPLETED" + }, + "pick_place_task.pick_place_basic": { + "calls": [ + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": -20.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 1000.0, + "axis": "x", + "position": 379.17, + "velocity": 200.0 + }, + { + "absolute": true, + "acceleration": 1000.0, + "axis": "y", + "position": 5.98, + "velocity": 200.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + }, + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": 62.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "grip_lid": false, + "position": 9.0, + "speed": "med" + }, + "method": "grip", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": {}, + "method": "is_plate_in_gripper", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + }, + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": 42.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 1000.0, + "axis": "x", + "position": 379.17, + "velocity": 200.0 + }, + { + "absolute": true, + "acceleration": 1000.0, + "axis": "y", + "position": 115.07300000000001, + "velocity": 200.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_place_height" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + }, + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": 62.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "position": null + }, + "method": "open_gripper", + "step": "release_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": -20.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + } + ], + "status": "COMPLETED" + }, + "pick_place_task.pick_place_mounted_group": { + "calls": [ + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": -20.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 1000.0, + "axis": "x", + "position": 379.17, + "velocity": 200.0 + }, + { + "absolute": true, + "acceleration": 1000.0, + "axis": "y", + "position": 5.98, + "velocity": 200.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + }, + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": 62.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "grip_lid": false, + "position": 9.0, + "speed": "med" + }, + "method": "grip", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": {}, + "method": "is_plate_in_gripper", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + }, + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": 42.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 1000.0, + "axis": "x", + "position": 379.17, + "velocity": 200.0 + }, + { + "absolute": true, + "acceleration": 1000.0, + "axis": "y", + "position": 115.07300000000001, + "velocity": 200.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_place_height" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + }, + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": 62.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "position": null + }, + "method": "open_gripper", + "step": "release_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": -20.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + } + ], + "status": "COMPLETED" + }, + "pick_place_task.pick_place_pickup_verification_failure": { + "calls": [ + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": -20.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 1000.0, + "axis": "x", + "position": 379.17, + "velocity": 200.0 + }, + { + "absolute": true, + "acceleration": 1000.0, + "axis": "y", + "position": 5.98, + "velocity": 200.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + }, + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": 62.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "grip_lid": false, + "position": 9.0, + "speed": "med" + }, + "method": "grip", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": {}, + "method": "is_plate_in_gripper", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": {}, + "method": "is_plate_in_gripper", + "step": "grip_plate" + } + ], + "status": "ABORTED" + }, + "relid_plate_task.relid_plate_basic": { + "calls": [ + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_safe_pick_start" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": -20.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_gripper_to_nesting" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 1000.0, + "axis": "x", + "position": 379.17, + "velocity": 200.0 + }, + { + "absolute": true, + "acceleration": 1000.0, + "axis": "y", + "position": 5.98, + "velocity": 200.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_pick" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + }, + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": 65.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_pick_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "grip_lid": true, + "position": 9.0, + "speed": "med" + }, + "method": "grip", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": {}, + "method": "is_plate_in_gripper", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "grip_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + }, + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": 32.400000000000006, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_carry_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 1000.0, + "axis": "x", + "position": 379.17, + "velocity": 200.0 + }, + { + "absolute": true, + "acceleration": 1000.0, + "axis": "y", + "position": 115.07300000000001, + "velocity": 200.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_xy_to_place" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_place_height" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "z", + "position": 0.0, + "velocity": 75.0 + }, + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": 66.9, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "move_to_place_height" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "position": null + }, + "method": "open_gripper", + "step": "release_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "release_plate" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 750.0, + "axis": "zg", + "position": -20.0, + "velocity": 75.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "y" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "zg" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "axis": "g" + }, + "method": "get_position", + "step": "return_gripper_to_nesting" + } + ], + "status": "COMPLETED" + }, + "scan_stack_height_task.scan_simulation_completed_count_matches": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_safe_start" + }, + { + "args": { + "position": null + }, + "method": "open_gripper", + "step": "move_to_safe_start" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "zg", + "position": -20.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_safe_start" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 379.17, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 5.98, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_xy_to_scan" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_scan_start" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "zg", + "position": 52.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_scan_start" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "zg", + "position": -20.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "return_gripper_to_nesting" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "return_gripper_to_nesting" + } + ], + "status": "COMPLETED" + }, + "scan_stack_height_task.scan_simulation_count_mismatch": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_safe_start" + }, + { + "args": { + "position": null + }, + "method": "open_gripper", + "step": "move_to_safe_start" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "zg", + "position": -20.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_safe_start" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 379.17, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 5.98, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_xy_to_scan" + }, + { + "args": { + "axis": "z" + }, + "method": "get_position", + "step": "move_to_scan_start" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "zg", + "position": 52.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_scan_start" + } + ], + "status": "ABORTED" + }, + "tips_off_task.tips_off_basic": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 379.17, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 5.98, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_eject_location" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "tip_touch" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 378.17, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 379.17, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 16.099999999999994, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "eject_tips" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": -11.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "eject_tips" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "eject_tips" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + } + ], + "status": "COMPLETED" + }, + "tips_off_task.tips_off_no_tip_touch_trash": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 379.17, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 5.98, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_eject_location" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 16.099999999999994, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "eject_tips" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": -11.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "eject_tips" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "eject_tips" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + } + ], + "status": "COMPLETED" + }, + "tips_off_task.tips_off_not_tracked_prompt": { + "calls": [], + "status": "ABORTED" + }, + "tips_off_task.tips_off_partial_block_anchor": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 298.17, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 14.98, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_eject_location" + }, + { + "args": { + "axis": "x" + }, + "method": "get_position", + "step": "tip_touch" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 297.17, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 298.17, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "tip_touch" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 16.099999999999994, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "eject_tips" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": -11.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "eject_tips" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "eject_tips" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + } + ], + "status": "COMPLETED" + }, + "tips_on_task.tips_on_basic_full_head": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "ensure_w_zero" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 379.17, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 5.98, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_tip_location" + }, + { + "args": { + "axes": [ + "x", + "y", + "z", + "w" + ] + }, + "method": "reset_faults", + "step": "clear_axis_faults" + }, + { + "args": { + "params": { + "acceleration": 250.0, + "axis": "z", + "max_position": 26.099999999999994, + "peak_current": 0.25217391304347825, + "tolerance": 5.0, + "velocity": 25.0 + } + }, + "method": "jog", + "step": "lower_z_to_tips" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + } + ], + "status": "COMPLETED" + }, + "tips_on_task.tips_on_lt_head_press_failure": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "ensure_w_zero" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 379.17, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 5.98, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_tip_location" + }, + { + "args": { + "axes": [ + "x", + "y", + "z", + "w" + ] + }, + "method": "reset_faults", + "step": "clear_axis_faults" + }, + { + "args": { + "params": { + "acceleration": 250.0, + "axis": "z", + "max_position": 26.099999999999994, + "peak_current": 0.6, + "tolerance": 5.0, + "velocity": 25.0 + } + }, + "method": "jog", + "step": "lower_z_to_tips" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "lower_z_to_tips" + } + ], + "status": "ABORTED" + }, + "tips_on_task.tips_on_partial_block_nonzero_head_offset": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "ensure_w_zero" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 325.17, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 32.980000000000004, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_tip_location" + }, + { + "args": { + "axes": [ + "x", + "y", + "z", + "w" + ] + }, + "method": "reset_faults", + "step": "clear_axis_faults" + }, + { + "args": { + "params": { + "acceleration": 250.0, + "axis": "z", + "max_position": 26.099999999999994, + "peak_current": 0.04, + "tolerance": 5.0, + "velocity": 25.0 + } + }, + "method": "jog", + "step": "lower_z_to_tips" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + } + ], + "status": "COMPLETED" + }, + "tips_on_task.tips_on_partial_block_with_w_reset": { + "calls": [ + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "safe_z_retract" + }, + { + "args": { + "axis": "w" + }, + "method": "get_position", + "step": "ensure_w_zero" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "w", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "ensure_w_zero" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "x", + "position": 406.17, + "velocity": 0.0 + }, + { + "absolute": true, + "acceleration": 0.0, + "axis": "y", + "position": 23.98, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "move_to_tip_location" + }, + { + "args": { + "axes": [ + "x", + "y", + "z", + "w" + ] + }, + "method": "reset_faults", + "step": "clear_axis_faults" + }, + { + "args": { + "params": { + "acceleration": 250.0, + "axis": "z", + "max_position": 26.099999999999994, + "peak_current": 0.04, + "tolerance": 5.0, + "velocity": 25.0 + } + }, + "method": "jog", + "step": "lower_z_to_tips" + }, + { + "args": { + "moves": [ + { + "absolute": true, + "acceleration": 0.0, + "axis": "z", + "position": 0.0, + "velocity": 0.0 + } + ], + "timeout": 30.0, + "wait": true + }, + "method": "move", + "step": "retract_z" + } + ], + "status": "COMPLETED" + } +} diff --git a/pylabrobot/agilent/bravo/state_machine/tips_on_off_golden_frame_tests.py b/pylabrobot/agilent/bravo/state_machine/tips_on_off_golden_frame_tests.py new file mode 100644 index 00000000000..b55b17e5238 --- /dev/null +++ b/pylabrobot/agilent/bravo/state_machine/tips_on_off_golden_frame_tests.py @@ -0,0 +1,211 @@ +"""Golden-frame tests for TipsOnTask and TipsOffTask. + +See :mod:`.golden_frame_support` for the recorder, task/engine driver, and +fixture-comparison base class this module reuses. +""" + +from __future__ import annotations + +import unittest + +from ..deck.labware import Labware +from ..head_mode import TipSelection, normalize_head_mode +from ..types import HeadType +from .engine import ErrorAction +from .golden_frame_support import ( + GoldenFrameTestCase, + new_config, + new_controller, + new_teachpoints, + run_task, +) +from .tasks import TipsOffTask, TipsOnTask + + +def _tipbox_96() -> Labware: + return Labware( + id="lw-tipbox96", + name="Test 96 Tip Box", + height=60.0, + width=85.5, + length=127.5, + wells=96, + metadata={"rows": 8, "cols": 12, "spacing_x_mm": 9.0, "spacing_y_mm": 9.0}, + ) + + +def _config_for(head_type: HeadType, teach_tip_length_mm: float = 26.1): + config = new_config(gripper=True) + config.head.head_type = head_type + config.head.teach_tip_length_mm = teach_tip_length_mm + return config + + +class TipsOnTaskGoldenTests(GoldenFrameTestCase): + async def test_basic_full_head(self): + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_head_type("96_d_70") + config = _config_for("96_d_70") + mode = normalize_head_mode("96_d_70", "all_barrels", None) + task = TipsOnTask( + ctrl, + new_teachpoints(), + config, + _tipbox_96(), + mode, + TipSelection(location=3, row=0, col=0), + 3, + tip_length_mm=26.1, + ) + result = await run_task(task, ctrl) + self.assert_matches_golden("tips_on_task.tips_on_basic_full_head", result) + + async def test_partial_block_with_w_reset(self): + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_head_type("16_d_st") + ctrl._axes["w"].position = 5.0 + config = _config_for("16_d_st") + mode = normalize_head_mode("16_d_st", "single_barrel", "back_right") + task = TipsOnTask( + ctrl, + new_teachpoints(), + config, + _tipbox_96(), + mode, + TipSelection(location=3, row=2, col=3), + 3, + tip_length_mm=19.9, + ) + result = await run_task(task, ctrl) + self.assert_matches_golden("tips_on_task.tips_on_partial_block_with_w_reset", result) + + async def test_partial_block_nonzero_head_offset(self): + # Anchored back_right on a multi-column head (96_d_70), so the + # head-mode XY offset is genuinely nonzero -- distinct from the + # single-column 16_d_st scenario above, whose head offset is always + # (0, 0) regardless of anchor corner. + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_head_type("96_d_70") + config = _config_for("96_d_70") + mode = normalize_head_mode("96_d_70", "single_barrel", "back_right") + task = TipsOnTask( + ctrl, + new_teachpoints(), + config, + _tipbox_96(), + mode, + TipSelection(location=3, row=3, col=5), + 3, + tip_length_mm=26.1, + ) + result = await run_task(task, ctrl) + self.assert_matches_golden("tips_on_task.tips_on_partial_block_nonzero_head_offset", result) + + async def test_lt_head_press_failure(self): + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_head_type("96_d_200") + config = _config_for("96_d_200") + mode = normalize_head_mode("96_d_200", "all_barrels", None) + task = TipsOnTask( + ctrl, + new_teachpoints(), + config, + _tipbox_96(), + mode, + TipSelection(location=3, row=0, col=0), + 3, + tip_length_mm=55.2, + ) + + original_jog = ctrl.jog + + def failing_jog(params): + ctrl._record("jog", params=params) + raise RuntimeError("Unable to reach destination on Z within tolerance.") + + ctrl.jog = failing_jog # type: ignore[method-assign] + try: + result = await run_task(task, ctrl, choice_fn=lambda t: ErrorAction.ABORT) + finally: + ctrl.jog = original_jog # type: ignore[method-assign] + self.assert_matches_golden("tips_on_task.tips_on_lt_head_press_failure", result) + + +class TipsOffTaskGoldenTests(GoldenFrameTestCase): + async def test_basic(self): + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_head_type("96_d_70") + config = _config_for("96_d_70") + mode = normalize_head_mode("96_d_70", "all_barrels", None) + task = TipsOffTask( + ctrl, + new_teachpoints(), + config, + _tipbox_96(), + mode, + TipSelection(location=3, row=0, col=0), + 3, + attached_tip_length_mm=26.1, + ) + result = await run_task(task, ctrl) + self.assert_matches_golden("tips_off_task.tips_off_basic", result) + + async def test_no_tip_touch_trash(self): + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_head_type("96_d_70") + config = _config_for("96_d_70") + config.safety.enable_tips_off_tip_touch = False + mode = normalize_head_mode("96_d_70", "all_barrels", None) + task = TipsOffTask( + ctrl, + new_teachpoints(), + config, + _tipbox_96(), + mode, + None, + 3, + attached_tip_length_mm=26.1, + ) + result = await run_task(task, ctrl) + self.assert_matches_golden("tips_off_task.tips_off_no_tip_touch_trash", result) + + async def test_not_tracked_prompt(self): + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_head_type("96_d_70") + config = _config_for("96_d_70") + mode = normalize_head_mode("96_d_70", "all_barrels", None) + task = TipsOffTask( + ctrl, + new_teachpoints(), + config, + _tipbox_96(), + mode, + TipSelection(location=3, row=0, col=0), + 3, + attached_tip_length_mm=26.1, + tips_are_tracked=False, + ) + result = await run_task(task, ctrl, choice_fn=lambda t: ErrorAction.ABORT) + self.assert_matches_golden("tips_off_task.tips_off_not_tracked_prompt", result) + + async def test_partial_block_anchor(self): + ctrl = new_controller(all_homed=True, gripper=True) + ctrl.set_head_type("96_d_70") + config = _config_for("96_d_70") + mode = normalize_head_mode("96_d_70", "single_barrel", "back_right") + task = TipsOffTask( + ctrl, + new_teachpoints(), + config, + _tipbox_96(), + mode, + TipSelection(location=3, row=1, col=2), + 3, + attached_tip_length_mm=26.1, + ) + result = await run_task(task, ctrl) + self.assert_matches_golden("tips_off_task.tips_off_partial_block_anchor", result) + + +if __name__ == "__main__": + unittest.main() From de99b111104fbe4f64a780edccaf5f690eb3f35b Mon Sep 17 00:00:00 2001 From: kelsorj Date: Fri, 21 Aug 2026 10:59:59 -0700 Subject: [PATCH 8/9] Add the Agilent Bravo liquid handler backend The Bravo is a fixed-head pipettor: every barrel moves together and shares one plunger drive. Every operation it can perform is therefore a contiguous rectangular block of head barrels anchored at one of four corners. block.py derives that block from the targeted wells and explains what shape would work when a selection is not rectangular. AgilentBravoBackend maps PyLabRobot operations onto it. A single well selects one barrel, a full column selects a column, and the 96 methods use the whole head, honouring gaps in the requested tips. A multi-channel aspirate requires a uniform volume and flow rate because of the shared plunger drive. num_arms and head96_installed derive from the installed model, so a gripperless SRT reports no arm and PyLabRobot rejects plate movement before the backend is reached. 384-channel heads work through the per-channel path. They cannot use the 96 path, which requires exactly 96 tips. --- pylabrobot/agilent/__init__.py | 1 + pylabrobot/agilent/bravo/__init__.py | 19 + pylabrobot/agilent/bravo/backend.py | 994 ++++++++++++++ pylabrobot/agilent/bravo/backend_tests.py | 1002 ++++++++++++++ pylabrobot/agilent/bravo/block.py | 171 +++ pylabrobot/agilent/bravo/block_tests.py | 160 +++ pylabrobot/agilent/bravo/bravo.py | 1512 +++++++++++++++++++++ pylabrobot/agilent/bravo/bravo_tests.py | 618 +++++++++ 8 files changed, 4477 insertions(+) create mode 100644 pylabrobot/agilent/bravo/__init__.py create mode 100644 pylabrobot/agilent/bravo/backend.py create mode 100644 pylabrobot/agilent/bravo/backend_tests.py create mode 100644 pylabrobot/agilent/bravo/block.py create mode 100644 pylabrobot/agilent/bravo/block_tests.py create mode 100644 pylabrobot/agilent/bravo/bravo.py create mode 100644 pylabrobot/agilent/bravo/bravo_tests.py diff --git a/pylabrobot/agilent/__init__.py b/pylabrobot/agilent/__init__.py index 73b4637c62a..dd4fe64469e 100644 --- a/pylabrobot/agilent/__init__.py +++ b/pylabrobot/agilent/__init__.py @@ -6,4 +6,5 @@ CytationImagingConfig, SynergyH1, ) +from .bravo import AgilentBravoBackend, Bravo, BravoDeck from .vspin import Access2, Access2Driver, VSpin diff --git a/pylabrobot/agilent/bravo/__init__.py b/pylabrobot/agilent/bravo/__init__.py new file mode 100644 index 00000000000..3187b1115dd --- /dev/null +++ b/pylabrobot/agilent/bravo/__init__.py @@ -0,0 +1,19 @@ +"""PyLabRobot integration for the Agilent Bravo liquid handler. + +Exposes the two pieces most callers need: :class:`Bravo`, the async device +facade, and :class:`AgilentBravoBackend`, the PyLabRobot +:class:`~pylabrobot.legacy.liquid_handling.backends.backend.LiquidHandlerBackend` +built on top of it, plus :class:`BravoDeck`, the PyLabRobot deck model for +the instrument's nine deck sites. Everything else in this package -- +``transport``, ``protocol``, ``controllers``, ``darwin``, ``deck``, +``state_machine``, and the head/tip/config modules -- is available by +importing the relevant submodule directly. +""" + +from __future__ import annotations + +from .backend import AgilentBravoBackend +from .bravo import Bravo +from .deck.resource import BravoDeck + +__all__ = ["AgilentBravoBackend", "Bravo", "BravoDeck"] diff --git a/pylabrobot/agilent/bravo/backend.py b/pylabrobot/agilent/bravo/backend.py new file mode 100644 index 00000000000..2974e2e1f02 --- /dev/null +++ b/pylabrobot/agilent/bravo/backend.py @@ -0,0 +1,994 @@ +"""PyLabRobot :class:`~pylabrobot.legacy.liquid_handling.backends.backend.LiquidHandlerBackend` +for the Agilent Bravo. + +Translates PyLabRobot's standard liquid-handling operations +(:mod:`pylabrobot.legacy.liquid_handling.standard`) into calls on a +:class:`~.bravo.Bravo` facade. The Bravo's own hardware model -- a single +plunger drive shared by every active barrel, and barrels only activatable +as a contiguous rectangular block anchored at one of the head's four +corners (see :mod:`.head_mode`) -- shapes almost every choice this module +makes: + +- A per-channel operation's target wells or tip spots are translated into + a :class:`~.block.HeadBlock` (see :mod:`.block`), which becomes the + active head mode plus the plate/tipbox anchor cell that block sits at. +- Volume and flow rate must be identical across every channel in one + aspirate/dispense call, because they drive the one shared plunger. +- ``aspirate96``/``dispense96`` always drive the whole head; their ``wells`` + list only ever locates the anchor well, never a shape, since a 96-head + operation cannot select a subset of its own 96 barrels. + +**384-channel heads:** this backend's per-channel path is channel-count +agnostic and works with a 384-channel head like any other. The *_tips96*/ +``aspirate96``/``dispense96`` path is not reachable for one, though: PyLabRobot's +own :meth:`~pylabrobot.legacy.liquid_handling.liquid_handler.LiquidHandler.pick_up_tips96` +(and the sibling 96-head methods) hard-require exactly 96 items on the +target tip rack/plate before this backend is ever called, and this backend +additionally rejects a *_tips96*/``aspirate96``/``dispense96`` call outright +when the installed head does not have exactly 96 channels (see +:meth:`AgilentBravoBackend._require_96_channel_head`). Drive a 384-channel +head through the per-channel path instead. + +**Unverified against real hardware:** the protocol and controller layers +this backend ultimately drives are exercised against real Agilent Bravo +instruments, but the PyLabRobot transport, deck mapping +(:mod:`.deck.resource`), and this backend itself are not. Please report any +issue at https://discuss.pylabrobot.org. +""" + +from __future__ import annotations + +import logging +from typing import Dict, List, Optional, Sequence, Tuple, Union + +from pylabrobot.legacy.liquid_handling.backends.backend import LiquidHandlerBackend +from pylabrobot.legacy.liquid_handling.standard import ( + Drop, + DropTipRack, + GripDirection, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + Pickup, + PickupTipRack, + ResourceDrop, + ResourceMove, + ResourcePickup, + SingleChannelAspiration, + SingleChannelDispense, +) +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.itemized_resource import ItemizedResource +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.tip import Tip +from pylabrobot.resources.tip_rack import TipRack, TipSpot + +from .block import HeadBlock, head_block_for_identifiers, parse_item_identifier +from .bravo import Bravo +from .deck.resource import BravoDeck +from .head_mode import HeadGeometry +from .tips import get_tip_definition +from .types import MAX_LOCATIONS, MIN_LOCATION + +logger = logging.getLogger(__name__) + +_SITE_COORDINATE_TOLERANCE_MM = 1.0 +"""Tolerance, in millimetres, for matching a :class:`~pylabrobot.resources.coordinate.Coordinate` +against a Bravo deck site's taught X/Y, in :meth:`AgilentBravoBackend._site_for_coordinate`. + +Chosen, not measured or derived from any hardware or PyLabRobot-documented +tolerance: adjacent deck sites are roughly 187 mm apart in X and 109 mm +apart in Y (:data:`~.types.X_TO_X_DISTANCE`/:data:`~.types.Y_TO_Y_DISTANCE`), +so anything up to several millimetres is nowhere near enough to confuse +two different sites for each other -- the risk this value trades against +is entirely on the other side: too tight, and a *legitimate* destination +computed by PyLabRobot's own resource-location arithmetic (composed +through :meth:`~pylabrobot.legacy.liquid_handling.liquid_handler.LiquidHandler.drop_resource`'s +offset/rotation math, which can accumulate more floating-point drift than +a single subtraction) gets rejected as "no site match" even though it was +plainly meant for one. 1 mm sits comfortably above ordinary floating-point +drift and comfortably below the ~100 mm+ gap between any two sites; it is +not a claim about the instrument's positioning accuracy.""" + + +def _subset_for_block(block: HeadBlock, geometry: HeadGeometry) -> Tuple[str, int, int]: + """Return the ``(subset_type, row_count, column_count)`` a block reduces to. + + Every shape reduces to the most specific mode :mod:`.head_mode` supports: + the whole head, a single barrel, a full row or column, or a rectangle. + The block is always anchored at head corner ``"back_left"`` -- any corner + would do, since the caller separately pins the matching plate/tipbox + anchor cell (see :meth:`AgilentBravoBackend._apply_channel_block`); this + just needs to be consistent between the two. + + Args: + block: The block of active barrels. + geometry: The installed head's own barrel grid. + + Returns: + The subset type and row/column counts to pass to + :meth:`~.bravo.Bravo.set_head_mode`. + """ + if block.num_rows == geometry.rows and block.num_columns == geometry.columns: + return "all_barrels", geometry.rows, geometry.columns + if block.num_rows == 1 and block.num_columns == 1: + return "single_barrel", 1, 1 + if block.num_columns == geometry.columns: + return "row", block.num_rows, geometry.columns + if block.num_rows == geometry.rows: + return "column", geometry.rows, block.num_columns + return "rectangle", block.num_rows, block.num_columns + + +def _uniform_value(values: Sequence[float], *, label: str) -> float: + """Return the single value shared by *values*, raising if they differ. + + Args: + values: The value each channel in one call supplied. Never empty for a + well-formed call (one entry per channel operation). + label: What the value represents, used in the error message. + + Returns: + The single shared value. + + Raises: + RuntimeError: If *values* contains more than one distinct value: the + Bravo head has a single plunger drive, so every channel in one + aspirate/dispense call must move it the same way. + """ + distinct = sorted({float(v) for v in values}) + if len(distinct) > 1: + raise RuntimeError( + f"AgilentBravoBackend requires a single {label} across every channel in one call, " + f"because the Bravo head has one plunger drive shared by every active barrel; " + f"got {distinct}." + ) + return distinct[0] + + +def _uniform_optional(values: Sequence[Optional[float]], *, label: str) -> Optional[float]: + """Return the single non-``None`` value shared by *values*, or ``None``. + + Args: + values: The value each channel in one call supplied; ``None`` means + "no override" and is excluded from the uniformity check. + label: What the value represents, used in the error message. + + Returns: + The single shared override, or ``None`` if every value was ``None``. + + Raises: + RuntimeError: If more than one distinct override value is present. + """ + present = sorted({float(v) for v in values if v is not None}) + if len(present) > 1: + raise RuntimeError( + f"AgilentBravoBackend requires a single {label} across every channel in one call, " + f"because the Bravo head has one plunger drive shared by every active barrel; " + f"got {present}." + ) + return present[0] if present else None + + +def _require_itemized(parent: Optional[Resource], *, role: str) -> ItemizedResource: + """Return *parent* as an :class:`~pylabrobot.resources.itemized_resource.ItemizedResource`. + + Args: + parent: The candidate parent resource, e.g. a well's or tip spot's + ``.parent``. + role: What kind of resource *parent* is expected to be, for the error + message. + + Raises: + RuntimeError: If *parent* is ``None`` or not itemized, and so has no + ``get_child_identifier`` to resolve an item's identifier from. + """ + if parent is None or not isinstance(parent, ItemizedResource): + raise RuntimeError( + f"Expected an itemized {role} (with its own A1-style item grid), got {parent!r}." + ) + return parent + + +class AgilentBravoBackend(LiquidHandlerBackend): + """PyLabRobot liquid-handling backend for the Agilent Bravo. + + Wraps a :class:`~.bravo.Bravo` facade, translating PyLabRobot's standard + operations into calls on it. See the module docstring for the shape of + that translation and its hardware-driven limits. + """ + + def __init__(self, bravo: Bravo) -> None: + """Initialize the backend. + + Args: + bravo: The Bravo facade to operate. Already constructed by the + caller (with whatever controller, transport, and config it needs); + this class does no construction of its own. + """ + super().__init__() + self._bravo = bravo + self._synced_resources: Dict[int, Optional[Resource]] = {} + + @property + def bravo(self) -> Bravo: + """The :class:`~.bravo.Bravo` facade this backend operates.""" + return self._bravo + + # -- Lifecycle -- + + async def setup(self) -> None: + """Bring the underlying :class:`~.bravo.Bravo` facade online. + + Logs the unverified-hardware warning described in the module + docstring, then delegates to :meth:`~.bravo.Bravo.setup`. Does not + home any axis; call :meth:`~.bravo.Bravo.home` or + :meth:`~.bravo.Bravo.initialize` on :attr:`bravo` directly first if the + protocol needs a cold-start sequence. + """ + await super().setup() + logger.warning( + "AgilentBravoBackend is unverified against real Agilent Bravo hardware: the " + "protocol and controller layers it drives are exercised against real " + "instruments, but the PyLabRobot transport, deck mapping, and this backend " + "are not. Please report any issue at https://discuss.pylabrobot.org." + ) + await self._bravo.setup() + self.setup_finished = True + + async def stop(self) -> None: + """Take the underlying :class:`~.bravo.Bravo` facade offline.""" + await self._bravo.stop() + self.setup_finished = False + + @property + def num_channels(self) -> int: + """The number of physical channels on the installed head (rows x columns).""" + geometry = self._bravo.head_geometry + return geometry.rows * geometry.columns + + @property + def num_arms(self) -> int: + """The number of robotic arms PyLabRobot can drive for resource pick/move/drop. + + 1 when the installed model has a gripper, 0 when it does not (e.g. the + gripperless SRT; see :attr:`~.bravo.Bravo.has_gripper`). The base + class's own default of 0 is exactly right for the SRT, but wrong for + every gripper-equipped model: left unset, ``LiquidHandler.setup()`` + builds an empty ``_resource_pickups`` map + (``{a: None for a in range(self.backend.num_arms)}``), and every + gripper call -- including the common ``LiquidHandler.move_plate()`` -- + raises PyLabRobot's own "No robotic arm is installed on this liquid + handler" before this backend's :meth:`pick_up_resource` is ever + reached, regardless of how complete that method is. Deriving this + from :attr:`~.bravo.Bravo.has_gripper` means the SRT still reports 0 + and gets that same PyLabRobot error -- the right layering, since + :meth:`_require_gripper` exists for a caller that bypasses + ``LiquidHandler`` and calls this backend directly. + """ + return 1 if self._bravo.has_gripper else 0 + + @property + def head96_installed(self) -> Optional[bool]: + """Whether the installed head has exactly 96 channels. + + Overrides the base class's own hardcoded ``False`` for the same + reason :attr:`num_arms` does: ``LiquidHandler.setup()`` sizes its + ``head96`` tip-tracker dict from this flag (96 entries if true, none + if false), and a real 96-head call -- ``pick_up_tips96``, + ``drop_tips96``, ``aspirate96``, ``dispense96`` -- indexes that dict + for every one of its 96 channels. Left at the base class's ``False``, + every one of those calls would raise a ``KeyError`` against an empty + dict before this backend's own, more informative + :meth:`_require_96_channel_head` rejection is ever reached. + """ + geometry = self._bravo.head_geometry + return geometry.rows * geometry.columns == 96 + + # -- Deck/head resolution -- + + def _bravo_deck(self) -> BravoDeck: + """Return the assigned deck, requiring it to be a :class:`~.deck.resource.BravoDeck`.""" + deck = self.deck + if not isinstance(deck, BravoDeck): + raise RuntimeError( + f"AgilentBravoBackend requires a BravoDeck, got {type(deck).__name__}. Construct the " + "LiquidHandler with a BravoDeck so PyLabRobot resources can be translated onto the " + "instrument's nine deck sites." + ) + return deck + + def _sync_site(self, deck: BravoDeck, site: int) -> None: + """Sync a Bravo deck site's labware from *deck* if its occupant has changed. + + Compares the resource currently assigned to *site* against what this + backend last saw there, by identity. A changed occupant (including + becoming empty) is pushed into :attr:`bravo` via + :meth:`~.bravo.Bravo.set_labware`/:meth:`~.bravo.Bravo.clear_labware`. + An unchanged occupant is left alone, so tip occupancy Bravo already + tracks for that site is not reset back to "full" on every call. + """ + current = deck.resource_at_site(site) + if site in self._synced_resources and self._synced_resources[site] is current: + return + if current is None: + self._bravo.clear_labware(site) + else: + labware = deck.labware_for_site(site) + if labware is None: + raise RuntimeError(f"Internal error: no labware description for deck site {site}.") + self._bravo.set_labware(site, labware) + self._synced_resources[site] = current + + def _resolve_site(self, resource: Resource, *, role: str) -> int: + """Return the deck site *resource* occupies, syncing it into :attr:`bravo`. + + Args: + resource: The resource to locate -- a plate, tip rack, or other + top-level occupant of a Bravo deck site. + role: What kind of resource this is, for the error message. + + Raises: + RuntimeError: If *resource* is not assigned to any Bravo deck site. + """ + deck = self._bravo_deck() + site = deck.site_for_resource(resource) + if site is None: + raise RuntimeError( + f"The {role} '{resource.name}' is not assigned to a Bravo deck site. Assign it with " + "BravoDeck.assign_child_at_site before using it in a liquid-handling operation." + ) + self._sync_site(deck, site) + return site + + def _site_for_coordinate(self, coordinate: Coordinate) -> int: + """Return the Bravo deck site whose taught X/Y matches *coordinate*. + + The gripper facade (:meth:`~.bravo.Bravo.gripper_move`, + :meth:`~.bravo.Bravo.gripper_place`) only knows how to target one of + the instrument's nine taught sites, not an arbitrary coordinate; this + matches PyLabRobot's resolved absolute coordinate back to whichever + site it came from. + + Args: + coordinate: The absolute (deck-relative) coordinate to match. + + Raises: + RuntimeError: If no site's taught X/Y is within + :data:`_SITE_COORDINATE_TOLERANCE_MM` of *coordinate*. + """ + deck = self._bravo_deck() + teachpoints = deck.teachpoints + for site in range(MIN_LOCATION, MAX_LOCATIONS + 1): + site_x = teachpoints.get_teachpoint(site, "x") + site_y = teachpoints.get_teachpoint(site, "y") + if ( + abs(coordinate.x - site_x) <= _SITE_COORDINATE_TOLERANCE_MM + and abs(coordinate.y - site_y) <= _SITE_COORDINATE_TOLERANCE_MM + ): + return site + raise RuntimeError( + f"{coordinate} does not match any of the Bravo deck's nine taught sites; the gripper " + "can only move to or place at one of those sites, not an arbitrary coordinate." + ) + + # -- Head-state guards -- + + def _require_identified_head(self, operation: str) -> None: + """Raise if the installed head has not been identified yet. + + ``head_type == "unknown"`` makes :attr:`~.bravo.Bravo.head_geometry` + and :attr:`~.bravo.Bravo.head_capacity` report the 96-channel default + as a permissive fallback (see :func:`~.head_mode.head_geometry_for_type`) -- + exactly right for a caller that just wants *some* answer, but wrong to + build an operation on top of, since the real installed head might + physically be the 8-barrel ``8_d_lt`` or any other size. + + Args: + operation: The operation name, for the error message. + """ + if self._bravo.head_type == "unknown": + raise RuntimeError( + f"Cannot run {operation}: the installed head has not been identified yet " + "(head_type is 'unknown'). Call Bravo.initialize() to detect it, or set " + "BravoMachineConfig.head.head_type explicitly." + ) + + def _require_96_channel_head(self, operation: str) -> None: + """Raise unless the installed head has exactly 96 channels. + + Args: + operation: The operation name, for the error message. + """ + self._require_identified_head(operation) + geometry = self._bravo.head_geometry + channels = geometry.rows * geometry.columns + if channels != 96: + raise RuntimeError( + f"{operation} requires a 96-channel head; the installed '{self._bravo.head_type}' " + f"head has {geometry.rows}x{geometry.columns} = {channels} channels. Use the " + "per-channel path (pick_up_tips/aspirate/dispense/drop_tips) instead." + ) + + def _require_gripper(self, operation: str) -> None: + """Raise unless the installed hardware has a gripper. + + Args: + operation: The operation name, for the error message. + """ + if not self._bravo.has_gripper: + raise RuntimeError(f"{self._bravo.model_name} has no gripper; {operation} is not available.") + + # -- Head-block dispatch -- + + def _require_block_fits(self, block: HeadBlock) -> None: + """Raise unless *block* fits the installed head's own barrel grid. + + :func:`~.head_mode.normalize_head_mode` silently clamps an oversized + request to the head's own size rather than raising, so this check has + to happen before :meth:`~.bravo.Bravo.set_head_mode` is ever called -- + otherwise a block that does not fit would silently become a smaller + one instead of being rejected. + + Args: + block: The block to check. + + Raises: + RuntimeError: If *block* is larger, in either dimension, than the + installed head. + """ + geometry = self._bravo.head_geometry + if not block.fits_within(geometry.rows, geometry.columns): + raise RuntimeError( + f"The requested {block.num_rows}x{block.num_columns} block of barrels does not fit " + f"the installed {geometry.rows}x{geometry.columns} head (head_type=" + f"'{self._bravo.head_type}'). Select at most {geometry.rows} row(s) and " + f"{geometry.columns} column(s)." + ) + + def _apply_channel_block(self, block: HeadBlock, location: int, *, target: str) -> None: + """Set the active head mode from *block* and pin the matching anchor. + + Used for an operation that is choosing the active head mode fresh -- + a tip pickup, or a liquid-handling operation. Not used for a tip + *return*, which must keep the head mode tips were picked up in rather + than set a new one (see :meth:`_apply_tip_return_anchor`). + + Args: + block: The block of active barrels. + location: The deck site to pin the anchor cell at. + target: ``"tip"`` to set a tipbox anchor, ``"plate"`` for a plate + anchor. + + Raises: + RuntimeError: If *block* does not fit the installed head (see + :meth:`_require_block_fits`), or the resolved anchor is illegal at + *location* (raised by :attr:`bravo` itself). + """ + self._require_block_fits(block) + subset_type, row_count, column_count = _subset_for_block(block, self._bravo.head_geometry) + if target == "tip": + self._set_tip_pickup_anchor(block, location, subset_type, row_count, column_count) + else: + self._bravo.set_head_mode(subset_type, "back_left", row_count, column_count) + self._bravo.set_plate_selection(location, block.row_start, block.col_start) + + def _set_tip_pickup_anchor( + self, block: HeadBlock, location: int, subset_type: str, row_count: int, column_count: int + ) -> None: + """Set the head mode and tipbox anchor for a tip *pickup*, trying every head corner. + + A tipbox anchor's legality depends on which corner of the box is + currently being consumed from (see :func:`~.head_mode.tipbox_mirror_corner` + and :func:`~.head_mode.is_legal_tipbox_anchor`), which is the *mirror + image* of the head's own anchor corner -- unlike a plate anchor, which + :meth:`~.bravo.Bravo` accepts at any reachable cell regardless of head + corner. Rather than re-deriving that mirroring and the box's current + occupancy here, this tries each of the four head corners (through + :attr:`bravo`'s own public API) and keeps the first one under which the + requested block is legal right now. + + Args: + block: The block of tip spots to pick up. + location: The deck site of the tip box. + subset_type: The head subset type :func:`_subset_for_block` chose. + row_count: The row count :func:`_subset_for_block` chose. + column_count: The column count :func:`_subset_for_block` chose. + + Raises: + RuntimeError: If no head corner makes the block a legal pickup + anchor given the tip box's current occupancy. + """ + last_error: Optional[RuntimeError] = None + for subset_config in ("back_left", "back_right", "front_left", "front_right"): + self._bravo.set_head_mode(subset_type, subset_config, row_count, column_count) + try: + self._bravo.set_tip_selection(location, block.row_start, block.col_start) + return + except RuntimeError as exc: + last_error = exc + raise RuntimeError( + f"The requested tip block (rows {block.row_start}-{block.row_stop - 1}, columns " + f"{block.col_start}-{block.col_stop - 1}) is not accessible for pickup at location " + f"{location} under any head orientation, given the tip box's current occupancy." + ) from last_error + + def _apply_tip_return_anchor(self, block: HeadBlock, location: int) -> None: + """Pin the tipbox anchor for a tip return, without changing the active head mode. + + A return reuses whichever head mode picked the tips up -- + :class:`~.bravo.Bravo` tracks that itself and :meth:`~.bravo.Bravo.tips_off` + resolves it automatically -- so only which tipbox cells the return + targets needs pinning here. + + A tip box tracks its own depletion in full row/column bands (see + :func:`~.head_mode.is_legal_tipbox_anchor`'s ``"return"`` branch): a + block that spans the box's full width or height (``"row"``, + ``"column"``, or ``"all_barrels"`` head mode) always returns cleanly, + but a block that does not (``"single_barrel"`` or a ``"rectangle"`` + narrower than the box) is only accepted once every other tip in the + row(s)/column(s) it touches has also been removed -- a lone tip + picked and immediately returned next to still-full neighbours is + illegal by that same rule, not a mapping defect here. This is caught + and re-raised with that explanation instead of Bravo's own + lower-level "not accessible for return" message. + + Args: + block: The block of tip spots being returned to. + location: The deck site of the tip box. + + Raises: + RuntimeError: If *block* does not fit the installed head (see + :meth:`_require_block_fits`), or the tip box's current occupancy + does not permit a return of exactly this block's shape and + position. + """ + self._require_block_fits(block) + try: + self._bravo.set_tip_selection(location, block.row_start, block.col_start) + except RuntimeError as exc: + raise RuntimeError( + f"Cannot return tips to rows {block.row_start}-{block.row_stop - 1}, columns " + f"{block.col_start}-{block.col_stop - 1} at location {location}: this block does not " + "span the tip box's full width or height, and a tip box only accepts a partial-width " + "return once every other tip in the row(s)/column(s) it touches has also been " + "removed -- returning a single barrel or a narrow rectangle right next to tips still " + "in the same row/column is rejected the same way picking one would be from the " + "middle of a still-full box. Return using a head mode that spans the box's full row " + "or column (or all_barrels), or drop to a trash location instead. " + f"Bravo's own error: {exc}" + ) from exc + + def _resolve_channel_group( + self, resources: Sequence[Resource], *, role: str + ) -> Tuple[int, HeadBlock]: + """Resolve the shared deck site and head block for a list of channel targets. + + Args: + resources: The item (well or tip spot) each channel in one call is + targeting. Every item must be a child of the same parent resource, + since the Bravo head only ever addresses one deck site per call. + role: What kind of item *resources* holds, for error messages (e.g. + ``"well"``, ``"tip"``). + + Returns: + The deck site the shared parent occupies, and the head block the + items' identifiers form. + + Raises: + RuntimeError: If *resources* is empty, its items do not share a + single parent, that parent is not assigned to a Bravo deck site, + or (from :func:`~.block.head_block_for_identifiers`) the items do + not form a contiguous rectangular block. + """ + if not resources: + raise RuntimeError(f"At least one {role} is required.") + parent = resources[0].parent + if parent is None or any(r.parent is not parent for r in resources): + names = ", ".join(sorted({r.name for r in resources})) + raise RuntimeError( + f"All channels in a single Bravo operation must target {role}s on the same labware; " + f"got {role}s that are not all children of one resource ({names})." + ) + itemized_parent = _require_itemized(parent, role="labware") + location = self._resolve_site(itemized_parent, role="labware") + identifiers = [itemized_parent.get_child_identifier(resource) for resource in resources] + block = head_block_for_identifiers(identifiers) + return location, block + + # -- Per-channel tips -- + + async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int]) -> None: + """Pick up tips at the channels' target tip spots. + + Derives the head block the target tip spots form, sets the head mode + and tipbox anchor to match, then picks up. + """ + self._require_identified_head("pick_up_tips") + location, block = self._resolve_channel_group([op.resource for op in ops], role="tip") + self._apply_channel_block(block, location, target="tip") + await self._bravo.tips_on(location) + + async def drop_tips(self, ops: List[Drop], use_channels: List[int]) -> None: + """Drop tips at the channels' target tip spots, or to a shared trash. + + A tip-spot target derives the head block being returned and pins its + tipbox anchor, keeping the head mode active tips were picked up in + (see :meth:`_apply_tip_return_anchor`). A trash target has no item + grid to derive a block from, so the head mode is left untouched; + :meth:`~.bravo.Bravo.tips_off` resolves it from what is already + tracked as mounted. + """ + self._require_identified_head("drop_tips") + resources = [op.resource for op in ops] + if not resources: + raise RuntimeError("At least one tip is required.") + first = resources[0] + if isinstance(first, TipSpot): + if any(not isinstance(r, TipSpot) for r in resources): + raise RuntimeError( + "All drop_tips channels in one call must target the same kind of resource " + "(tip spots or a shared trash), not a mix of both." + ) + location, block = self._resolve_channel_group(resources, role="tip") + self._apply_tip_return_anchor(block, location) + else: + if any(r is not first for r in resources): + names = ", ".join(sorted({r.name for r in resources})) + raise RuntimeError( + f"All drop_tips channels dropping to trash in one call must target the same trash " + f"resource; got {names}." + ) + location = self._resolve_site(first, role="tip trash") + await self._bravo.tips_off(location) + + # -- Per-channel liquid handling -- + + @staticmethod + def _require_no_mix(ops: Sequence[Union[SingleChannelAspiration, SingleChannelDispense]]) -> None: + """Raise if any op carries an embedded mix step; unsupported by this backend.""" + if any(op.mix is not None for op in ops): + raise RuntimeError( + "AgilentBravoBackend does not support a mix step embedded in aspirate/dispense; " + "call Bravo.mix directly instead." + ) + + async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[int]) -> None: + """Aspirate at the channels' target wells. + + Volume and flow rate must be identical across every op, since the head + has one plunger drive; ``liquid_height`` (mapped to + :meth:`~.bravo.Bravo.aspirate`'s ``distance_from_bottom``) and + ``blow_out_air_volume`` (mapped to its ``post_aspirate``) must be too, + for the same reason -- there is only one Z position and one air-gap + volume per call. + """ + self._require_identified_head("aspirate") + self._require_no_mix(ops) + volume = _uniform_value([op.volume for op in ops], label="aspirate volume") + flow_rate = _uniform_optional([op.flow_rate for op in ops], label="flow rate") + liquid_height = _uniform_optional([op.liquid_height for op in ops], label="liquid height") + blow_out = _uniform_optional( + [op.blow_out_air_volume for op in ops], label="blow-out air volume" + ) + location, block = self._resolve_channel_group([op.resource for op in ops], role="well") + self._apply_channel_block(block, location, target="plate") + liquid_class = {"aspirate": {"w_velocity_ul_s": flow_rate}} if flow_rate is not None else None + await self._bravo.aspirate( + location, + volume, + distance_from_bottom=liquid_height if liquid_height is not None else 1.0, + post_aspirate=blow_out or 0.0, + liquid_class=liquid_class, + ) + + async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[int]) -> None: + """Dispense at the channels' target wells. + + See :meth:`aspirate` for why volume, flow rate, ``liquid_height``, and + ``blow_out_air_volume`` must each be uniform across every op. + ``blow_out_air_volume`` maps to :meth:`~.bravo.Bravo.dispense`'s + ``blowout``. + """ + self._require_identified_head("dispense") + self._require_no_mix(ops) + volume = _uniform_value([op.volume for op in ops], label="dispense volume") + flow_rate = _uniform_optional([op.flow_rate for op in ops], label="flow rate") + liquid_height = _uniform_optional([op.liquid_height for op in ops], label="liquid height") + blow_out = _uniform_optional( + [op.blow_out_air_volume for op in ops], label="blow-out air volume" + ) + location, block = self._resolve_channel_group([op.resource for op in ops], role="well") + self._apply_channel_block(block, location, target="plate") + liquid_class = {"dispense": {"w_velocity_ul_s": flow_rate}} if flow_rate is not None else None + await self._bravo.dispense( + location, + volume, + distance_from_bottom=liquid_height if liquid_height is not None else 1.0, + blowout=blow_out or 0.0, + liquid_class=liquid_class, + ) + + # -- 96-head tips -- + + async def pick_up_tips96(self, pickup: PickupTipRack) -> None: + """Pick up tips with the 96 head. + + ``pickup.tips`` carries one entry per rack position, ``None`` where a + position has no tip; a fully-populated rack reduces to the whole head, + a partial one to the block those populated positions form (see + :func:`~.block.head_block_for_identifiers`). + """ + self._require_96_channel_head("pick_up_tips96") + rack = pickup.resource + location = self._resolve_site(rack, role="tip rack") + items = rack.get_all_items() + identifiers = [ + rack.get_child_identifier(item) for item, tip in zip(items, pickup.tips) if tip is not None + ] + if not identifiers: + raise RuntimeError("pick_up_tips96 requires at least one populated tip rack position.") + block = head_block_for_identifiers(identifiers) + self._apply_channel_block(block, location, target="tip") + await self._bravo.tips_on(location) + + async def drop_tips96(self, drop: DropTipRack) -> None: + """Drop tips with the 96 head, to a tip rack or to trash. + + No per-position information is available at this call (unlike + :meth:`pick_up_tips96`), so the head mode is left untouched; + :meth:`~.bravo.Bravo.tips_off` resolves it from what is already + tracked as mounted. + """ + self._require_96_channel_head("drop_tips96") + resource = drop.resource + role = "tip rack" if isinstance(resource, TipRack) else "tip trash" + location = self._resolve_site(resource, role=role) + await self._bravo.tips_off(location) + + # -- 96-head liquid handling -- + + def _resolve_96_location( + self, + op: Union[ + MultiHeadAspirationPlate, + MultiHeadDispensePlate, + MultiHeadAspirationContainer, + MultiHeadDispenseContainer, + ], + ) -> int: + """Return the deck site a 96-head operation targets.""" + if isinstance(op, (MultiHeadAspirationPlate, MultiHeadDispensePlate)): + if not op.wells: + raise RuntimeError("A 96-head plate operation requires at least one well.") + parent = op.wells[0].parent + if parent is None or any(w.parent is not parent for w in op.wells): + raise RuntimeError("All wells in a 96-head operation must belong to the same plate.") + return self._resolve_site(parent, role="plate") + return self._resolve_site(op.container, role="container") + + def _set_96_plate_anchor( + self, location: int, op: Union[MultiHeadAspirationPlate, MultiHeadDispensePlate] + ) -> None: + """Pin the plate anchor for a 96-head plate operation to its wells' own minimum cell. + + The whole head is always active for a 96-head operation (there is no + partial-channel concept here, unlike :meth:`pick_up_tips96`); what + varies is which plate cell aligns with the head's own reference + barrel, which is the smallest (row, col) among the targeted wells -- + correct even when the wells are a strided subset of a higher-density + plate (e.g. a 96-head striping a 384-well plate), since + :class:`~.bravo.Bravo` itself resolves the barrel-to-plate pitch ratio + from the anchor cell and the installed head/plate geometry. + """ + plate = _require_itemized(op.wells[0].parent, role="plate") + positions = [parse_item_identifier(plate.get_child_identifier(well)) for well in op.wells] + anchor_row = min(row for row, _ in positions) + anchor_col = min(col for _, col in positions) + self._bravo.set_plate_selection(location, anchor_row, anchor_col) + + async def aspirate96( + self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] + ) -> None: + """Aspirate a single scalar volume with the whole 96 head. + + Matches the head's single plunger drive exactly: there is one volume, + one flow rate, and one Z position for the whole head, so no + uniformity check is needed here the way it is for the per-channel path. + """ + self._require_96_channel_head("aspirate96") + if aspiration.mix is not None: + raise RuntimeError( + "AgilentBravoBackend does not support a mix step embedded in aspirate96; call " + "Bravo.mix directly instead." + ) + location = self._resolve_96_location(aspiration) + self._bravo.set_head_mode("all_barrels", "back_left") + if isinstance(aspiration, MultiHeadAspirationPlate): + self._set_96_plate_anchor(location, aspiration) + flow_rate = aspiration.flow_rate + liquid_class = {"aspirate": {"w_velocity_ul_s": flow_rate}} if flow_rate is not None else None + height = aspiration.liquid_height + await self._bravo.aspirate( + location, + aspiration.volume, + distance_from_bottom=height if height is not None else 1.0, + post_aspirate=aspiration.blow_out_air_volume or 0.0, + liquid_class=liquid_class, + ) + + async def dispense96( + self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer] + ) -> None: + """Dispense a single scalar volume with the whole 96 head. + + See :meth:`aspirate96`. + """ + self._require_96_channel_head("dispense96") + if dispense.mix is not None: + raise RuntimeError( + "AgilentBravoBackend does not support a mix step embedded in dispense96; call " + "Bravo.mix directly instead." + ) + location = self._resolve_96_location(dispense) + self._bravo.set_head_mode("all_barrels", "back_left") + if isinstance(dispense, MultiHeadDispensePlate): + self._set_96_plate_anchor(location, dispense) + flow_rate = dispense.flow_rate + liquid_class = {"dispense": {"w_velocity_ul_s": flow_rate}} if flow_rate is not None else None + await self._bravo.dispense( + location, + dispense.volume, + distance_from_bottom=dispense.liquid_height if dispense.liquid_height is not None else 1.0, + blowout=dispense.blow_out_air_volume or 0.0, + liquid_class=liquid_class, + ) + + # -- Gripper -- + + def _require_zero_offset(self, offset: Coordinate, *, operation: str) -> None: + """Raise unless *offset* is ``Coordinate.zero()``. + + The Bravo gripper has a single, fixed approach position per taught + site -- there is no axis to apply an arbitrary XYZ offset onto. + Silently ignoring a non-zero offset would place the resource + somewhere other than where the caller asked, with nothing telling + them that happened; rejecting is safer than guessing. + + Args: + offset: The offset to check. + operation: The operation name, for the error message. + """ + if offset != Coordinate.zero(): + raise RuntimeError( + f"{operation}: offset={offset} is not supported -- the Bravo gripper has a single, " + "fixed approach position per deck site and cannot apply an arbitrary offset. Pass " + "Coordinate.zero(), or omit it." + ) + + def _require_front_direction( + self, direction: GripDirection, *, field: str, operation: str + ) -> None: + """Raise unless *direction* is ``GripDirection.FRONT``. + + The Bravo gripper approaches every site from a single fixed side; it + cannot rotate to grip (or place) from the back, left, or right. See + :meth:`_require_zero_offset` for why this rejects rather than ignores. + + Args: + direction: The direction to check. + field: Which field on the op this came from, for the error message. + operation: The operation name, for the error message. + """ + if direction != GripDirection.FRONT: + raise RuntimeError( + f"{operation}: {field}={direction.name} is not supported -- the Bravo gripper always " + "approaches from a single fixed direction (GripDirection.FRONT) and cannot grip or " + "place from another side." + ) + + def _require_zero_rotation(self, rotation: float, *, operation: str) -> None: + """Raise unless *rotation* is ``0.0``. + + The Bravo gripper cannot rotate a resource while carrying it. See + :meth:`_require_zero_offset` for why this rejects rather than ignores. + + Args: + rotation: The rotation, in degrees, to check. + operation: The operation name, for the error message. + """ + if rotation != 0.0: + raise RuntimeError( + f"{operation}: rotation={rotation} is not supported -- the Bravo gripper cannot " + "rotate a resource while carrying it." + ) + + async def pick_up_resource(self, pickup: ResourcePickup) -> None: + """Pick up a resource with the gripper. + + ``pickup.pickup_distance_from_top`` is not representable in the + Bravo's fixed gripper geometry (a single grip depth per taught site) + and is ignored. ``pickup.offset`` and ``pickup.direction`` are + rejected outright when not at their default instead: silently + ignoring a caller's explicit offset or grip direction could place or + grip the resource wrong without any indication that happened (see + :meth:`_require_zero_offset`/:meth:`_require_front_direction`). + + Raises: + RuntimeError: If the gripper is unavailable (see + :meth:`_require_gripper`), *pickup.offset* is not + ``Coordinate.zero()``, or *pickup.direction* is not + ``GripDirection.FRONT``. + """ + self._require_gripper("pick_up_resource") + self._require_zero_offset(pickup.offset, operation="pick_up_resource") + self._require_front_direction(pickup.direction, field="direction", operation="pick_up_resource") + location = self._resolve_site(pickup.resource, role="resource") + await self._bravo.gripper_pick(location) + + async def move_picked_up_resource(self, move: ResourceMove) -> None: + """Move a resource the gripper is holding to hover over a deck site. + + ``move.location`` must match one of the Bravo deck's nine taught sites + (see :meth:`_site_for_coordinate`). ``move.offset`` and + ``move.gripped_direction`` are rejected when not at their default, + for the reason given in :meth:`pick_up_resource`. + + Raises: + RuntimeError: If the gripper is unavailable, *move.offset* is not + ``Coordinate.zero()``, *move.gripped_direction* is not + ``GripDirection.FRONT``, or *move.location* does not match a + taught site. + """ + self._require_gripper("move_picked_up_resource") + self._require_zero_offset(move.offset, operation="move_picked_up_resource") + self._require_front_direction( + move.gripped_direction, field="gripped_direction", operation="move_picked_up_resource" + ) + location = self._site_for_coordinate(move.location) + await self._bravo.gripper_move(location) + + async def drop_resource(self, drop: ResourceDrop) -> None: + """Place the resource the gripper is holding and release it. + + ``drop.destination`` must match one of the Bravo deck's nine taught + sites (see :meth:`_site_for_coordinate`). ``drop.pickup_distance_from_top`` + is ignored, for the reason given in :meth:`pick_up_resource`. + ``drop.offset``, ``drop.pickup_direction``, ``drop.direction``, and + ``drop.rotation`` are rejected when not at their default, for the + same reason a non-default offset/direction is rejected there -- + including ``rotation``, since a caller that asked to place a resource + rotated 90 degrees from how it was picked up would otherwise have it + placed unrotated with no error at all. + + Raises: + RuntimeError: If the gripper is unavailable, *drop.offset* is not + ``Coordinate.zero()``, *drop.pickup_direction* or *drop.direction* + is not ``GripDirection.FRONT``, *drop.rotation* is not ``0.0``, + or *drop.destination* does not match a taught site. + """ + self._require_gripper("drop_resource") + self._require_zero_offset(drop.offset, operation="drop_resource") + self._require_front_direction( + drop.pickup_direction, field="pickup_direction", operation="drop_resource" + ) + self._require_front_direction(drop.direction, field="direction", operation="drop_resource") + self._require_zero_rotation(drop.rotation, operation="drop_resource") + location = self._site_for_coordinate(drop.destination) + await self._bravo.gripper_place(location) + + # -- Tip compatibility -- + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + """Return whether *tip* is compatible with the installed head. + + Every barrel on a Bravo head is identical, so *channel_idx* does not + change the answer. Returns ``False`` for an unidentified head, since + there is then no installed head to check the tip's capacity against. + """ + del channel_idx + head_type = self._bravo.head_type + if head_type == "unknown": + return False + return get_tip_definition(head_type, tip.maximal_volume) is not None diff --git a/pylabrobot/agilent/bravo/backend_tests.py b/pylabrobot/agilent/bravo/backend_tests.py new file mode 100644 index 00000000000..00de607f32e --- /dev/null +++ b/pylabrobot/agilent/bravo/backend_tests.py @@ -0,0 +1,1002 @@ +"""Unit tests for :mod:`.backend`. + +Drives :class:`AgilentBravoBackend` against +:class:`~.controllers.simulation.SimulationController` through a real +:class:`~.bravo.Bravo` and :class:`~.deck.resource.BravoDeck`. Two kinds of +assertion are used, per the two failure modes a golden-frame comparison +alone would miss: most tests inspect :class:`Bravo`'s own tracked state +(head mode, plate/tip selection, tip-on-head flag) directly, since that +state never reaches a controller call as a distinguishable value; a few +inspect the captured controller-call sequence directly, for values (flow +rate, distance from bottom) that only show up as a move target. +""" + +from __future__ import annotations + +import unittest +from typing import List, Tuple + +from pylabrobot.legacy.liquid_handling.liquid_handler import LiquidHandler +from pylabrobot.legacy.liquid_handling.standard import ( + Drop, + DropTipRack, + GripDirection, + MultiHeadAspirationPlate, + MultiHeadDispensePlate, + Pickup, + PickupTipRack, + ResourceDrop, + ResourceMove, + ResourcePickup, + SingleChannelAspiration, + SingleChannelDispense, +) +from pylabrobot.resources import Trash, cor_96_wellplate_360uL_Fb, opentrons_96_tiprack_300ul +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.rotation import Rotation +from pylabrobot.resources.tip import Tip +from pylabrobot.resources.tip_rack import TipRack + +from .backend import AgilentBravoBackend +from .block import HeadBlock, HeadBlockError +from .bravo import Bravo +from .config import BravoMachineConfig +from .deck.resource import BravoDeck +from .deck.teachpoints import Teachpoints +from .state_machine.golden_frame_support import RecordingSimulationController +from .types import HeadType + + +class _GripperlessSimulationController(RecordingSimulationController): + """A simulated controller for the gripperless Bravo SRT. + + :class:`~.controllers.simulation.SimulationController` always reports + ``has_gripper = True`` (the generic default from + :class:`~.controllers.base.BravoController`); the real + :class:`~.controllers.agile_srt.AgileSrtController` overrides both class + attributes the same way this test double does. + """ + + has_gripper = False + model_name = "Bravo SRT" + + +def _make_tip(maximal_volume: float = 30.0) -> Tip: + """Build a disposable tip fixture.""" + return Tip( + has_filter=False, + total_tip_length=50.0, + maximal_volume=maximal_volume, + fitting_depth=8.0, + name="test_tip", + ) + + +def _new_backend( + *, + head_type: HeadType = "96_d_70", + gripper: bool = True, + controller_cls=RecordingSimulationController, +) -> Tuple[AgilentBravoBackend, Bravo, BravoDeck, RecordingSimulationController]: + """Build an AgilentBravoBackend wired up to a real Bravo and BravoDeck.""" + # "unknown" has no default teachpoints of its own (nothing has been + # detected to build them from); build the deck/controller against a real + # head's teachpoints and leave only config.head.head_type -- what + # Bravo.head_type actually reflects -- set to "unknown". + teachpoint_head_type = head_type if head_type != "unknown" else "96_d_70" + ctrl = controller_cls(head_type=teachpoint_head_type) + teachpoints = Teachpoints() + teachpoints.set_default_teachpoints(teachpoint_head_type) + config = BravoMachineConfig() + config.head.head_type = head_type + config.head.teach_tip_length_mm = 26.1 + if not gripper: + config.axes = {k: v for k, v in config.axes.items() if k not in ("g", "zg")} + deck = BravoDeck(head_type=teachpoint_head_type, teachpoints=teachpoints) + bravo = Bravo(ctrl, config=config, deck=deck) + backend = AgilentBravoBackend(bravo) + backend.set_deck(deck) + return backend, bravo, deck, ctrl + + +async def _mount_tips(backend: AgilentBravoBackend, deck: BravoDeck, rack_site: int = 4) -> TipRack: + """Pick up the whole head from the rack at *rack_site*, assigning one if empty.""" + existing = deck.resource_at_site(rack_site) + if existing is None: + rack: TipRack = opentrons_96_tiprack_300ul(name=f"rack_{rack_site}") + deck.assign_child_at_site(rack, rack_site) + else: + assert isinstance(existing, TipRack) + rack = existing + ops = [ + Pickup(resource=spot, offset=Coordinate.zero(), tip=_make_tip()) + for spot in rack.get_all_items() + ] + await backend.pick_up_tips(ops, use_channels=list(range(96))) + return rack + + +class NumChannelsTests(unittest.IsolatedAsyncioTestCase): + """num_channels is rows x columns of the installed head.""" + + async def test_96_d_70_reports_96_channels(self): + backend, _, _, _ = _new_backend(head_type="96_d_70") + self.assertEqual(backend.num_channels, 96) + + async def test_8_d_lt_reports_8_channels(self): + backend, _, _, _ = _new_backend(head_type="8_d_lt") + self.assertEqual(backend.num_channels, 8) + + async def test_384_d_70_reports_384_channels(self): + backend, _, _, _ = _new_backend(head_type="384_d_70") + self.assertEqual(backend.num_channels, 384) + + +class NumArmsTests(unittest.IsolatedAsyncioTestCase): + """num_arms/head96_installed derive from the installed model, not the base class default. + + LiquidHandlerBackend's own defaults (num_arms=0, head96_installed=False) + are exactly wrong for a gripper-equipped, 96-channel Bravo: left + unoverridden, LiquidHandler.setup() builds an empty _resource_pickups + map and an empty head96 tracker dict, so every gripper call and every + 96-head call fails at the LiquidHandler layer before this backend is + ever reached -- regardless of how complete pick_up_resource or + aspirate96 are. The tests below drive a real LiquidHandler, not just + the backend directly, since that is the only way this class of gap + shows up at all. + """ + + async def test_gripper_model_reports_one_arm(self): + backend, _, _, _ = _new_backend(gripper=True) + self.assertEqual(backend.num_arms, 1) + + async def test_srt_reports_zero_arms(self): + backend, _, _, _ = _new_backend(gripper=True, controller_cls=_GripperlessSimulationController) + self.assertEqual(backend.num_arms, 0) + + async def test_96_channel_head_reports_head96_installed(self): + backend, _, _, _ = _new_backend(head_type="96_d_70") + self.assertTrue(backend.head96_installed) + + async def test_8_channel_head_reports_head96_not_installed(self): + backend, _, _, _ = _new_backend(head_type="8_d_lt") + self.assertFalse(backend.head96_installed) + + async def test_move_plate_round_trips_through_a_real_liquid_handler(self): + # The end-to-end path a PyLabRobot user actually drives: LiquidHandler + # -> AgilentBravoBackend -> Bravo. A test that only calls + # backend.pick_up_resource/move_picked_up_resource/drop_resource + # directly cannot see a num_arms gap, because LiquidHandler is the + # layer that reads it. + backend, bravo, deck, _ = _new_backend(gripper=True) + lh = LiquidHandler(backend=backend, deck=deck) + await lh.setup() + plate = cor_96_wellplate_360uL_Fb(name="source_plate") + deck.assign_child_at_site(plate, 1) + await lh.move_plate(plate, deck._site_holders[2]) + self.assertEqual(deck.site_for_resource(plate), 2) + self.assertIsNone(bravo.get_labware(1)) + self.assertIsNotNone(bravo.get_labware(2)) + + async def test_move_plate_on_the_srt_raises_pylabrobots_own_no_arm_error(self): + backend, bravo, deck, _ = _new_backend( + gripper=True, controller_cls=_GripperlessSimulationController + ) + lh = LiquidHandler(backend=backend, deck=deck) + await lh.setup() + plate = cor_96_wellplate_360uL_Fb(name="source_plate") + deck.assign_child_at_site(plate, 1) + with self.assertRaises(RuntimeError) as ctx: + await lh.move_plate(plate, deck._site_holders[2]) + # This is PyLabRobot's own LiquidHandler-level error (raised before + # the backend's pick_up_resource -- and its own SRT rejection -- is + # ever reached), not AgilentBravoBackend._require_gripper's. + self.assertIn("No robotic arm is installed", str(ctx.exception)) + + +class SetupStopTests(unittest.IsolatedAsyncioTestCase): + """setup/stop delegate to Bravo and log the unverified-hardware warning.""" + + async def test_setup_logs_the_unverified_hardware_warning(self): + backend, _, _, _ = _new_backend() + with self.assertLogs("pylabrobot.agilent.bravo.backend", level="WARNING") as ctx: + await backend.setup() + self.assertTrue(any("unverified" in message for message in ctx.output)) + self.assertTrue(any("discuss.pylabrobot.org" in message for message in ctx.output)) + self.assertTrue(backend.setup_finished) + + async def test_stop_clears_setup_finished(self): + backend, _, _, _ = _new_backend() + await backend.setup() + await backend.stop() + self.assertFalse(backend.setup_finished) + + async def test_setup_without_a_deck_raises(self): + ctrl = RecordingSimulationController() + bravo = Bravo(ctrl, config=BravoMachineConfig(), deck=None) + backend = AgilentBravoBackend(bravo) + with self.assertRaises(AssertionError): + await backend.setup() + + +class DeckRequirementTests(unittest.IsolatedAsyncioTestCase): + """Operations require a BravoDeck, not a generic PyLabRobot Deck.""" + + async def test_non_bravo_deck_is_rejected(self): + from pylabrobot.resources.deck import Deck + + ctrl = RecordingSimulationController() + bravo = Bravo(ctrl, config=BravoMachineConfig(), deck=None) + backend = AgilentBravoBackend(bravo) + backend.set_deck(Deck(size_x=1, size_y=1, size_z=1, name="generic")) + plate = cor_96_wellplate_360uL_Fb(name="p1") + op = SingleChannelAspiration( + resource=plate.get_item("A1"), + offset=Coordinate.zero(), + tip=_make_tip(), + volume=10.0, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ) + with self.assertRaises(RuntimeError) as ctx: + await backend.aspirate([op], use_channels=[0]) + self.assertIn("BravoDeck", str(ctx.exception)) + + +class PickUpDropTipsHeadModeTests(unittest.IsolatedAsyncioTestCase): + """pick_up_tips/drop_tips derive the head block and set the matching mode.""" + + async def test_single_op_produces_single_barrel_mode(self): + backend, bravo, deck, _ = await self._backend_with_rack() + rack = deck.resource_at_site(4) + op = Pickup(resource=rack.get_item("A1"), offset=Coordinate.zero(), tip=_make_tip()) + await backend.pick_up_tips([op], use_channels=[0]) + self.assertEqual(bravo.head_mode.subset_type, "single_barrel") + self.assertTrue(bravo._tips_on_head) + + async def test_full_column_produces_column_mode(self): + backend, bravo, deck, _ = await self._backend_with_rack() + rack = deck.resource_at_site(4) + spots = [rack.get_item(f"{row}1") for row in "ABCDEFGH"] + ops = [Pickup(resource=spot, offset=Coordinate.zero(), tip=_make_tip()) for spot in spots] + await backend.pick_up_tips(ops, use_channels=list(range(8))) + self.assertEqual(bravo.head_mode.subset_type, "column") + self.assertEqual(bravo.head_mode.column_count, 1) + + async def test_full_row_produces_row_mode(self): + backend, bravo, deck, _ = await self._backend_with_rack() + rack = deck.resource_at_site(4) + spots = [rack.get_item(f"A{col}") for col in range(1, 13)] + ops = [Pickup(resource=spot, offset=Coordinate.zero(), tip=_make_tip()) for spot in spots] + await backend.pick_up_tips(ops, use_channels=list(range(12))) + self.assertEqual(bravo.head_mode.subset_type, "row") + self.assertEqual(bravo.head_mode.row_count, 1) + + async def test_whole_rack_produces_all_barrels_mode(self): + backend, bravo, deck, _ = await self._backend_with_rack() + await _mount_tips(backend, deck) + self.assertEqual(bravo.head_mode.subset_type, "all_barrels") + + async def test_drop_tips_returns_the_same_block_to_the_rack(self): + # A full column, not a single barrel: Bravo's own return-legality + # tracking (see bravo.py's _is_legal_tipbox_anchor "return" branch) + # walks a full row/column band back in from the edge it was consumed + # from, so only a return that spans a full row or column round-trips + # cleanly against a box that started completely full. + backend, bravo, deck, _ = await self._backend_with_rack() + rack = deck.resource_at_site(4) + spots = [rack.get_item(f"{row}1") for row in "ABCDEFGH"] + ops = [Pickup(resource=spot, offset=Coordinate.zero(), tip=_make_tip()) for spot in spots] + await backend.pick_up_tips(ops, use_channels=list(range(8))) + drops = [Drop(resource=spot, offset=Coordinate.zero(), tip=_make_tip()) for spot in spots] + await backend.drop_tips(drops, use_channels=list(range(8))) + self.assertFalse(bravo._tips_on_head) + + async def test_single_barrel_return_to_a_still_full_box_is_rejected_clearly(self): + # A genuine instrument/tracking constraint, not a backend defect (see + # _apply_tip_return_anchor's docstring): a single-barrel return is + # only legal once the rest of its row has also been vacated. Picking + # then immediately returning one tip out of an otherwise-full box + # hits exactly that constraint, and should say so rather than + # surfacing Bravo's own lower-level "not accessible for return". + backend, bravo, deck, _ = await self._backend_with_rack() + rack = deck.resource_at_site(4) + op = Pickup(resource=rack.get_item("A1"), offset=Coordinate.zero(), tip=_make_tip()) + await backend.pick_up_tips([op], use_channels=[0]) + drop = Drop(resource=rack.get_item("A1"), offset=Coordinate.zero(), tip=_make_tip()) + with self.assertRaises(RuntimeError) as ctx: + await backend.drop_tips([drop], use_channels=[0]) + message = str(ctx.exception) + self.assertIn("does not span the tip box's full width or height", message) + self.assertIn("Bravo's own error", message) + # Tips are still tracked as mounted: the rejected return must not + # have silently cleared Bravo's own tip-on-head state. + self.assertTrue(bravo._tips_on_head) + + async def test_drop_tips_to_a_shared_trash_ejects_whatever_is_mounted(self): + # End-to-end through the public path only: BravoDeck.assign_child_at_site + # plus a real pylabrobot.resources.trash.Trash, with no pre-seeded + # Labware. This is the path that caught deck/resource.py's Trash -> + # "plate" mapping gap (fixed by mapping Trash to "tip_trash" there); a + # white-box test that pre-seeds the tip_trash Labware cannot see that + # class of bug, because it never asks BravoDeck to do the translation. + backend, bravo, deck, _ = await self._backend_with_rack() + rack = deck.resource_at_site(4) + op = Pickup(resource=rack.get_item("A1"), offset=Coordinate.zero(), tip=_make_tip()) + await backend.pick_up_tips([op], use_channels=[0]) + # Use a site far from the rack (site 9, the opposite corner of the 3x3 + # grid from site 4) so the head's neighbor-clearance check has nothing + # tall nearby to trip on -- unrelated to what this test is pinning. + trash = Trash(name="trash1", size_x=127.0, size_y=85.0, size_z=10.0) + deck.assign_child_at_site(trash, 9) + drop = Drop(resource=trash, offset=Coordinate.zero(), tip=_make_tip()) + await backend.drop_tips([drop], use_channels=[0]) + self.assertFalse(bravo._tips_on_head) + + async def test_mixed_tip_spot_and_trash_drop_is_rejected(self): + backend, bravo, deck, _ = await self._backend_with_rack() + rack = deck.resource_at_site(4) + trash = Trash(name="trash2", size_x=127.0, size_y=85.0, size_z=10.0) + deck.assign_child_at_site(trash, 5) + drops = [ + Drop(resource=rack.get_item("A1"), offset=Coordinate.zero(), tip=_make_tip()), + Drop(resource=trash, offset=Coordinate.zero(), tip=_make_tip()), + ] + with self.assertRaises(RuntimeError) as ctx: + await backend.drop_tips(drops, use_channels=[0, 1]) + self.assertIn("same kind of resource", str(ctx.exception)) + + async def _backend_with_rack(self): + backend, bravo, deck, ctrl = _new_backend() + rack = opentrons_96_tiprack_300ul(name="rack4") + deck.assign_child_at_site(rack, 4) + return backend, bravo, deck, ctrl + + +class PickUpTipsRejectionTests(unittest.IsolatedAsyncioTestCase): + """pick_up_tips: contiguity and unassigned-labware rejections.""" + + async def test_non_contiguous_selection_is_rejected_with_a_clear_message(self): + backend, _, deck, _ = _new_backend() + rack = opentrons_96_tiprack_300ul(name="rack4") + deck.assign_child_at_site(rack, 4) + ops = [ + Pickup(resource=rack.get_item("A1"), offset=Coordinate.zero(), tip=_make_tip()), + Pickup(resource=rack.get_item("B2"), offset=Coordinate.zero(), tip=_make_tip()), + ] + # HeadBlockError (raised by block.head_block_for_identifiers) is a + # ValueError, not a RuntimeError: it is not wrapped, since it already + # carries a clear, specific message. + with self.assertRaises(HeadBlockError) as ctx: + await backend.pick_up_tips(ops, use_channels=[0, 1]) + message = str(ctx.exception) + self.assertIn("do not form a contiguous rectangular block", message) + + async def test_unassigned_rack_is_rejected_by_name(self): + backend, _, _deck, _ = _new_backend() + rack = opentrons_96_tiprack_300ul(name="unassigned_rack") + ops = [Pickup(resource=rack.get_item("A1"), offset=Coordinate.zero(), tip=_make_tip())] + with self.assertRaises(RuntimeError) as ctx: + await backend.pick_up_tips(ops, use_channels=[0]) + self.assertIn("unassigned_rack", str(ctx.exception)) + + +class OversizedBlockRejectionTests(unittest.IsolatedAsyncioTestCase): + """A block that does not fit the installed head is rejected before dispatch.""" + + async def test_two_columns_on_a_single_column_head_is_rejected(self): + backend, bravo, deck, _ = _new_backend(head_type="8_d_lt") + rack = opentrons_96_tiprack_300ul(name="rack4") + deck.assign_child_at_site(rack, 4) + ops = [ + Pickup(resource=rack.get_item("A1"), offset=Coordinate.zero(), tip=_make_tip()), + Pickup(resource=rack.get_item("A2"), offset=Coordinate.zero(), tip=_make_tip()), + ] + with self.assertRaises(RuntimeError) as ctx: + await backend.pick_up_tips(ops, use_channels=[0, 1]) + message = str(ctx.exception) + self.assertIn("does not fit the installed", message) + self.assertIn("8x1", message) + + async def test_require_block_fits_directly_rejects_an_oversized_block(self): + # Calls _require_block_fits in isolation from every other operation + # precondition (deck assignment, tip catalogue, teachpoints): the + # mutation-testing report flagged that the end-to-end test above can + # be "killed" by an unrelated downstream error (an unconfigured tip + # length for the 8_d_lt head, reached only once the fits check is + # disabled) rather than by this exact assertion. This test can only + # fail here, on this message, since nothing else runs. + backend, _, _, _ = _new_backend(head_type="8_d_lt") + block = HeadBlock(row_start=0, row_stop=1, col_start=0, col_stop=2) + with self.assertRaises(RuntimeError) as ctx: + backend._require_block_fits(block) + message = str(ctx.exception) + self.assertIn("does not fit the installed", message) + self.assertIn("8x1", message) + + async def test_require_block_fits_accepts_a_block_that_fits(self): + backend, _, _, _ = _new_backend(head_type="8_d_lt") + block = HeadBlock(row_start=0, row_stop=8, col_start=0, col_stop=1) + backend._require_block_fits(block) # does not raise + + +class AspirateDispenseTests(unittest.IsolatedAsyncioTestCase): + """aspirate/dispense: block/mode derivation and value uniformity.""" + + async def _backend_ready_to_pipette(self): + backend, bravo, deck, ctrl = _new_backend() + plate = cor_96_wellplate_360uL_Fb(name="plate5") + deck.assign_child_at_site(plate, 5) + await backend.setup() + await _mount_tips(backend, deck) + ctrl.calls.clear() + return backend, bravo, deck, ctrl, plate + + async def test_aspirate_on_one_well_sets_single_barrel_mode_and_its_anchor(self): + backend, bravo, deck, ctrl, plate = await self._backend_ready_to_pipette() + op = SingleChannelAspiration( + resource=plate.get_item("C4"), + offset=Coordinate.zero(), + tip=_make_tip(), + volume=25.0, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ) + await backend.aspirate([op], use_channels=[0]) + self.assertEqual(bravo.head_mode.subset_type, "single_barrel") + self.assertEqual((bravo._plate_selection[5].row, bravo._plate_selection[5].col), (2, 3)) + w_moves = [ + m for c in ctrl.calls if c["method"] == "move" for m in c["args"]["moves"] if m["axis"] == "w" + ] + self.assertTrue(any(abs(m["position"] - 25.0) < 1e-6 for m in w_moves)) + + async def test_aspirate_full_row_sets_row_mode(self): + backend, bravo, deck, ctrl, plate = await self._backend_ready_to_pipette() + ops = [ + SingleChannelAspiration( + resource=plate.get_item(f"A{col}"), + offset=Coordinate.zero(), + tip=_make_tip(), + volume=10.0, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ) + for col in range(1, 13) + ] + await backend.aspirate(ops, use_channels=list(range(12))) + self.assertEqual(bravo.head_mode.subset_type, "row") + + async def test_non_uniform_volume_is_rejected_naming_the_values(self): + backend, bravo, deck, ctrl, plate = await self._backend_ready_to_pipette() + ops = [ + SingleChannelAspiration( + resource=plate.get_item("A1"), + offset=Coordinate.zero(), + tip=_make_tip(), + volume=10.0, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ), + SingleChannelAspiration( + resource=plate.get_item("B1"), + offset=Coordinate.zero(), + tip=_make_tip(), + volume=20.0, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ), + ] + with self.assertRaises(RuntimeError) as ctx: + await backend.aspirate(ops, use_channels=[0, 1]) + message = str(ctx.exception) + self.assertIn("single aspirate volume", message) + self.assertIn("10.0", message) + self.assertIn("20.0", message) + + async def test_non_uniform_flow_rate_is_rejected_naming_the_values(self): + backend, bravo, deck, ctrl, plate = await self._backend_ready_to_pipette() + ops = [ + SingleChannelAspiration( + resource=plate.get_item("A1"), + offset=Coordinate.zero(), + tip=_make_tip(), + volume=10.0, + flow_rate=5.0, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ), + SingleChannelAspiration( + resource=plate.get_item("B1"), + offset=Coordinate.zero(), + tip=_make_tip(), + volume=10.0, + flow_rate=7.5, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ), + ] + with self.assertRaises(RuntimeError) as ctx: + await backend.aspirate(ops, use_channels=[0, 1]) + message = str(ctx.exception) + self.assertIn("flow rate", message) + self.assertIn("5.0", message) + self.assertIn("7.5", message) + + async def test_flow_rate_reaches_the_w_axis_move_velocity(self): + async def w_velocity_for(flow_rate): + backend, bravo, deck, ctrl, plate = await self._backend_ready_to_pipette() + op = SingleChannelAspiration( + resource=plate.get_item("A1"), + offset=Coordinate.zero(), + tip=_make_tip(), + volume=10.0, + flow_rate=flow_rate, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ) + await backend.aspirate([op], use_channels=[0]) + w_moves = [ + m + for c in ctrl.calls + if c["method"] == "move" + for m in c["args"]["moves"] + if m["axis"] == "w" + ] + return {m["velocity"] for m in w_moves} + + default_velocities = await w_velocity_for(None) + overridden_velocities = await w_velocity_for(12.5) + self.assertNotIn(12.5, default_velocities) + self.assertIn(12.5, overridden_velocities) + + async def test_liquid_height_reaches_the_z_target(self): + async def z_targets_for(liquid_height): + backend, bravo, deck, ctrl, plate = await self._backend_ready_to_pipette() + op = SingleChannelAspiration( + resource=plate.get_item("A1"), + offset=Coordinate.zero(), + tip=_make_tip(), + volume=10.0, + flow_rate=None, + liquid_height=liquid_height, + blow_out_air_volume=None, + mix=None, + ) + await backend.aspirate([op], use_channels=[0]) + return tuple( + m["position"] + for c in ctrl.calls + if c["method"] == "move" + for m in c["args"]["moves"] + if m["axis"] == "z" + ) + + near = await z_targets_for(1.0) + far = await z_targets_for(8.0) + self.assertNotEqual(near, far) + + async def test_mix_embedded_in_an_op_is_rejected(self): + from pylabrobot.legacy.liquid_handling.standard import Mix + + backend, bravo, deck, ctrl, plate = await self._backend_ready_to_pipette() + op = SingleChannelAspiration( + resource=plate.get_item("A1"), + offset=Coordinate.zero(), + tip=_make_tip(), + volume=10.0, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=None, + mix=Mix(volume=5.0, repetitions=2, flow_rate=5.0), + ) + with self.assertRaises(RuntimeError) as ctx: + await backend.aspirate([op], use_channels=[0]) + self.assertIn("mix", str(ctx.exception)) + + async def test_dispense_maps_blow_out_to_blowout(self): + # blow_out_air_volume is folded into the same W move as an additive + # total (see DispenseTask: total = volume + blowout), not a separate + # move -- so the signal to pin is the move's target position, not an + # extra move appearing. + async def w_targets_for(blow_out_air_volume): + # Aspirate more than volume + blowout will ever consume, so the + # dispense target position (aspirated - (volume + blowout)) stays + # positive and distinguishable instead of clamping to 0 in both + # cases (see DispenseTask._target_w_after_dispense). + backend, bravo, deck, ctrl, plate = await self._backend_ready_to_pipette() + aspirate_op = SingleChannelAspiration( + resource=plate.get_item("A1"), + offset=Coordinate.zero(), + tip=_make_tip(), + volume=50.0, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ) + await backend.aspirate([aspirate_op], use_channels=[0]) + ctrl.calls.clear() + dispense_op = SingleChannelDispense( + resource=plate.get_item("A1"), + offset=Coordinate.zero(), + tip=_make_tip(), + volume=10.0, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=blow_out_air_volume, + mix=None, + ) + await backend.dispense([dispense_op], use_channels=[0]) + return tuple( + m["position"] + for c in ctrl.calls + if c["method"] == "move" + for m in c["args"]["moves"] + if m["axis"] == "w" + ) + + without_blowout = await w_targets_for(None) + with_blowout = await w_targets_for(5.0) + self.assertNotEqual(without_blowout, with_blowout) + + +class Tips96Tests(unittest.IsolatedAsyncioTestCase): + """pick_up_tips96/drop_tips96.""" + + async def test_full_rack_pickup_is_all_barrels(self): + backend, bravo, deck, ctrl = _new_backend() + rack = opentrons_96_tiprack_300ul(name="rack4") + deck.assign_child_at_site(rack, 4) + items = rack.get_all_items() + tips = [_make_tip() for _ in items] + pickup = PickupTipRack(resource=rack, offset=Coordinate.zero(), tips=tips) + await backend.pick_up_tips96(pickup) + self.assertEqual(bravo.head_mode.subset_type, "all_barrels") + self.assertTrue(bravo._tips_on_head) + + async def test_partial_rack_pickup_derives_the_populated_block(self): + backend, bravo, deck, ctrl = _new_backend() + rack = opentrons_96_tiprack_300ul(name="rack4") + deck.assign_child_at_site(rack, 4) + items = rack.get_all_items() + tips: List = [] + for item in items: + identifier = rack.get_child_identifier(item) + tips.append(_make_tip() if identifier in ("A1", "B1") else None) + pickup = PickupTipRack(resource=rack, offset=Coordinate.zero(), tips=tips) + await backend.pick_up_tips96(pickup) + # A1 and B1 only: a 2x1 block, not a full column (which would need all + # 8 rows) -- so this reduces to "rectangle", not "column". + self.assertEqual(bravo.head_mode.subset_type, "rectangle") + self.assertEqual(bravo.head_mode.row_count, 2) + self.assertEqual(bravo.head_mode.column_count, 1) + + async def test_fully_empty_rack_pickup_is_rejected(self): + backend, bravo, deck, ctrl = _new_backend() + rack = opentrons_96_tiprack_300ul(name="rack4") + deck.assign_child_at_site(rack, 4) + pickup = PickupTipRack( + resource=rack, offset=Coordinate.zero(), tips=[None for _ in rack.get_all_items()] + ) + with self.assertRaises(RuntimeError) as ctx: + await backend.pick_up_tips96(pickup) + self.assertIn("at least one populated", str(ctx.exception)) + + async def test_drop_tips96_ejects_whatever_is_mounted(self): + backend, bravo, deck, ctrl = _new_backend() + rack = opentrons_96_tiprack_300ul(name="rack4") + deck.assign_child_at_site(rack, 4) + await _mount_tips(backend, deck) + drop = DropTipRack(resource=rack, offset=Coordinate.zero()) + await backend.drop_tips96(drop) + self.assertFalse(bravo._tips_on_head) + + async def test_96_path_rejected_on_a_non_96_channel_head(self): + backend, bravo, deck, ctrl = _new_backend(head_type="384_d_70") + rack = opentrons_96_tiprack_300ul(name="rack4") + deck.assign_child_at_site(rack, 4) + pickup = PickupTipRack( + resource=rack, offset=Coordinate.zero(), tips=[_make_tip() for _ in rack.get_all_items()] + ) + with self.assertRaises(RuntimeError) as ctx: + await backend.pick_up_tips96(pickup) + message = str(ctx.exception) + self.assertIn("requires a 96-channel head", message) + self.assertIn("384_d_70", message) + + async def test_unidentified_head_is_rejected(self): + backend, bravo, deck, ctrl = _new_backend(head_type="unknown") + rack = opentrons_96_tiprack_300ul(name="rack4") + deck.assign_child_at_site(rack, 4) + pickup = PickupTipRack( + resource=rack, offset=Coordinate.zero(), tips=[_make_tip() for _ in rack.get_all_items()] + ) + with self.assertRaises(RuntimeError) as ctx: + await backend.pick_up_tips96(pickup) + self.assertIn("not been identified", str(ctx.exception)) + + +class AspirateDispense96Tests(unittest.IsolatedAsyncioTestCase): + """aspirate96/dispense96: single scalar volume, whole head.""" + + async def test_aspirate96_sets_all_barrels_and_anchors_at_the_minimum_well(self): + backend, bravo, deck, ctrl = _new_backend() + plate = cor_96_wellplate_360uL_Fb(name="plate5") + deck.assign_child_at_site(plate, 5) + await _mount_tips(backend, deck) + wells = plate.get_all_items() + op = MultiHeadAspirationPlate( + wells=wells, + offset=Coordinate.zero(), + tips=[_make_tip() for _ in wells], + volume=15.0, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ) + await backend.aspirate96(op) + self.assertEqual(bravo.head_mode.subset_type, "all_barrels") + self.assertEqual((bravo._plate_selection[5].row, bravo._plate_selection[5].col), (0, 0)) + + async def test_dispense96_mix_is_rejected(self): + from pylabrobot.legacy.liquid_handling.standard import Mix + + backend, bravo, deck, ctrl = _new_backend() + plate = cor_96_wellplate_360uL_Fb(name="plate5") + deck.assign_child_at_site(plate, 5) + await _mount_tips(backend, deck) + wells = plate.get_all_items() + op = MultiHeadDispensePlate( + wells=wells, + offset=Coordinate.zero(), + tips=[_make_tip() for _ in wells], + volume=15.0, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=None, + mix=Mix(volume=5.0, repetitions=1, flow_rate=5.0), + ) + with self.assertRaises(RuntimeError) as ctx: + await backend.dispense96(op) + self.assertIn("mix", str(ctx.exception)) + + +class GripperTests(unittest.IsolatedAsyncioTestCase): + """pick_up_resource/move_picked_up_resource/drop_resource.""" + + async def test_full_cycle_moves_the_labware(self): + backend, bravo, deck, ctrl = _new_backend(gripper=True) + plate = cor_96_wellplate_360uL_Fb(name="source_plate") + deck.assign_child_at_site(plate, 1) + pickup = ResourcePickup( + resource=plate, + offset=Coordinate.zero(), + pickup_distance_from_top=5.0, + direction=GripDirection.FRONT, + ) + await backend.pick_up_resource(pickup) + self.assertIsNotNone(bravo._gripper_held_task) + + move = ResourceMove( + resource=plate, + location=Coordinate( + x=deck.teachpoints.get_teachpoint(2, "x"), y=deck.teachpoints.get_teachpoint(2, "y"), z=0.0 + ), + gripped_direction=GripDirection.FRONT, + pickup_distance_from_top=5.0, + offset=Coordinate.zero(), + ) + await backend.move_picked_up_resource(move) + + drop = ResourceDrop( + resource=plate, + destination=Coordinate( + x=deck.teachpoints.get_teachpoint(2, "x"), y=deck.teachpoints.get_teachpoint(2, "y"), z=0.0 + ), + destination_absolute_rotation=Rotation(0, 0, 0), + offset=Coordinate.zero(), + pickup_distance_from_top=5.0, + pickup_direction=GripDirection.FRONT, + direction=GripDirection.FRONT, + rotation=0.0, + ) + await backend.drop_resource(drop) + self.assertIsNone(bravo._gripper_held_task) + self.assertIsNone(bravo.get_labware(1)) + self.assertIsNotNone(bravo.get_labware(2)) + + async def test_pick_up_resource_rejects_a_non_zero_offset(self): + backend, bravo, deck, ctrl = _new_backend(gripper=True) + plate = cor_96_wellplate_360uL_Fb(name="source_plate") + deck.assign_child_at_site(plate, 1) + pickup = ResourcePickup( + resource=plate, + offset=Coordinate(1.0, 0.0, 0.0), + pickup_distance_from_top=5.0, + direction=GripDirection.FRONT, + ) + with self.assertRaises(RuntimeError) as ctx: + await backend.pick_up_resource(pickup) + self.assertIn("offset", str(ctx.exception)) + + async def test_pick_up_resource_rejects_a_non_front_direction(self): + backend, bravo, deck, ctrl = _new_backend(gripper=True) + plate = cor_96_wellplate_360uL_Fb(name="source_plate") + deck.assign_child_at_site(plate, 1) + pickup = ResourcePickup( + resource=plate, + offset=Coordinate.zero(), + pickup_distance_from_top=5.0, + direction=GripDirection.LEFT, + ) + with self.assertRaises(RuntimeError) as ctx: + await backend.pick_up_resource(pickup) + message = str(ctx.exception) + self.assertIn("direction", message) + self.assertIn("LEFT", message) + + async def test_move_picked_up_resource_rejects_a_non_zero_offset(self): + backend, bravo, deck, ctrl = _new_backend(gripper=True) + plate = cor_96_wellplate_360uL_Fb(name="source_plate") + deck.assign_child_at_site(plate, 1) + pickup = ResourcePickup( + resource=plate, + offset=Coordinate.zero(), + pickup_distance_from_top=5.0, + direction=GripDirection.FRONT, + ) + await backend.pick_up_resource(pickup) + move = ResourceMove( + resource=plate, + location=Coordinate( + x=deck.teachpoints.get_teachpoint(2, "x"), y=deck.teachpoints.get_teachpoint(2, "y"), z=0.0 + ), + gripped_direction=GripDirection.FRONT, + pickup_distance_from_top=5.0, + offset=Coordinate(0.0, 1.0, 0.0), + ) + with self.assertRaises(RuntimeError) as ctx: + await backend.move_picked_up_resource(move) + self.assertIn("offset", str(ctx.exception)) + + async def test_drop_resource_rejects_a_non_zero_rotation(self): + # A caller asking for a 90-degree rotated placement without checking + # for an error would otherwise get the plate placed unrotated, in the + # wrong orientation, with no indication anything went wrong. + backend, bravo, deck, ctrl = _new_backend(gripper=True) + plate = cor_96_wellplate_360uL_Fb(name="source_plate") + deck.assign_child_at_site(plate, 1) + pickup = ResourcePickup( + resource=plate, + offset=Coordinate.zero(), + pickup_distance_from_top=5.0, + direction=GripDirection.FRONT, + ) + await backend.pick_up_resource(pickup) + drop = ResourceDrop( + resource=plate, + destination=Coordinate( + x=deck.teachpoints.get_teachpoint(2, "x"), y=deck.teachpoints.get_teachpoint(2, "y"), z=0.0 + ), + destination_absolute_rotation=Rotation(0, 0, 0), + offset=Coordinate.zero(), + pickup_distance_from_top=5.0, + pickup_direction=GripDirection.FRONT, + direction=GripDirection.FRONT, + rotation=90.0, + ) + with self.assertRaises(RuntimeError) as ctx: + await backend.drop_resource(drop) + self.assertIn("rotation", str(ctx.exception)) + + async def test_drop_resource_rejects_a_non_front_drop_direction(self): + backend, bravo, deck, ctrl = _new_backend(gripper=True) + plate = cor_96_wellplate_360uL_Fb(name="source_plate") + deck.assign_child_at_site(plate, 1) + pickup = ResourcePickup( + resource=plate, + offset=Coordinate.zero(), + pickup_distance_from_top=5.0, + direction=GripDirection.FRONT, + ) + await backend.pick_up_resource(pickup) + drop = ResourceDrop( + resource=plate, + destination=Coordinate( + x=deck.teachpoints.get_teachpoint(2, "x"), y=deck.teachpoints.get_teachpoint(2, "y"), z=0.0 + ), + destination_absolute_rotation=Rotation(0, 0, 0), + offset=Coordinate.zero(), + pickup_distance_from_top=5.0, + pickup_direction=GripDirection.FRONT, + direction=GripDirection.BACK, + rotation=0.0, + ) + with self.assertRaises(RuntimeError) as ctx: + await backend.drop_resource(drop) + message = str(ctx.exception) + self.assertIn("direction", message) + self.assertIn("BACK", message) + + async def test_drop_destination_not_matching_a_site_is_rejected(self): + backend, bravo, deck, ctrl = _new_backend(gripper=True) + plate = cor_96_wellplate_360uL_Fb(name="source_plate") + deck.assign_child_at_site(plate, 1) + pickup = ResourcePickup( + resource=plate, + offset=Coordinate.zero(), + pickup_distance_from_top=5.0, + direction=GripDirection.FRONT, + ) + await backend.pick_up_resource(pickup) + drop = ResourceDrop( + resource=plate, + destination=Coordinate(x=99999.0, y=99999.0, z=0.0), + destination_absolute_rotation=Rotation(0, 0, 0), + offset=Coordinate.zero(), + pickup_distance_from_top=5.0, + pickup_direction=GripDirection.FRONT, + direction=GripDirection.FRONT, + rotation=0.0, + ) + with self.assertRaises(RuntimeError) as ctx: + await backend.drop_resource(drop) + self.assertIn("taught sites", str(ctx.exception)) + + async def test_srt_gripper_operations_are_rejected_naming_the_model(self): + backend, bravo, deck, ctrl = _new_backend( + gripper=True, controller_cls=_GripperlessSimulationController + ) + plate = cor_96_wellplate_360uL_Fb(name="source_plate") + deck.assign_child_at_site(plate, 1) + pickup = ResourcePickup( + resource=plate, + offset=Coordinate.zero(), + pickup_distance_from_top=5.0, + direction=GripDirection.FRONT, + ) + with self.assertRaises(RuntimeError) as ctx: + await backend.pick_up_resource(pickup) + message = str(ctx.exception) + self.assertIn("Bravo SRT", message) + self.assertIn("no gripper", message) + + +class CanPickUpTipTests(unittest.IsolatedAsyncioTestCase): + """can_pick_up_tip: tip capacity against the installed head.""" + + async def test_compatible_tip_is_accepted(self): + backend, _, _, _ = _new_backend(head_type="96_d_70") + self.assertTrue(backend.can_pick_up_tip(0, _make_tip(30.0))) + + async def test_channel_idx_does_not_change_the_answer(self): + backend, _, _, _ = _new_backend(head_type="96_d_70") + tip = _make_tip(30.0) + results = {backend.can_pick_up_tip(ch, tip) for ch in range(96)} + self.assertEqual(results, {True}) + + async def test_incompatible_capacity_is_rejected(self): + backend, _, _, _ = _new_backend(head_type="96_d_70") + self.assertFalse(backend.can_pick_up_tip(0, _make_tip(999.0))) + + async def test_unidentified_head_rejects_every_tip(self): + backend, _, _, _ = _new_backend(head_type="unknown") + self.assertFalse(backend.can_pick_up_tip(0, _make_tip(30.0))) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/block.py b/pylabrobot/agilent/bravo/block.py new file mode 100644 index 00000000000..91e2f425885 --- /dev/null +++ b/pylabrobot/agilent/bravo/block.py @@ -0,0 +1,171 @@ +"""Translate PyLabRobot items into an Agilent Bravo head block. + +Every operation the Bravo pipetting head can perform -- a single barrel, a +full row or column, a rectangle, or the whole head -- is a contiguous +rectangular block of barrels anchored at one of the head's four corners +(see :mod:`.head_mode`). PyLabRobot identifies the wells or tip spots a +caller wants to work with as a set of resources inside an +:class:`~pylabrobot.resources.itemized_resource.ItemizedResource` (a +:class:`~pylabrobot.resources.plate.Plate` or +:class:`~pylabrobot.resources.tip_rack.TipRack`), each carrying a string +identifier such as ``"A1"`` from +:meth:`~pylabrobot.resources.itemized_resource.ItemizedResource.get_child_identifier`. + +This module bridges the two: given the identifiers a caller selected, it +either returns the single rectangular :class:`HeadBlock` they describe, or +raises :class:`HeadBlockError` explaining why they cannot be one and what +selection would work instead. Whether that block's *shape* fits a +particular installed head -- and which of the head's four corners it +should be anchored at -- is a further decision this module deliberately +leaves to its caller (see :class:`HeadBlock.fits_within`), since it has no +notion of installed hardware at all. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, List, Tuple + +from pylabrobot.resources.utils import label_to_row_index, row_index_to_label, split_identifier + +_MAX_LISTED_IDENTIFIERS = 8 +"""Cap on how many identifiers an error message spells out by name, past +which it falls back to a count -- so a rejection on a 384-position +selection stays readable instead of dumping the whole list.""" + + +class HeadBlockError(ValueError): + """A set of items cannot be translated into a single Bravo head block.""" + + +@dataclass(frozen=True) +class HeadBlock: + """A contiguous rectangular block of head barrels. + + Row/column bounds are zero-based and half-open, in the same (row, col) + frame :func:`parse_item_identifier` produces: row 0 is an item grid's + first row ("A"), column 0 its first column ("1"). Always the output of + :func:`head_block_for_identifiers` -- never construct one directly. + + Attributes: + row_start: First active row. + row_stop: One past the last active row. + col_start: First active column. + col_stop: One past the last active column. + """ + + row_start: int + row_stop: int + col_start: int + col_stop: int + + @property + def num_rows(self) -> int: + """Number of active rows.""" + return self.row_stop - self.row_start + + @property + def num_columns(self) -> int: + """Number of active columns.""" + return self.col_stop - self.col_start + + @property + def num_barrels(self) -> int: + """Total number of active barrels.""" + return self.num_rows * self.num_columns + + def fits_within(self, rows: int, columns: int) -> bool: + """Return whether this block fits within a *rows* x *columns* head. + + Args: + rows: Number of barrel rows the head has. + columns: Number of barrel columns the head has. + + Returns: + True if this block's own row count and column count are each no + larger than the head's. + """ + return self.num_rows <= rows and self.num_columns <= columns + + +def parse_item_identifier(identifier: str) -> Tuple[int, int]: + """Parse an item identifier like ``"A1"`` into a zero-based (row, col) pair. + + Args: + identifier: A transposed Excel-style identifier, e.g. ``"A1"`` for the + first row/column, or ``"AF48"`` for row 32 of a 1536-position head. + + Returns: + The zero-based ``(row, col)`` position the identifier names. + + Raises: + HeadBlockError: If *identifier* is not a valid item identifier -- not + in ```` form, or with a row label longer than two + letters. + """ + try: + row_label, col_label = split_identifier(identifier) + return label_to_row_index(row_label), int(col_label) - 1 + except ValueError as exc: + raise HeadBlockError(f"'{identifier}' is not a valid item identifier: {exc}") from exc + + +def _identifier(row: int, col: int) -> str: + """Return the item identifier for a zero-based (row, col) position.""" + return f"{row_index_to_label(row)}{col + 1}" + + +def _format_identifier_list(cells: List[Tuple[int, int]]) -> str: + """Format (row, col) cells as a readable, length-capped identifier list.""" + identifiers = [_identifier(row, col) for row, col in cells] + if len(identifiers) <= _MAX_LISTED_IDENTIFIERS: + return ", ".join(identifiers) + shown = identifiers[:_MAX_LISTED_IDENTIFIERS] + remaining = len(identifiers) - _MAX_LISTED_IDENTIFIERS + return f"{', '.join(shown)}, and {remaining} more" + + +def head_block_for_identifiers(identifiers: Iterable[str]) -> HeadBlock: + """Return the :class:`HeadBlock` a set of item identifiers forms. + + Computes the bounding box of every identifier and checks that every + position inside that box was also selected -- the only way a set of grid + positions can be a contiguous rectangle. This single check catches an + L-shape, a diagonal pair, and a rectangle with a hole in it alike: each + leaves at least one bounding-box position unselected. + + Args: + identifiers: The item identifiers selected, e.g. ``["A1", "B1", "C1"]`` + for a 3-row column. Duplicates are ignored; order does not matter. + + Returns: + The block covering every given identifier. + + Raises: + HeadBlockError: If *identifiers* is empty, contains an invalid + identifier (see :func:`parse_item_identifier`), or does not describe + a contiguous rectangle. + """ + ids = list(identifiers) + if not ids: + raise HeadBlockError( + "No items were selected; the Bravo head needs at least one active barrel to operate." + ) + cells = {parse_item_identifier(identifier) for identifier in ids} + rows = [row for row, _ in cells] + cols = [col for _, col in cells] + row_start, row_stop = min(rows), max(rows) + 1 + col_start, col_stop = min(cols), max(cols) + 1 + expected = { + (row, col) for row in range(row_start, row_stop) for col in range(col_start, col_stop) + } + missing = sorted(expected - cells) + if missing: + bounding_box = f"{_identifier(row_start, col_start)}:{_identifier(row_stop - 1, col_stop - 1)}" + raise HeadBlockError( + "The selected items do not form a contiguous rectangular block: they span " + f"{bounding_box} ({len(expected)} positions) but only {len(cells)} were selected, " + f"missing {_format_identifier_list(missing)}. Select every item in {bounding_box}, " + "or a smaller selection that is itself a complete rectangle." + ) + return HeadBlock(row_start=row_start, row_stop=row_stop, col_start=col_start, col_stop=col_stop) diff --git a/pylabrobot/agilent/bravo/block_tests.py b/pylabrobot/agilent/bravo/block_tests.py new file mode 100644 index 00000000000..452481f1635 --- /dev/null +++ b/pylabrobot/agilent/bravo/block_tests.py @@ -0,0 +1,160 @@ +"""Unit tests for :mod:`.block`.""" + +from __future__ import annotations + +import unittest + +from .block import HeadBlock, HeadBlockError, head_block_for_identifiers, parse_item_identifier + + +class ParseItemIdentifierTests(unittest.TestCase): + """parse_item_identifier.""" + + def test_a1_is_the_origin(self): + self.assertEqual(parse_item_identifier("A1"), (0, 0)) + + def test_h12_is_the_last_cell_of_a_96_grid(self): + self.assertEqual(parse_item_identifier("H12"), (7, 11)) + + def test_two_letter_row_label_for_a_1536_head(self): + # A 32-row 1536 head reaches row label "AF" (zero-based row 31). + self.assertEqual(parse_item_identifier("AF48"), (31, 47)) + + def test_malformed_identifier_raises_head_block_error(self): + with self.assertRaises(HeadBlockError) as ctx: + parse_item_identifier("1A") + self.assertIn("'1A'", str(ctx.exception)) + + def test_three_letter_row_label_raises_head_block_error(self): + with self.assertRaises(HeadBlockError): + parse_item_identifier("AAA1") + + +class HeadBlockGeometryTests(unittest.TestCase): + """HeadBlock's derived properties and fits_within.""" + + def test_single_cell_block_dimensions(self): + block = HeadBlock(row_start=2, row_stop=3, col_start=4, col_stop=5) + self.assertEqual(block.num_rows, 1) + self.assertEqual(block.num_columns, 1) + self.assertEqual(block.num_barrels, 1) + + def test_rectangle_block_dimensions(self): + block = HeadBlock(row_start=0, row_stop=3, col_start=0, col_stop=4) + self.assertEqual(block.num_rows, 3) + self.assertEqual(block.num_columns, 4) + self.assertEqual(block.num_barrels, 12) + + def test_fits_within_a_head_at_least_as_large(self): + block = HeadBlock(row_start=0, row_stop=8, col_start=0, col_stop=12) + self.assertTrue(block.fits_within(8, 12)) + self.assertTrue(block.fits_within(16, 24)) + + def test_does_not_fit_a_head_with_too_few_rows(self): + block = HeadBlock(row_start=0, row_stop=9, col_start=0, col_stop=1) + self.assertFalse(block.fits_within(8, 12)) + + def test_does_not_fit_a_head_with_too_few_columns(self): + block = HeadBlock(row_start=0, row_stop=1, col_start=0, col_stop=13) + self.assertFalse(block.fits_within(8, 12)) + + def test_oversized_in_both_dimensions_does_not_fit(self): + block = HeadBlock(row_start=0, row_stop=16, col_start=0, col_stop=24) + self.assertFalse(block.fits_within(8, 12)) + + +class HeadBlockForIdentifiersTests(unittest.TestCase): + """head_block_for_identifiers: the shapes it accepts.""" + + def test_single_well(self): + block = head_block_for_identifiers(["C4"]) + self.assertEqual((block.row_start, block.row_stop), (2, 3)) + self.assertEqual((block.col_start, block.col_stop), (3, 4)) + + def test_full_column(self): + block = head_block_for_identifiers([f"{row}1" for row in "ABCDEFGH"]) + self.assertEqual((block.row_start, block.row_stop), (0, 8)) + self.assertEqual((block.col_start, block.col_stop), (0, 1)) + + def test_full_row(self): + block = head_block_for_identifiers([f"A{col}" for col in range(1, 13)]) + self.assertEqual((block.row_start, block.row_stop), (0, 1)) + self.assertEqual((block.col_start, block.col_stop), (0, 12)) + + def test_quadrant(self): + identifiers = [f"{row}{col}" for row in "ABCD" for col in range(1, 7)] + block = head_block_for_identifiers(identifiers) + self.assertEqual((block.row_start, block.row_stop), (0, 4)) + self.assertEqual((block.col_start, block.col_stop), (0, 6)) + self.assertEqual(block.num_barrels, 24) + + def test_offset_block_keeps_its_own_anchor(self): + # A 2x3 block starting away from the grid's own origin: the block's + # bounds should reflect exactly where it sits, not be shifted to A1. + identifiers = ["C4", "C5", "C6", "D4", "D5", "D6"] + block = head_block_for_identifiers(identifiers) + self.assertEqual((block.row_start, block.row_stop), (2, 4)) + self.assertEqual((block.col_start, block.col_stop), (3, 6)) + self.assertEqual((block.num_rows, block.num_columns), (2, 3)) + + def test_duplicate_identifiers_are_ignored(self): + block = head_block_for_identifiers(["A1", "A1", "B1"]) + self.assertEqual((block.row_start, block.row_stop), (0, 2)) + + def test_order_of_identifiers_does_not_matter(self): + forward = head_block_for_identifiers(["A1", "A2", "B1", "B2"]) + backward = head_block_for_identifiers(["B2", "B1", "A2", "A1"]) + self.assertEqual(forward, backward) + + +class HeadBlockForIdentifiersRejectionTests(unittest.TestCase): + """head_block_for_identifiers: rejections, asserted on message content.""" + + def test_empty_selection_is_rejected(self): + with self.assertRaises(HeadBlockError) as ctx: + head_block_for_identifiers([]) + self.assertIn("No items were selected", str(ctx.exception)) + + def test_l_shape_is_rejected_with_missing_cell_named(self): + # A1, A2, B1 form an L; B2 is the missing corner that would complete it. + with self.assertRaises(HeadBlockError) as ctx: + head_block_for_identifiers(["A1", "A2", "B1"]) + message = str(ctx.exception) + self.assertIn("do not form a contiguous rectangular block", message) + self.assertIn("A1:B2", message) + self.assertIn("B2", message) + + def test_diagonal_pair_is_rejected_with_both_gaps_named(self): + with self.assertRaises(HeadBlockError) as ctx: + head_block_for_identifiers(["A1", "B2"]) + message = str(ctx.exception) + self.assertIn("A1:B2", message) + self.assertIn("A2", message) + self.assertIn("B1", message) + + def test_rectangle_with_a_hole_is_rejected(self): + identifiers = [f"{row}{col}" for row in "ABC" for col in range(1, 4)] + identifiers.remove("B2") + with self.assertRaises(HeadBlockError) as ctx: + head_block_for_identifiers(identifiers) + message = str(ctx.exception) + self.assertIn("A1:C3", message) + self.assertIn("B2", message) + + def test_many_missing_cells_are_summarized_with_a_count(self): + # A single corner cell out of a 9x9 block: 80 cells missing, well past + # the point where the message should stop spelling every one out. + with self.assertRaises(HeadBlockError) as ctx: + head_block_for_identifiers(["A1", "I9"]) + message = str(ctx.exception) + self.assertIn("and", message) + self.assertIn("more", message) + + def test_invalid_identifier_in_the_set_is_rejected(self): + with self.assertRaises(HeadBlockError) as ctx: + head_block_for_identifiers(["A1", "not-an-id"]) + self.assertIn("not-an-id", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/agilent/bravo/bravo.py b/pylabrobot/agilent/bravo/bravo.py new file mode 100644 index 00000000000..30429e8f90e --- /dev/null +++ b/pylabrobot/agilent/bravo/bravo.py @@ -0,0 +1,1512 @@ +"""High-level Bravo device facade. + +Wraps a :class:`~.controllers.base.BravoController`, the state-machine +engine, deck state, taught positions, and head/tip selection state behind a +single async API. A caller constructs the controller (and, for real +hardware, the transport it uses) and injects both here; this module does no +controller-type dispatch and loads no configuration file of its own. + +Every step failure raises directly out of the awaited call: this facade +deliberately never calls :meth:`~.state_machine.engine.StateMachineEngine.set_error_handler`, +so a task here never pauses waiting for an operator's retry/ignore/abort +choice -- :class:`~.state_machine.engine.TaskStatus.ABORTED` is therefore +unreachable through this facade. The engine's abort/retry/ignore machinery +still exists and works exactly as documented on +:class:`~.state_machine.engine.StateMachineEngine`; a caller that wants +that interactive recovery loop registers its own error handler on +:attr:`Bravo.engine` (or drives the state-machine tasks directly) rather +than getting it from this facade. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional + +from .axis_config import DEFAULT_SPEEDS +from .config import BravoMachineConfig +from .controllers.base import AxisMoveInfo, BravoController, JogParams +from .deck.geometry import well_center_offset_from_teachpoint_mm, well_geometry_from_metadata +from .deck.labware import DeckState, Labware +from .deck.teachpoints import Teachpoints +from .head_mode import ( + HeadGeometry, + HeadMode, + PlateSelection, + TipSelection, + head_geometry_for_type, + head_mode_offsets_mm, + is_legal_plate_anchor, + is_legal_tipbox_anchor, + legal_plate_anchors, + legal_tipbox_anchors, + normalize_head_mode, + plate_selection, + selected_tip_wells, + tipbox_selection, +) +from .state_machine.engine import StateMachineEngine +from .state_machine.tasks import ( + AspirateTask, + DispenseTask, + HomeTask, + InitializeTask, + MixTask, + MoveToLocationTask, + PickPlaceTask, + TipsOffTask, + TipsOnTask, + _assert_neighbor_clearance, + _gripper_head_offsets, +) +from .tip_offsets import ResolvedTipOffsets, get_tip_offset_table +from .tips import get_default_tip_id_for_head, get_tip_id_for_capacity, get_tip_length_mm +from .transport._bridge import AsyncTransportBase +from .types import ALL_AXES, Axis, HeadType, SpeedLevel, safe_home_order + +if TYPE_CHECKING: + from .deck.resource import BravoDeck + +logger = logging.getLogger(__name__) + +_NEIGHBOR_CLEARANCE_SAFETY_MM = 2.0 +"""Millimetres of margin subtracted from a neighbor-clearance check's allowed +top plane, so a selection is rejected before it would just barely clip a +neighbor.""" + + +class _GripperPickPhase(PickPlaceTask): + """Runs only the pick-and-lift half of :class:`PickPlaceTask`. + + Constructed with the source location standing in for both endpoints, so + the pick geometry (:meth:`PickPlaceTask._calculate_positions`) is solved + purely from what is known at pick time: the source stack. The lift target + this reaches is therefore only guaranteed to clear the source stack, not + any particular destination -- :class:`_GripperPlacePhase` re-solves and, + if necessary, climbs further once the real destination is known. + """ + + def __init__( + self, + controller: BravoController, + teachpoints: Teachpoints, + config: BravoMachineConfig, + deck: DeckState, + location: int, + speed: SpeedLevel = "med", + ) -> None: + """Initialize the task. + + Args: + controller: The controller to operate. + teachpoints: The deck teachpoints to move against. + config: The machine configuration to move against. + deck: The deck state to pick from. + location: The deck location to pick up from. + speed: The speed profile for XYZ/Zg motion. + """ + PickPlaceTask.__init__( + self, + controller, + teachpoints, + config, + deck, + from_location=location, + to_location=location, + speed=speed, + ) + self.name = f"GripperPick_{location}" + + def get_steps(self) -> list[tuple[str, Callable[[], Awaitable[None]]]]: + """Return the pick-and-lift steps, stopping short of any lateral travel.""" + return [ + ("move_to_safe_pick_start", self._move_to_safe_pick_start), + ("move_gripper_to_nesting", self._move_gripper_to_nesting), + ("move_xy_to_pick", self._move_xy_to_pick), + ("move_to_pick_height", self._move_to_pick_height), + ("grip_plate", self._grip_plate), + ("move_to_carry_height", self._move_to_carry_height), + ] + + +class _GripperPlacePhase(PickPlaceTask): + """Runs only the lower-and-release half of :class:`PickPlaceTask`. + + Constructed with the real source and destination, so + :meth:`PickPlaceTask._calculate_positions` solves the full, obstacle- and + destination-aware carry and place geometry -- unlike + :class:`_GripperPickPhase`, which only knew the source. Its first step + re-runs the carry-height move with these corrected values: since the + corrected carry height is always greater than or equal to the + source-only value :class:`_GripperPickPhase` already reached (the + destination and obstacle terms in that ``max()`` can only add height, + never remove it), this only ever climbs further before it moves + laterally, never descends into something along the way. + + The grip itself already happened in a prior :class:`_GripperPickPhase` + run, so this is constructed with ``plate_already_gripped=True`` -- + :class:`PickPlaceTask`'s own supported way to seed the state a completed + grip would have left, rather than gripping again. + """ + + def __init__( + self, + controller: BravoController, + teachpoints: Teachpoints, + config: BravoMachineConfig, + deck: DeckState, + from_location: int, + to_location: int, + speed: SpeedLevel = "med", + ) -> None: + """Initialize the task. + + Args: + controller: The controller to operate. + teachpoints: The deck teachpoints to move against. + config: The machine configuration to move against. + deck: The deck state, updated on a successful place. + from_location: The deck location the resource was picked up from. + to_location: The deck location to place at. + speed: The speed profile for XYZ/Zg motion. + """ + PickPlaceTask.__init__( + self, + controller, + teachpoints, + config, + deck, + from_location=from_location, + to_location=to_location, + speed=speed, + plate_already_gripped=True, + ) + self.name = f"GripperPlace_{from_location}_{to_location}" + + def get_steps(self) -> list[tuple[str, Callable[[], Awaitable[None]]]]: + """Return the carry, place, and release steps.""" + return [ + ("move_to_carry_height", self._move_to_carry_height), + ("move_xy_to_place", self._move_xy_to_place), + ("move_to_place_height", self._move_to_place_height), + ("release_plate", self._release_plate), + ("return_gripper_to_nesting", self._return_gripper_to_nesting), + ] + + +class Bravo: + """High-level interface for the Agilent Bravo liquid handler. + + Owns the controller, the state-machine engine, deck state, taught + positions, and head/tip selection state. Every operation builds a task + and runs it through the engine, or for the small handful of single + commands with no failure-recovery surface, calls the controller directly. + """ + + def __init__( + self, + controller: BravoController, + transport: Optional[AsyncTransportBase] = None, + config: Optional[BravoMachineConfig] = None, + deck: Optional["BravoDeck"] = None, + ) -> None: + """Initialize the facade. + + Args: + controller: The controller to operate. Already constructed by the + caller; this class does no controller-type dispatch. + transport: The transport the controller communicates over, if any. + ``None`` for a controller that performs no I/O (such as + :class:`~.controllers.simulation.SimulationController`). + config: The machine configuration to operate against. Defaults to + :class:`BravoMachineConfig`'s own defaults when omitted. + deck: The deck model to source taught positions from. When given, + its :attr:`~.deck.resource.BravoDeck.teachpoints` is the single + source of truth both this facade and the deck model's site + origins agree on. Omitted (e.g. in a test against + :class:`~.controllers.simulation.SimulationController` with no PLR + deck wired up yet), a default set of teachpoints for the + configured head type is built instead. + """ + self._controller = controller + self._transport = transport + self._config = config if config is not None else BravoMachineConfig() + if deck is not None: + self._teachpoints = deck.teachpoints + else: + self._teachpoints = Teachpoints() + self._teachpoints.set_default_teachpoints(self._config.head.head_type) + self._engine = StateMachineEngine() + self._deck_state = DeckState() + self._homed_axes: set[Axis] = set() + self._head_mode = normalize_head_mode(self._config.head.head_type, "all_barrels", "back_left") + self._tips_on_head = False + self._tip_definition_id = "" + self._attached_tip_length_mm: Optional[float] = None + self._tips_on_head_mode: Optional[HeadMode] = None + self._tip_selection: Optional[TipSelection] = None + self._plate_selection: dict[int, PlateSelection] = {} + self._tipbox_occupancy: dict[int, set[tuple[int, int]]] = {} + self._tipbox_untracked: set[int] = set() + self._gripper_held_task: Optional[PickPlaceTask] = None + self._gripper_pick_location: Optional[int] = None + + @property + def controller(self) -> BravoController: + """The controller this facade operates.""" + return self._controller + + @property + def engine(self) -> StateMachineEngine: + """The state-machine engine this facade runs every task through. + + No error handler is registered on it by this facade (see the module + docstring): a caller that wants the engine's interactive abort/retry/ + ignore recovery loop instead of a raised exception registers its own + handler here with + :meth:`~.state_machine.engine.StateMachineEngine.set_error_handler`. + """ + return self._engine + + # -- Lifecycle -- + + async def setup(self) -> None: + """Bring the transport (if any) and controller online. + + Sets up the transport, then calls the controller's own + :meth:`~.controllers.base.BravoController.initialize`. Deliberately + low-level and non-interactive: it does not home any axis and cannot + raise waiting on an operator's answer to a prompt, so a caller such as + PyLabRobot's ``LiquidHandler.setup()`` can await it without risking a + hang. Call :meth:`home` to home the axes, or :meth:`initialize` for + the fuller cold-start sequence, once the connection is up. Also syncs + the software-tracked homed-axes cache from the controller once, so a + controller that starts pre-homed (like + :class:`~.controllers.simulation.SimulationController`) is reflected + immediately rather than only after the next :meth:`home` call. + """ + if self._transport is not None: + await self._transport.setup() + self._controller.initialize() + self._homed_axes = {axis for axis in ALL_AXES if self._controller.is_axis_homed(axis)} + + async def stop(self) -> None: + """Take the controller and transport (if any) offline. + + Calls the controller's :meth:`~.controllers.base.BravoController.deinitialize`, + then stops the transport. + """ + self._controller.deinitialize() + if self._transport is not None: + await self._transport.stop() + self._homed_axes.clear() + + async def initialize(self) -> None: + """Run the full cold-start sequence: detect the head/gripper and home. + + Runs :class:`~.state_machine.tasks.InitializeTask` -- ping, detect the + gripper and head, clear faults, and home every axis not already + homed, in the order that keeps the head and gripper clear of the deck. + Call :meth:`setup` first to bring the connection up. + + This is not part of :meth:`setup` because, with the default + configuration, it may need to ask an operator whether it is safe to + home the W axis (fluid may be in the tips). This facade registers no + engine error handler, so a step that would normally pause for that + answer raises a :class:`RuntimeError` carrying the prompt's message + instead -- it never blocks waiting for a response. The same applies + to the gripper-detection and plate-in-gripper confirmation steps. + Set ``config.safety.prompt_home_w = False`` to skip the W-axis + question entirely. + + Raises: + RuntimeError: If any step fails, including an operator-confirmation + step whose question was never answered. + """ + task = InitializeTask(self._controller, self._config) + await self._engine.execute(task) + self._homed_axes = {axis for axis in ALL_AXES if self._controller.is_axis_homed(axis)} + + # -- Homing -- + + async def home(self, axes: "Optional[list[Axis]]" = None, *, force: bool = False) -> "list[Axis]": + """Home the machine. + + Args: + axes: The axes to home. Defaults to X/Y/Z, plus W unless the + configuration ignores it, plus G/Zg when the controller has a + gripper and the configuration has axis entries for them. + force: Re-runs the routine on axes that already report themselves + homed. An explicit operator "home this axis" request should pass + ``True``: nothing moving because the axes "look" homed is never + the right answer for a deliberate request. + + Returns: + The axes homed, in the order they were homed. + """ + if axes is None: + axes = ["x", "y", "z"] + if not self._config.safety.ignore_w_axis: + axes.append("w") + if self._controller.has_gripper and "g" in self._config.axes and "zg" in self._config.axes: + axes.extend(["g", "zg"]) + ordered_axes = safe_home_order(axes) + task = HomeTask( + self._controller, + self._config, + ordered_axes, + safe_z_position=self._config.safety.z_safe_position, + force=force, + ) + await self._engine.execute(task) + self._homed_axes.update(ordered_axes) + return list(ordered_axes) + + async def home_single_axis(self, axis: Axis) -> None: + """Home one axis on explicit request, forcing the routine unconditionally. + + Bypasses the state machine: this is a direct request for one axis, not + a full cold-start sequence. W is parked back at 0 after homing, matching + what a fresh W homes to. + + Args: + axis: The axis to home. + """ + self._controller.home_axes([axis], force=True) + if axis == "w": + current = float(self._controller.get_position("w")) + if abs(current) > 0.07: + self._controller.move([AxisMoveInfo(axis="w", position=0.0)], wait=True) + self._homed_axes.add(axis) + + def is_axis_homed(self, axis: Axis) -> bool: + """Return whether *axis* is homed, from the software-tracked cache. + + Tracked in software rather than polled on every call: a wire read per + axis is not worth paying on every check. The cache is refreshed once at + :meth:`setup` and updated by every homing call; a power cycle behind + this facade's back would leave it stale until the next of those. + """ + return axis in self._homed_axes + + # -- Motion -- + + async def move_axis( + self, axis: Axis, position: float, velocity: float = 0.0, acceleration: float = 0.0 + ) -> None: + """Move one axis to an absolute position. + + Args: + axis: The axis to move. + position: The target position, in millimetres (or microlitres for W). + velocity: Move velocity. ``0`` leaves the controller's current setting. + acceleration: Move acceleration. ``0`` leaves the controller's current + setting. + """ + self._controller.move( + [AxisMoveInfo(axis=axis, position=position, velocity=velocity, acceleration=acceleration)] + ) + + async def jog_axis( + self, + axis: Axis, + step: float, + speed: SpeedLevel = "med", + peak_current: Optional[float] = None, + ) -> float: + """Jog one axis by a relative step and return its new position. + + Args: + axis: The axis to jog. + step: The relative distance to move, in millimetres (or microlitres + for W). + speed: The speed profile to jog at. + peak_current: If given, jogs with a current limit instead of a fixed + distance, stopping when the motor current exceeds the limit -- + used to verify current feedback before a tips-on press. + + Returns: + The axis's position after the jog. + """ + velocity, acceleration = self._speed_profile(axis, speed) + if peak_current is not None: + current_pos = self._controller.get_position(axis) + target = current_pos + step + try: + return self._controller.jog( + JogParams( + axis=axis, + velocity=velocity, + acceleration=acceleration, + max_position=target, + tolerance=2.0, + peak_current=peak_current, + ) + ) + except Exception as exc: + logger.warning("Force-limited jog on %s stopped short: %s", axis, exc) + return self._controller.get_position(axis) + move = AxisMoveInfo( + axis=axis, position=step, velocity=velocity, acceleration=acceleration, absolute=False + ) + self._controller.move([move]) + return self._controller.get_position(axis) + + def _speed_profile(self, axis: Axis, level: SpeedLevel) -> tuple[float, float]: + """Return the (velocity, acceleration) pair for *axis* at *level*.""" + cfg = self._config.axes.get(axis) + if cfg is not None and level in cfg.speeds: + profile = cfg.speeds[level] + return profile.velocity, profile.acceleration + fallback = DEFAULT_SPEEDS.get(axis, {}).get(level) + if fallback is None: + return 0.0, 0.0 + return fallback.velocity, fallback.acceleration + + async def move_to_location( + self, + location: int, + approach_height: float = 0.0, + only_move_z: bool = False, + speed: SpeedLevel = "med", + ) -> None: + """Move the head to a deck location using its taught position. + + Args: + location: The deck location to move to. + approach_height: Millimetres to stop above the taught Z before the + final lowering move. ``0`` lowers straight to the taught position. + only_move_z: Move only Z, to the safe position, skipping the lateral + move and the final lowering. + speed: The speed profile for the move. + """ + lateral_axes: "tuple[Axis, Axis, Axis]" = ("x", "y", "z") + task = MoveToLocationTask( + self._controller, + self._teachpoints, + location, + safe_z_position=self._config.safety.z_safe_position, + approach_height=approach_height, + only_move_z=only_move_z, + speed_profiles={axis: self._speed_profile(axis, speed) for axis in lateral_axes}, + ) + await self._engine.execute(task) + + async def move_to_safe_z(self, speed: SpeedLevel = "med") -> None: + """Retract Z to the configured safe position. + + Args: + speed: The speed profile for the move. + """ + velocity, acceleration = self._speed_profile("z", speed) + self._controller.move( + [ + AxisMoveInfo( + axis="z", + position=self._config.safety.z_safe_position, + velocity=velocity, + acceleration=acceleration, + ) + ], + wait=True, + ) + + def get_position(self, axis: Axis) -> float: + """Return the current position of *axis*, in engineering units.""" + return self._controller.get_position(axis) + + def get_all_positions(self) -> dict[str, float]: + """Return the current position of every axis, keyed by axis name.""" + return {axis: self._controller.get_position(axis) for axis in ALL_AXES} + + def enable_motor(self, axis: Axis) -> None: + """Enable the motor drive for *axis*.""" + self._controller.enable_motor(axis) + + def disable_motor(self, axis: Axis) -> None: + """Disable the motor drive for *axis*.""" + self._controller.disable_motor(axis) + + # -- Liquid handling -- + + def _assert_well_access(self, location: int) -> None: + """Raise if the labware at *location* cannot currently accept a well access.""" + labware = self._deck_state.get_stack(location).top + if labware is None: + return + if labware.is_lidded: + raise RuntimeError( + f"Cannot access wells at location {location}: plate '{labware.name}' has a lid on it." + ) + if labware.is_sealed: + raise RuntimeError( + f"Cannot access wells at location {location}: plate '{labware.name}' is sealed." + ) + + async def aspirate( + self, + location: int, + volume: float, + pre_aspirate: float = 0.0, + post_aspirate: float = 0.0, + distance_from_bottom: float = 1.0, + dynamic_tip_extension: float = 0.0, + tip_touch: bool = False, + liquid_class: "Optional[dict[str, Any]]" = None, + pipette_technique: "Optional[dict[str, Any]]" = None, + ) -> None: + """Aspirate a volume at a deck location. + + Args: + location: The deck location to aspirate at. + volume: The volume to aspirate, in microlitres. + pre_aspirate: An air-gap volume drawn before descending into liquid, + in microlitres. + post_aspirate: An air-gap volume drawn after retracting, in + microlitres. + distance_from_bottom: Clearance above the well bottom, in + millimetres. + dynamic_tip_extension: Millimetres the head lowers per corrected + microlitre aspirated. ``0`` disables it. + tip_touch: Whether to touch the tip against the well wall after + aspirating. + liquid_class: Pre-resolved per-operation motion parameters, if any. + pipette_technique: Pre-resolved swirl-technique parameters, if any. + """ + self._assert_well_access(location) + labware = self._deck_state.get_stack(location).top + task = AspirateTask( + self._controller, + self._teachpoints, + location, + volume, + pre_aspirate_volume=pre_aspirate, + post_aspirate_volume=post_aspirate, + distance_from_bottom=distance_from_bottom, + safe_z_position=self._config.safety.z_safe_position, + labware=labware, + head_type=self._config.head.head_type, + head_mode=self._head_mode, + plate_selection=self._effective_plate_selection(location, labware, self._head_mode), + dynamic_tip_extension=dynamic_tip_extension, + tip_touch=tip_touch, + liquid_class=liquid_class, + pipette_technique=pipette_technique, + deck=self._deck_state, + teach_tip_length_mm=self._config.head.teach_tip_length_mm, + attached_tip_length_mm=self._attached_tip_length_mm, + tips_on_head=self._tips_on_head, + ) + await self._engine.execute(task) + + async def dispense( + self, + location: int, + volume: float, + blowout: float = 0.0, + distance_from_bottom: float = 1.0, + empty_tips: bool = False, + dynamic_tip_retraction: float = 0.0, + tip_touch: bool = False, + liquid_class: "Optional[dict[str, Any]]" = None, + pipette_technique: "Optional[dict[str, Any]]" = None, + ) -> None: + """Dispense a volume at a deck location. + + Args: + location: The deck location to dispense at. + volume: The volume to dispense, in microlitres. + blowout: An extra volume dispensed beyond ``volume`` to clear the + tip, in microlitres. + distance_from_bottom: Clearance above the well bottom, in + millimetres. + empty_tips: Dispense the tip's entire remaining contents instead of a + fixed volume. + dynamic_tip_retraction: Millimetres the head raises per corrected + microlitre dispensed. ``0`` disables it. + tip_touch: Whether to touch the tip against the well wall after + dispensing. + liquid_class: Pre-resolved per-operation motion parameters, if any. + pipette_technique: Pre-resolved swirl-technique parameters, if any. + """ + self._assert_well_access(location) + labware = self._deck_state.get_stack(location).top + task = DispenseTask( + self._controller, + self._teachpoints, + location, + volume=volume, + blowout_volume=blowout, + distance_from_bottom=distance_from_bottom, + safe_z_position=self._config.safety.z_safe_position, + labware=labware, + head_type=self._config.head.head_type, + head_mode=self._head_mode, + plate_selection=self._effective_plate_selection(location, labware, self._head_mode), + empty_tips=empty_tips, + dynamic_tip_retraction=dynamic_tip_retraction, + tip_touch=tip_touch, + liquid_class=liquid_class, + pipette_technique=pipette_technique, + deck=self._deck_state, + teach_tip_length_mm=self._config.head.teach_tip_length_mm, + attached_tip_length_mm=self._attached_tip_length_mm, + tips_on_head=self._tips_on_head, + ) + await self._engine.execute(task) + + async def mix( + self, + location: int, + volume: float, + pre_aspirate: float = 0.0, + blowout: float = 0.0, + mix_cycles: int = 3, + aspirate_distance: float = 1.0, + dispense_at_different_distance: bool = False, + dispense_distance: float = 1.0, + dynamic_tip_extension: float = 0.0, + tip_touch: bool = False, + liquid_class: "Optional[dict[str, Any]]" = None, + pipette_technique: "Optional[dict[str, Any]]" = None, + ) -> None: + """Mix liquid at a deck location using repeated aspirate/dispense strokes. + + Args: + location: The deck location to mix at. + volume: The volume drawn and expelled on each stroke, in microlitres. + pre_aspirate: An extra air-gap volume drawn on every aspirate stroke, + in microlitres. + blowout: An extra volume dispensed beyond ``volume`` on every + dispense stroke, in microlitres. + mix_cycles: Number of aspirate/dispense strokes to perform. + aspirate_distance: Clearance above the well bottom for the aspirate + stroke, in millimetres. + dispense_at_different_distance: Move to ``dispense_distance`` before + each dispense stroke instead of staying at ``aspirate_distance``. + dispense_distance: Clearance above the well bottom for the dispense + stroke, in millimetres, when ``dispense_at_different_distance``. + dynamic_tip_extension: Millimetres the head lowers per corrected + microlitre aspirated on each stroke. ``0`` disables it. + tip_touch: Whether to touch the tip against the well wall after the + final stroke. + liquid_class: Pre-resolved per-operation motion parameters, if any. + pipette_technique: Pre-resolved swirl-technique parameters, if any. + """ + self._assert_well_access(location) + labware = self._deck_state.get_stack(location).top + task = MixTask( + self._controller, + self._teachpoints, + location, + volume=volume, + pre_aspirate_volume=pre_aspirate, + blowout_volume=blowout, + mix_cycles=mix_cycles, + aspirate_distance=aspirate_distance, + dispense_distance=dispense_distance, + dispense_at_different_distance=dispense_at_different_distance, + safe_z_position=self._config.safety.z_safe_position, + labware=labware, + head_type=self._config.head.head_type, + head_mode=self._head_mode, + plate_selection=self._effective_plate_selection(location, labware, self._head_mode), + dynamic_tip_extension=dynamic_tip_extension, + tip_touch=tip_touch, + liquid_class=liquid_class, + pipette_technique=pipette_technique, + deck=self._deck_state, + teach_tip_length_mm=self._config.head.teach_tip_length_mm, + attached_tip_length_mm=self._attached_tip_length_mm, + tips_on_head=self._tips_on_head, + ) + await self._engine.execute(task) + + # -- Tips -- + + @staticmethod + def _labware_base_class(labware: Labware) -> str: + """Return a labware's normalized ``base_class`` metadata value.""" + return str((labware.metadata or {}).get("base_class") or "").strip().lower() + + @staticmethod + def _labware_kind(labware: Labware) -> str: + """Return a labware's normalized ``kind`` metadata value.""" + return str((labware.metadata or {}).get("kind") or "").strip().lower() + + def _labware_at_location(self, location: int) -> Labware: + """Return the labware at *location*, raising if none is assigned.""" + labware = self._deck_state.get_stack(location).top + if labware is None: + raise RuntimeError(f"No labware assigned to location {location}") + return labware + + def _require_tip_box(self, location: int, *, operation: str) -> Labware: + """Return the tip box labware at *location*, raising if it is something else.""" + labware = self._labware_at_location(location) + if self._labware_base_class(labware) != "tip_box" and self._labware_kind(labware) != "tip_box": + raise RuntimeError(f"{operation} requires a tip box at location {location}") + return labware + + def _require_well_labware(self, location: int, *, operation: str) -> Labware: + """Return the well-based labware at *location*, raising if it is a tip box/trash.""" + labware = self._labware_at_location(location) + base_class = self._labware_base_class(labware) + kind = self._labware_kind(labware) + if base_class in {"tip_box", "tip_trash"} or kind in {"tip_box", "tip_trash"}: + raise RuntimeError(f"{operation} requires plate-style labware at location {location}") + geometry = well_geometry_from_metadata(labware.metadata) + if geometry.rows <= 0 or geometry.cols <= 0: + raise RuntimeError(f"{operation} requires well-based labware at location {location}") + return labware + + def _require_tip_receptacle(self, location: int, *, operation: str) -> Labware: + """Return the tip box or tip trash labware at *location*.""" + labware = self._labware_at_location(location) + base_class = self._labware_base_class(labware) + kind = self._labware_kind(labware) + if base_class not in {"tip_box", "tip_trash"} and kind not in {"tip_box", "tip_trash"}: + raise RuntimeError(f"{operation} requires a tip box or tip trash at location {location}") + return labware + + def _tip_id_for_labware(self, labware: Labware) -> str: + """Return the tip catalogue id a tip box's tips resolve to.""" + metadata = labware.metadata or {} + tip_id = str(metadata.get("tip_definition_id") or "").strip() + if tip_id: + return tip_id + capacity = metadata.get("disposable_tip_capacity_ul") + inferred = get_tip_id_for_capacity(self._config.head.head_type, capacity) + if inferred: + return inferred + return get_default_tip_id_for_head(self._config.head.head_type) or "" + + def _tip_length_for_labware(self, labware: Labware) -> float: + """Return the measured length of the tips in a tip box, in millimetres.""" + tip_id = self._tip_id_for_labware(labware) + length = get_tip_length_mm(self._config.head.head_type, tip_id) + if length is None: + length = get_tip_length_mm( + self._config.head.head_type, + self._config.head.default_tip_id or self._config.head.default_tip_capacity, + ) + if length is None: + raise RuntimeError(f"Tip length is not configured for {self._config.head.head_type}") + return float(length) + + def _resolve_tip_offsets(self, labware: Labware) -> ResolvedTipOffsets: + """Resolve per-(head, tip box) Tips On/Off offsets for *labware*.""" + safety = self._config.safety + return get_tip_offset_table().resolve( + self._config.head.head_type, + tipbox_name=labware.name, + tipbox_id=labware.definition_id or labware.id, + default_z_offset=float(safety.tips_off_z_offset), + default_w_position=float(safety.tips_off_w_position), + ) + + @staticmethod + def _tipbox_dimensions(labware: Labware) -> tuple[int, int]: + """Return the (rows, cols) of a tip box, from metadata or well count.""" + metadata = labware.metadata or {} + rows = int(metadata.get("rows") or 0) + cols = int(metadata.get("cols") or 0) + if rows > 0 and cols > 0: + return rows, cols + wells = int(metadata.get("wells") or 0) + if wells == 96: + return 8, 12 + if wells == 384: + return 16, 24 + if wells == 1536: + return 32, 48 + return rows, cols + + def _initialize_tipbox_occupancy( + self, location: int, labware: Labware, *, fill_state: str = "full" + ) -> None: + """(Re)populate the occupancy set for a tip box at *location*.""" + if self._labware_base_class(labware) != "tip_box" and self._labware_kind(labware) != "tip_box": + self._tipbox_occupancy.pop(location, None) + return + rows, cols = self._tipbox_dimensions(labware) + if rows <= 0 or cols <= 0: + self._tipbox_occupancy.pop(location, None) + return + normalized = str(fill_state or "full").strip().lower() + if normalized == "preserve" and location in self._tipbox_occupancy: + return + if normalized == "empty": + self._tipbox_occupancy[location] = set() + return + self._tipbox_occupancy[location] = {(row, col) for row in range(rows) for col in range(cols)} + + def _ensure_tipbox_occupancy(self, location: int, labware: Labware) -> None: + """Populate the occupancy set for *location* if it has none yet.""" + if location not in self._tipbox_occupancy: + self._initialize_tipbox_occupancy(location, labware) + + def _occupied_tip_wells(self, location: int) -> set[tuple[int, int]]: + """Return the currently tip-occupied cells at *location*.""" + return set(self._tipbox_occupancy.get(location, set())) + + def _legal_tip_anchors( + self, location: int, labware: Labware, head_mode: HeadMode, *, purpose: str + ) -> list[dict[str, int | str]]: + """Return every legal tip anchor at *location* for pickup or return.""" + rows, cols = self._tipbox_dimensions(labware) + if rows <= 0 or cols <= 0: + return [] + if location in self._tipbox_untracked: + occupied: set[tuple[int, int]] = ( + {(r, c) for r in range(rows) for c in range(cols)} if purpose == "pickup" else set() + ) + else: + self._ensure_tipbox_occupancy(location, labware) + occupied = self._occupied_tip_wells(location) + anchors = legal_tipbox_anchors(rows, cols, head_mode, occupied, purpose=purpose) + return [ + anchor.to_dict() + for anchor in anchors + if self._is_tip_anchor_reachable(location, labware, head_mode, anchor.row, anchor.col) + ] + + def _axis_xy_range(self) -> tuple[tuple[float, float], tuple[float, float]]: + """Return the ((x_min, x_max), (y_min, y_max)) travel range.""" + x_cfg = self._config.axes.get("x") + y_cfg = self._config.axes.get("y") + if x_cfg is None or y_cfg is None: + raise RuntimeError("Missing X/Y axis configuration") + return ( + (float(x_cfg.range.min_pos), float(x_cfg.range.max_pos)), + (float(y_cfg.range.min_pos), float(y_cfg.range.max_pos)), + ) + + def _tip_xy_target( + self, location: int, labware: Labware, head_mode: HeadMode, anchor_row: int, anchor_col: int + ) -> tuple[float, float]: + """Return the (x, y) target for a tip box anchor cell.""" + teach_x = self._teachpoints.get_teachpoint(location, "x") + teach_y = self._teachpoints.get_teachpoint(location, "y") + head_offset_x, head_offset_y = head_mode_offsets_mm(self._config.head.head_type, head_mode) + selection = tipbox_selection(location, anchor_row, anchor_col, head_mode) + offset_x, offset_y = well_center_offset_from_teachpoint_mm( + labware.metadata, row=selection.row, col=selection.col + ) + return teach_x + offset_x - head_offset_x, teach_y + offset_y - head_offset_y + + def _is_tip_anchor_reachable( + self, location: int, labware: Labware, head_mode: HeadMode, anchor_row: int, anchor_col: int + ) -> bool: + """Return whether a tip box anchor's XY target is within axis travel.""" + try: + target_x, target_y = self._tip_xy_target(location, labware, head_mode, anchor_row, anchor_col) + (x_min, x_max), (y_min, y_max) = self._axis_xy_range() + except Exception: + return False + epsilon = 1e-6 + return (x_min - epsilon) <= target_x <= (x_max + epsilon) and (y_min - epsilon) <= target_y <= ( + y_max + epsilon + ) + + def _validated_tip_wells( + self, labware: Labware, head_mode: HeadMode, selection: TipSelection, *, purpose: str = "pickup" + ) -> list[tuple[int, int]]: + """Return the tip box cells *selection* covers, raising if it is illegal.""" + rows, cols = self._tipbox_dimensions(labware) + if rows <= 0 or cols <= 0: + raise RuntimeError("Tip box metadata is missing rows/cols") + wells = selected_tip_wells(rows, cols, selection) + if not wells: + raise RuntimeError("No tips are selected for the current head mode") + if self._labware_base_class(labware) == "tip_box" or self._labware_kind(labware) == "tip_box": + if selection.location not in self._tipbox_untracked: + self._ensure_tipbox_occupancy(selection.location, labware) + occupied = self._occupied_tip_wells(selection.location) + if not is_legal_tipbox_anchor( + rows, cols, head_mode, occupied, selection.row, selection.col, purpose=purpose + ): + raise RuntimeError( + f"Tip selection ({selection.row}, {selection.col}) is not accessible for {purpose}" + ) + return wells + + def _effective_tip_selection( + self, location: int, labware: Labware, head_mode: HeadMode, *, purpose: str = "pickup" + ) -> TipSelection: + """Return the tip selection to use at *location*: the operator's choice if + still legal, otherwise the first legal anchor.""" + if self._tip_selection is not None and self._tip_selection.location == location: + try: + self._validated_tip_wells(labware, head_mode, self._tip_selection, purpose=purpose) + return self._tip_selection + except RuntimeError: + pass + rows, cols = self._tipbox_dimensions(labware) + if rows <= 0 or cols <= 0: + raise RuntimeError("Tip box metadata is missing rows/cols") + anchors = self._legal_tip_anchors(location, labware, head_mode, purpose=purpose) + if not anchors: + raise RuntimeError(f"No legal tip anchors are available for {purpose} at location {location}") + selection = tipbox_selection( + location, int(anchors[0]["row"]), int(anchors[0]["col"]), head_mode + ) + self._validated_tip_wells(labware, head_mode, selection, purpose=purpose) + self._tip_selection = selection + return selection + + def _selection_from_clicked_tip( + self, + location: int, + labware: Labware, + head_mode: HeadMode, + clicked_row: int, + clicked_col: int, + *, + purpose: str, + ) -> TipSelection: + """Return the tip selection covering a specific clicked cell, if legal.""" + anchors = self._legal_tip_anchors(location, labware, head_mode, purpose=purpose) + for anchor in anchors: + row_start, col_start = int(anchor["row"]), int(anchor["col"]) + row_count, col_count = int(anchor["row_count"]), int(anchor["column_count"]) + if ( + row_start <= clicked_row < row_start + row_count + and col_start <= clicked_col < col_start + col_count + ): + selection = tipbox_selection(location, row_start, col_start, head_mode) + self._validated_tip_wells(labware, head_mode, selection, purpose=purpose) + return selection + selection = tipbox_selection(location, clicked_row, clicked_col, head_mode) + self._validated_tip_wells(labware, head_mode, selection, purpose=purpose) + return selection + + def _apply_tipbox_selection( + self, location: int, head_mode: HeadMode, selection: TipSelection, *, purpose: str + ) -> None: + """Update tip occupancy at *location* after a pickup or return.""" + labware = self._require_tip_box(location, operation="Tip inventory update") + wells = self._validated_tip_wells(labware, head_mode, selection, purpose=purpose) + if location in self._tipbox_untracked: + return + occupied = self._occupied_tip_wells(location) + if purpose == "pickup": + occupied.difference_update(wells) + elif purpose == "return": + box_tip_id = self._tip_id_for_labware(labware) + if self._tip_definition_id and box_tip_id and box_tip_id != self._tip_definition_id: + raise RuntimeError( + f"Tip return requires matching tip definitions " + f"({self._tip_definition_id} on head, {box_tip_id} in box)" + ) + occupied.update(wells) + else: + raise ValueError(f"Unknown tip inventory purpose: {purpose}") + self._tipbox_occupancy[location] = occupied + + def _set_tip_state( + self, + *, + tip_definition_id: str, + tip_length_mm: float, + head_mode: HeadMode, + tip_selection: TipSelection, + ) -> None: + """Record that tips are now on the head.""" + self._tips_on_head = True + self._tip_definition_id = tip_definition_id + self._attached_tip_length_mm = float(tip_length_mm) + self._tips_on_head_mode = head_mode + self._tip_selection = tip_selection + + def _clear_tip_state(self) -> None: + """Record that tips are no longer on the head.""" + self._tips_on_head = False + self._tip_definition_id = "" + self._attached_tip_length_mm = None + self._tips_on_head_mode = None + + async def tips_on(self, location: int) -> None: + """Pick up tips from a tip box. + + Args: + location: The deck location of the tip box. + """ + if self._tips_on_head: + raise RuntimeError("Tips are already on the head") + labware = self._require_tip_box(location, operation="Tips on") + tip_selection = self._effective_tip_selection(location, labware, self._head_mode) + tip_length_mm = self._tip_length_for_labware(labware) + tip_offsets = self._resolve_tip_offsets(labware) + task = TipsOnTask( + self._controller, + self._teachpoints, + self._config, + labware, + self._head_mode, + tip_selection, + location, + tip_length_mm, + safe_z_position=self._config.safety.z_safe_position, + deck=self._deck_state, + tip_offsets=tip_offsets, + ) + await self._engine.execute(task) + self._apply_tipbox_selection(location, self._head_mode, tip_selection, purpose="pickup") + self._set_tip_state( + tip_definition_id=self._tip_id_for_labware(labware), + tip_length_mm=tip_length_mm, + head_mode=self._head_mode, + tip_selection=tip_selection, + ) + + async def tips_off(self, location: int) -> None: + """Eject tips at a tip box or tip trash location. + + Args: + location: The deck location to eject at. + """ + tips_are_tracked = self._tips_on_head + tip_length = self._attached_tip_length_mm or 9.0 + labware = self._require_tip_receptacle(location, operation="Tips off") + target_selection: Optional[TipSelection] = None + effective_mode = self._tips_on_head_mode or self._head_mode + if self._labware_base_class(labware) == "tip_box" or self._labware_kind(labware) == "tip_box": + target_selection = self._effective_tip_selection( + location, labware, effective_mode, purpose="return" + ) + tip_offsets = self._resolve_tip_offsets(labware) + task = TipsOffTask( + self._controller, + self._teachpoints, + self._config, + labware, + effective_mode, + target_selection, + location, + attached_tip_length_mm=tip_length, + safe_z_position=self._config.safety.z_safe_position, + deck=self._deck_state, + tips_are_tracked=tips_are_tracked, + tip_offsets=tip_offsets, + ) + await self._engine.execute(task) + if target_selection is not None: + self._apply_tipbox_selection(location, effective_mode, target_selection, purpose="return") + self._clear_tip_state() + + def set_tip_selection(self, location: int, row: int, col: int) -> TipSelection: + """Manually select a tip box anchor cell. + + Args: + location: The deck location of the tip box. + row: Zero-based row of the clicked cell. + col: Zero-based column of the clicked cell. + + Returns: + The resolved selection covering the clicked cell. + """ + labware = self._require_tip_box(location, operation="Tip selection") + rows, cols = self._tipbox_dimensions(labware) + if rows <= 0 or cols <= 0: + raise RuntimeError(f"Tip box at location {location} has no row/column metadata") + if row < 0 or row >= rows or col < 0 or col >= cols: + raise RuntimeError( + f"Tip selection ({row}, {col}) is outside the tip box at location {location}" + ) + purpose = "return" if self._tips_on_head else "pickup" + active_mode = ( + (self._tips_on_head_mode or self._head_mode) if self._tips_on_head else self._head_mode + ) + selection = self._selection_from_clicked_tip( + location, labware, active_mode, row, col, purpose=purpose + ) + self._tip_selection = selection + return selection + + # -- Plate selection -- + + def _plate_xy_target( + self, location: int, labware: Labware, head_mode: HeadMode, selection: PlateSelection + ) -> tuple[float, float]: + """Return the (x, y) target for a plate anchor well.""" + teach_x = self._teachpoints.get_teachpoint(location, "x") + teach_y = self._teachpoints.get_teachpoint(location, "y") + offset_x, offset_y = well_center_offset_from_teachpoint_mm( + labware.metadata, row=selection.row, col=selection.col + ) + head_offset_x, head_offset_y = head_mode_offsets_mm(self._config.head.head_type, head_mode) + return teach_x + offset_x - head_offset_x, teach_y + offset_y - head_offset_y + + def _is_plate_anchor_reachable( + self, location: int, labware: Labware, head_mode: HeadMode, row: int, col: int + ) -> bool: + """Return whether a plate anchor's XY target is within axis travel.""" + try: + selection = plate_selection(location, row, col) + target_x, target_y = self._plate_xy_target(location, labware, head_mode, selection) + (x_min, x_max), (y_min, y_max) = self._axis_xy_range() + except Exception: + return False + epsilon = 1e-6 + return (x_min - epsilon) <= target_x <= (x_max + epsilon) and (y_min - epsilon) <= target_y <= ( + y_max + epsilon + ) + + def _is_legal_plate_anchor( + self, labware: Labware, head_mode: HeadMode, row: int, col: int + ) -> bool: + """Return whether the head mode's footprint fits the plate anchored at a cell.""" + geometry = well_geometry_from_metadata(labware.metadata) + if geometry.rows <= 0 or geometry.cols <= 0: + return False + return is_legal_plate_anchor( + self._config.head.head_type, + head_mode, + geometry.rows, + geometry.cols, + geometry.pitch_x_mm, + geometry.pitch_y_mm, + row, + col, + ) + + def _assert_plate_anchor_clearance( + self, + location: int, + labware: Labware, + head_mode: HeadMode, + row: int, + col: int, + *, + command_name: str = "Plate selection", + ) -> None: + """Raise if a plate anchor's footprint would clip a taller neighbor.""" + selection = plate_selection(location, row, col) + target_x, target_y = self._plate_xy_target(location, labware, head_mode, selection) + target_height = self._deck_state.get_height(location) + tip_length = self._attached_tip_length_mm or 0.0 + allowed_top_plane = target_height + tip_length - _NEIGHBOR_CLEARANCE_SAFETY_MM + _assert_neighbor_clearance( + command_name=command_name, + teachpoints=self._teachpoints, + deck=self._deck_state, + head_type=self._config.head.head_type, + head_mode=head_mode, + target_location=location, + target_x=target_x, + target_y=target_y, + allowed_top_plane_mm=allowed_top_plane, + gripper_present=self._controller.has_gripper, + ) + + def _is_plate_anchor_selectable( + self, location: int, labware: Labware, head_mode: HeadMode, row: int, col: int + ) -> bool: + """Return whether a plate anchor is reachable, legal, and clear of neighbors.""" + if not self._is_plate_anchor_reachable(location, labware, head_mode, row, col): + return False + if not self._is_legal_plate_anchor(labware, head_mode, row, col): + return False + try: + self._assert_plate_anchor_clearance(location, labware, head_mode, row, col) + except RuntimeError: + return False + return True + + def _candidate_plate_anchors( + self, location: int, labware: Labware, head_mode: HeadMode + ) -> "list[dict[str, int]]": + """Return every reachable plate anchor cell, regardless of clearance.""" + geometry = well_geometry_from_metadata(labware.metadata) + anchors = legal_plate_anchors( + self._config.head.head_type, + head_mode, + geometry.rows, + geometry.cols, + geometry.pitch_x_mm, + geometry.pitch_y_mm, + ) + return [ + {"row": anchor.row, "col": anchor.col} + for anchor in anchors + if self._is_plate_anchor_reachable(location, labware, head_mode, anchor.row, anchor.col) + ] + + def _legal_plate_anchors( + self, location: int, labware: Labware, head_mode: HeadMode + ) -> "list[dict[str, int]]": + """Return every plate anchor that is reachable, legal, and clear of neighbors.""" + return [ + anchor + for anchor in self._candidate_plate_anchors(location, labware, head_mode) + if self._is_plate_anchor_selectable( + location, labware, head_mode, anchor["row"], anchor["col"] + ) + ] + + def _effective_plate_selection( + self, location: int, labware: Optional[Labware], head_mode: HeadMode + ) -> Optional[PlateSelection]: + """Return the plate selection to use at *location*, or ``None`` for a tip box/no labware.""" + if labware is None: + return None + if self._labware_base_class(labware) in {"tip_box", "tip_trash"} or self._labware_kind( + labware + ) in {"tip_box", "tip_trash"}: + return None + geometry = well_geometry_from_metadata(labware.metadata) + if geometry.rows <= 0 or geometry.cols <= 0: + return None + current = self._plate_selection.get(location) + if current is not None: + try: + if self._is_plate_anchor_selectable(location, labware, head_mode, current.row, current.col): + return current + except Exception: + pass + candidates = self._candidate_plate_anchors(location, labware, head_mode) + legal = [ + anchor + for anchor in candidates + if self._is_plate_anchor_selectable( + location, labware, head_mode, anchor["row"], anchor["col"] + ) + ] + if not legal: + if candidates: + first = candidates[0] + self._assert_plate_anchor_clearance( + location, labware, head_mode, first["row"], first["col"] + ) + raise RuntimeError( + f"No legal plate anchors are available at location {location} for the current head mode" + ) + selection = plate_selection(location, legal[0]["row"], legal[0]["col"]) + self._plate_selection[location] = selection + return selection + + def set_plate_selection(self, location: int, row: int, col: int) -> PlateSelection: + """Manually select a plate anchor well. + + Args: + location: The deck location of the plate. + row: Zero-based row of the anchor well. + col: Zero-based column of the anchor well. + + Returns: + The selection. + """ + labware = self._require_well_labware(location, operation="Plate selection") + geometry = well_geometry_from_metadata(labware.metadata) + if row < 0 or row >= geometry.rows or col < 0 or col >= geometry.cols: + raise RuntimeError( + f"Plate selection ({row}, {col}) is outside the labware at location {location}" + ) + if not self._is_plate_anchor_reachable(location, labware, self._head_mode, row, col): + raise RuntimeError(f"Plate selection ({row}, {col}) is not reachable at location {location}") + if not self._is_legal_plate_anchor(labware, self._head_mode, row, col): + raise RuntimeError(f"Plate selection ({row}, {col}) is not legal for the current head mode") + self._assert_plate_anchor_clearance(location, labware, self._head_mode, row, col) + selection = plate_selection(location, row, col) + self._plate_selection[location] = selection + return selection + + # -- Gripper -- + + async def gripper_pick(self, location: int, speed: SpeedLevel = "med") -> None: + """Pick up the labware at *location* with the gripper. + + Lifts to a height that clears the source stack. The lift does not yet + account for the eventual destination -- :meth:`gripper_place` climbs + further first if the destination needs more clearance. + + Args: + location: The deck location to pick up from. + speed: The speed profile for XYZ/Zg motion. + + Raises: + RuntimeError: If the gripper is already holding a resource, or no + labware is assigned at *location*. + """ + if self._gripper_held_task is not None: + raise RuntimeError("The gripper is already holding a resource") + task = _GripperPickPhase( + self._controller, self._teachpoints, self._config, self._deck_state, location, speed=speed + ) + await self._engine.execute(task) + self._gripper_held_task = task + self._gripper_pick_location = location + + async def gripper_move(self, location: int) -> None: + """Translate the held resource, at its current height, to hover over *location*. + + Args: + location: The deck location to hover over. + + Raises: + RuntimeError: If the gripper is not currently holding a resource. + """ + task = self._gripper_held_task + if task is None: + raise RuntimeError("The gripper is not holding a resource") + _, head_y_offset = _gripper_head_offsets(self._config.head.head_type) + x = self._teachpoints.get_teachpoint(location, "x") + y = ( + self._teachpoints.get_teachpoint(location, "y") + + self._config.gripper.y_offset + + head_y_offset + ) + self._controller.move( + [AxisMoveInfo(axis="x", position=x), AxisMoveInfo(axis="y", position=y)], wait=True + ) + + async def gripper_place(self, location: int, speed: SpeedLevel = "med") -> None: + """Place the held resource at *location* and release the gripper. + + Args: + location: The deck location to place at. + speed: The speed profile for XYZ/Zg motion. + + Raises: + RuntimeError: If the gripper is not currently holding a resource. + """ + if self._gripper_held_task is None or self._gripper_pick_location is None: + raise RuntimeError("The gripper is not holding a resource") + task = _GripperPlacePhase( + self._controller, + self._teachpoints, + self._config, + self._deck_state, + self._gripper_pick_location, + location, + speed=speed, + ) + await self._engine.execute(task) + self._gripper_held_task = None + self._gripper_pick_location = None + + # -- Deck -- + + def set_labware( + self, + location: int, + labware: Labware, + *, + tipbox_fill_state: str = "full", + track_tips: bool = True, + ) -> None: + """Assign *labware* to a deck location, replacing whatever was there. + + This facade has no built-in catalogue: *labware* must already be a + fully-built :class:`~.deck.labware.Labware`. A caller bridging from + PyLabRobot resources gets one from + :meth:`~.deck.resource.BravoDeck.labware_for_site` (or the underlying + :func:`~.deck.resource.labware_from_resource`), which translates + whatever :class:`~pylabrobot.resources.resource.Resource` is assigned + to a :class:`~.deck.resource.BravoDeck` site into this type. + + Args: + location: The deck location to assign to. + labware: The labware to place there. + tipbox_fill_state: For a tip box, its initial occupancy: ``"full"``, + ``"empty"``, or ``"preserve"`` (keep whatever occupancy the + location already tracked). + track_tips: Whether tip occupancy at this location should be tracked + at all. ``False`` treats every cell as always available for pickup + and always available for return, skipping legality checks. + """ + self._deck_state.set_single(location, labware) + self._plate_selection.pop(location, None) + self._initialize_tipbox_occupancy(location, labware, fill_state=tipbox_fill_state) + if track_tips: + self._tipbox_untracked.discard(location) + else: + self._tipbox_untracked.add(location) + + def clear_labware(self, location: int) -> None: + """Remove whatever labware is assigned to a deck location. + + Args: + location: The deck location to clear. + """ + self._deck_state.clear(location) + self._plate_selection.pop(location, None) + self._tipbox_occupancy.pop(location, None) + + def get_labware(self, location: int) -> Optional[Labware]: + """Return the labware currently assigned to a deck location, if any.""" + return self._deck_state.get_stack(location).top + + # -- Head mode -- + + @property + def head_mode(self) -> HeadMode: + """The active head mode/subset.""" + return self._head_mode + + def set_head_mode( + self, + subset_type: Optional[str], + subset_config: Optional[str], + row_count: Optional[int] = None, + column_count: Optional[int] = None, + ) -> HeadMode: + """Set the active head mode/subset. + + Args: + subset_type: The requested subset kind, e.g. ``"row"``, ``"column"``, + ``"rectangle"``, ``"single_barrel"``, or ``"all_barrels"``. + subset_config: The requested anchor corner. + row_count: Requested active row count, for ``"row"``/``"rectangle"``. + column_count: Requested active column count, for + ``"column"``/``"rectangle"``. + + Returns: + The normalized head mode now active. + """ + self._head_mode = normalize_head_mode( + self._config.head.head_type, subset_type, subset_config, row_count, column_count + ) + return self._head_mode + + # -- Head/instrument identity -- + + @property + def head_type(self) -> HeadType: + """The configured head type this facade operates against. + + This is the value every task built by this facade is given as its own + ``head_type`` argument (see :meth:`aspirate`, :meth:`tips_on`, and + friends), so it is the authoritative "installed head" for any caller + that needs to reason about geometry before an operation reaches the + engine -- for example, rejecting a request the installed head cannot + physically perform rather than letting :func:`~.head_mode.normalize_head_mode` + silently clamp it to a smaller shape. ``"unknown"`` means no head has + been identified yet; :attr:`head_geometry` and :attr:`head_capacity` + still return the 96-channel default for it, so a caller that needs to + guarantee a real head is installed should check for ``"unknown"`` + explicitly rather than trust those. + """ + return self._config.head.head_type + + @property + def head_geometry(self) -> HeadGeometry: + """The physical barrel grid of the installed head.""" + return head_geometry_for_type(self._config.head.head_type) + + @property + def head_capacity(self) -> int: + """The total number of physical channels on the installed head.""" + geometry = self.head_geometry + return geometry.rows * geometry.columns + + @property + def has_gripper(self) -> bool: + """Whether the controller's hardware has a gripper accessory.""" + return self._controller.has_gripper + + @property + def model_name(self) -> str: + """The controller's human-readable model name.""" + return self._controller.model_name diff --git a/pylabrobot/agilent/bravo/bravo_tests.py b/pylabrobot/agilent/bravo/bravo_tests.py new file mode 100644 index 00000000000..9a8c9bf3b40 --- /dev/null +++ b/pylabrobot/agilent/bravo/bravo_tests.py @@ -0,0 +1,618 @@ +"""Unit tests for :mod:`.bravo`. + +Drives :class:`Bravo` against :class:`~.controllers.simulation.SimulationController` +(via the golden-frame package's recording wrapper, reused here rather than +building a second recorder) and asserts both the resulting controller-call +sequence and values that never reach a controller call directly, per the +two failure modes a golden-frame comparison alone would miss. + +:class:`Bravo` builds a :class:`~.state_machine.engine.StateMachineEngine` +at construction time, which allocates an ``asyncio.Lock``; every test class +here is an :class:`unittest.IsolatedAsyncioTestCase` so that allocation +always happens against a live event loop, matching the golden-frame test +modules' own convention. +""" + +from __future__ import annotations + +import asyncio +import unittest + +from .bravo import Bravo +from .config import BravoMachineConfig +from .controllers.base import AxisMoveInfo +from .deck.labware import Labware +from .head_mode import normalize_head_mode +from .state_machine.engine import ErrorAction +from .state_machine.golden_frame_support import ( + RecordingSimulationController, + new_config, + new_controller, + new_teachpoints, +) +from .transport._bridge import AsyncTransportBase + + +def _plate(name: str = "test_plate") -> Labware: + """Build a simple 96-well plate labware fixture.""" + return Labware( + id=name, + definition_id=name, + name=name, + height=14.0, + width=85.5, + length=127.5, + wells=96, + metadata={ + "kind": "plate", + "base_class": "plate", + "rows": 8, + "cols": 12, + "spacing_x_mm": 9.0, + "spacing_y_mm": 9.0, + }, + ) + + +def _tip_box(name: str = "test_tipbox") -> Labware: + """Build a simple 96-position tip box labware fixture.""" + return Labware( + id=name, + definition_id=name, + name=name, + height=60.0, + width=85.5, + length=127.5, + wells=96, + metadata={ + "kind": "tip_box", + "base_class": "tip_box", + "rows": 8, + "cols": 12, + "spacing_x_mm": 9.0, + "spacing_y_mm": 9.0, + "tip_definition_id": "st_30ul", + "disposable_tip_capacity_ul": 30.0, + }, + ) + + +class _RecordingTransport(AsyncTransportBase): + """A transport that records when setup/stop run, for ordering tests.""" + + def __init__(self, order: "list[str]") -> None: + super().__init__("fake", "test") + self._order = order + + async def _open_io(self) -> None: + self._order.append("transport.setup") + + async def _close_io(self) -> None: + self._order.append("transport.stop") + + def send(self, data: bytes) -> None: + raise NotImplementedError + + def receive(self, timeout: float = 2.0) -> bytes: + raise NotImplementedError + + def receive_exact(self, num_bytes: int, timeout: float = 2.0) -> bytes: + raise NotImplementedError + + +def _new_bravo(*, gripper: bool = True) -> "tuple[Bravo, RecordingSimulationController]": + """Build a Bravo facade against a fresh recording simulation controller.""" + ctrl = RecordingSimulationController() + config = new_config(gripper=gripper) + config.head.teach_tip_length_mm = 26.1 + bravo = Bravo(ctrl, config=config, deck=None) + # Bravo() builds its own default teachpoints for the configured head type, + # which matches new_teachpoints()'s "96_d_70" default -- asserted directly + # rather than assumed, since the two are built independently. + assert bravo._teachpoints.as_dict() == new_teachpoints().as_dict() + return bravo, ctrl + + +class SetupStopOrderingTests(unittest.IsolatedAsyncioTestCase): + """setup() must bring the transport up before the controller; stop() the reverse.""" + + async def test_setup_calls_transport_before_controller(self): + order: "list[str]" = [] + ctrl = RecordingSimulationController() + original_initialize = ctrl.initialize + + def recording_initialize(): + order.append("controller.initialize") + return original_initialize() + + ctrl.initialize = recording_initialize # type: ignore[method-assign] + transport = _RecordingTransport(order) + bravo = Bravo(ctrl, transport=transport, config=BravoMachineConfig()) + await bravo.setup() + self.assertEqual(order, ["transport.setup", "controller.initialize"]) + + async def test_stop_calls_controller_before_transport(self): + order: "list[str]" = [] + ctrl = RecordingSimulationController() + original_deinitialize = ctrl.deinitialize + + def recording_deinitialize(): + order.append("controller.deinitialize") + return original_deinitialize() + + ctrl.deinitialize = recording_deinitialize # type: ignore[method-assign] + transport = _RecordingTransport(order) + bravo = Bravo(ctrl, transport=transport, config=BravoMachineConfig()) + await bravo.stop() + self.assertEqual(order, ["controller.deinitialize", "transport.stop"]) + + async def test_setup_with_no_transport_only_initializes_the_controller(self): + ctrl = RecordingSimulationController() + bravo = Bravo(ctrl, config=BravoMachineConfig()) + await bravo.setup() # must not raise despite transport=None + + async def test_setup_syncs_the_homed_axes_cache_from_the_controller(self): + bravo, ctrl = _new_bravo() + self.assertFalse(bravo.is_axis_homed("x")) + await bravo.setup() + # SimulationController starts every axis homed at its offset. + self.assertTrue(bravo.is_axis_homed("x")) + self.assertTrue(bravo.is_axis_homed("g")) + + async def test_stop_clears_the_homed_axes_cache(self): + bravo, ctrl = _new_bravo() + await bravo.setup() + await bravo.stop() + self.assertFalse(bravo.is_axis_homed("x")) + + +class HeadModeDefaultTests(unittest.IsolatedAsyncioTestCase): + """The facade's head mode starts at the normalized all-barrels default.""" + + async def test_default_head_mode_is_all_barrels_back_left(self): + bravo, _ = _new_bravo() + expected = normalize_head_mode("96_d_70", "all_barrels", "back_left") + self.assertEqual(bravo.head_mode, expected) + self.assertEqual(bravo.head_mode.subset_type, "all_barrels") + self.assertEqual(bravo.head_mode.subset_config, "back_left") + self.assertEqual(bravo.head_mode.row_count, 8) + self.assertEqual(bravo.head_mode.column_count, 12) + + async def test_constructor_passes_all_barrels_back_left_literally(self): + """normalize_head_mode collapses subset_config to "back_left" for + "all_barrels" regardless of what was passed in, so the resulting + HeadMode alone can't tell "back_left" apart from some other literal + the constructor might have passed instead -- this pins the literal + arguments themselves, via the module attribute bravo.py calls through. + """ + import pylabrobot.agilent.bravo.bravo as bravo_module + + calls: list = [] + original = bravo_module.normalize_head_mode + + def recording(head_type, subset_type, subset_config, *args, **kwargs): + calls.append((subset_type, subset_config)) + return original(head_type, subset_type, subset_config, *args, **kwargs) + + bravo_module.normalize_head_mode = recording + try: + _new_bravo() + finally: + bravo_module.normalize_head_mode = original + self.assertEqual(calls[0], ("all_barrels", "back_left")) + + async def test_set_head_mode_updates_and_returns_the_new_mode(self): + bravo, _ = _new_bravo() + mode = bravo.set_head_mode("row", "back_left", row_count=1) + self.assertEqual(bravo.head_mode, mode) + self.assertEqual(mode.subset_type, "row") + self.assertEqual(mode.row_count, 1) + + +class HeadIdentityTests(unittest.IsolatedAsyncioTestCase): + """head_type/head_geometry/head_capacity/has_gripper/model_name never reach a controller call.""" + + async def test_head_type_reflects_the_configured_head(self): + ctrl = RecordingSimulationController() + config = new_config() + config.head.head_type = "384_d_70" + bravo = Bravo(ctrl, config=config, deck=None) + self.assertEqual(bravo.head_type, "384_d_70") + + async def test_head_geometry_for_96_d_70(self): + bravo, _ = _new_bravo() + geometry = bravo.head_geometry + self.assertEqual((geometry.rows, geometry.columns), (8, 12)) + + async def test_head_capacity_is_rows_times_columns(self): + bravo, _ = _new_bravo() + self.assertEqual(bravo.head_capacity, 96) + + async def test_has_gripper_reflects_the_controller(self): + bravo, _ = _new_bravo(gripper=True) + self.assertTrue(bravo.has_gripper) + + async def test_model_name_reflects_the_controller(self): + bravo, ctrl = _new_bravo() + self.assertEqual(bravo.model_name, ctrl.model_name) + + +class EngineErrorHandlerEscapeHatchTests(unittest.IsolatedAsyncioTestCase): + """No error handler is registered by default (see the module docstring), + but the engine itself is reachable via bravo.engine for a caller that + wants the interactive abort/retry/ignore loop instead. + """ + + async def test_no_handler_by_default_a_step_failure_raises(self): + ctrl = new_controller(all_homed=False) + config = new_config(gripper=True) + config.head.teach_tip_length_mm = 26.1 + bravo = Bravo(ctrl, config=config) + with self.assertRaises(RuntimeError): + await bravo.initialize() + + async def test_a_caller_registered_handler_can_intercept_the_same_failure(self): + ctrl = new_controller(all_homed=False) + config = new_config(gripper=True) + config.head.teach_tip_length_mm = 26.1 + bravo = Bravo(ctrl, config=config) + errors: list = [] + bravo.engine.set_error_handler(errors.append) + + async def auto_ignore(): + while True: + if bravo.engine.awaiting_error_action: + bravo.engine.resolve_error(ErrorAction.IGNORE) + return + await asyncio.sleep(0) + + task = asyncio.ensure_future(bravo.initialize()) + await auto_ignore() + await task # must not raise: the registered handler intercepted it + self.assertEqual(len(errors), 1) + + +class InitializeTests(unittest.IsolatedAsyncioTestCase): + """initialize() runs the full InitializeTask cold-start sequence.""" + + async def test_initialize_homes_every_axis_when_the_w_prompt_is_disabled(self): + ctrl = new_controller(all_homed=False) + config = new_config(gripper=True) + config.head.teach_tip_length_mm = 26.1 + config.safety.prompt_home_w = False + bravo = Bravo(ctrl, config=config) + await bravo.initialize() + for axis in ("x", "y", "z", "w", "g", "zg"): + self.assertTrue(bravo.is_axis_homed(axis)) + + async def test_initialize_raises_instead_of_blocking_on_the_w_axis_prompt(self): + """With prompt_home_w at its default (True) and no engine error handler + registered, the W-axis confirmation step must raise a RuntimeError + carrying the prompt's own message rather than hang waiting for an + operator response that will never come. + """ + ctrl = new_controller(all_homed=False) + config = new_config(gripper=True) + config.head.teach_tip_length_mm = 26.1 + self.assertTrue(config.safety.prompt_home_w) + bravo = Bravo(ctrl, config=config) + with self.assertRaises(RuntimeError) as ctx: + await bravo.initialize() + self.assertIn("W-axis", str(ctx.exception)) + # The exception propagated before home_w ran: W is still unhomed. + self.assertFalse(bravo.is_axis_homed("w")) + + +class HomingTests(unittest.IsolatedAsyncioTestCase): + """home() reaches the engine and produces the expected controller calls.""" + + async def test_home_with_default_axes_homes_xyzwg_zg_in_safe_order(self): + bravo, ctrl = _new_bravo() + homed = await bravo.home() + self.assertEqual(homed, ["z", "zg", "g", "x", "y", "w"]) + home_calls = [c for c in ctrl.calls if c["method"] == "home_axes"] + self.assertEqual(len(home_calls), 1) + self.assertEqual(home_calls[0]["args"]["axes"], ["z", "zg", "g", "x", "y", "w"]) + for axis in homed: + self.assertTrue(bravo.is_axis_homed(axis)) + + async def test_home_without_gripper_axes_when_controller_has_no_gripper(self): + bravo, ctrl = _new_bravo(gripper=False) + homed = await bravo.home() + self.assertNotIn("g", homed) + self.assertNotIn("zg", homed) + + async def test_home_single_axis_forces_and_marks_homed(self): + bravo, ctrl = _new_bravo() + await bravo.home_single_axis("x") + home_calls = [c for c in ctrl.calls if c["method"] == "home_axes"] + self.assertEqual(home_calls[-1]["args"], {"axes": ["x"], "force": True}) + self.assertTrue(bravo.is_axis_homed("x")) + + async def test_home_single_axis_w_parks_at_zero(self): + bravo, ctrl = _new_bravo() + ctrl.move([AxisMoveInfo(axis="w", position=25.0)]) + await bravo.home_single_axis("w") + self.assertAlmostEqual(ctrl.get_position("w"), 0.0, places=3) + + async def test_is_axis_homed_defaults_false_before_any_home(self): + bravo, _ = _new_bravo() + self.assertFalse(bravo.is_axis_homed("x")) + + +class MotionTests(unittest.IsolatedAsyncioTestCase): + """move_axis/jog_axis/move_to_location/move_to_safe_z/get_position(s).""" + + async def test_move_axis_reaches_the_controller(self): + bravo, ctrl = _new_bravo() + await bravo.setup() + await bravo.move_axis("x", 50.0) + self.assertAlmostEqual(bravo.get_position("x"), 50.0, places=3) + + async def test_jog_axis_moves_relative_and_returns_new_position(self): + bravo, ctrl = _new_bravo() + await bravo.setup() + await bravo.move_axis("x", 50.0) + new_pos = await bravo.jog_axis("x", 5.0) + self.assertAlmostEqual(new_pos, 55.0, places=3) + + async def test_move_to_location_reaches_the_engine_and_moves_xyz(self): + bravo, ctrl = _new_bravo() + await bravo.setup() + await bravo.move_to_location(1) + moved_axes = { + m["axis"] for c in ctrl.calls if c["method"] == "move" for m in c["args"]["moves"] + } + self.assertIn("x", moved_axes) + self.assertIn("y", moved_axes) + self.assertIn("z", moved_axes) + self.assertAlmostEqual( + bravo.get_position("x"), bravo._teachpoints.get_teachpoint(1, "x"), places=3 + ) + + async def test_move_to_safe_z_moves_z_to_the_configured_safe_position(self): + bravo, ctrl = _new_bravo() + await bravo.setup() + bravo._config.safety.z_safe_position = 5.0 + await bravo.move_to_safe_z() + self.assertAlmostEqual(bravo.get_position("z"), 5.0, places=3) + + async def test_get_all_positions_covers_every_axis(self): + bravo, ctrl = _new_bravo() + await bravo.setup() + positions = bravo.get_all_positions() + self.assertEqual(set(positions.keys()), {"x", "y", "z", "w", "g", "zg"}) + + async def test_enable_disable_motor_reach_the_controller(self): + bravo, ctrl = _new_bravo() + bravo.enable_motor("x") + bravo.disable_motor("x") + methods = [c["method"] for c in ctrl.calls] + self.assertIn("enable_motor", methods) + self.assertIn("disable_motor", methods) + + +class DeckLabwareTests(unittest.IsolatedAsyncioTestCase): + """set_labware/clear_labware/get_labware.""" + + async def test_set_labware_then_get_labware_round_trips(self): + bravo, _ = _new_bravo() + plate = _plate() + bravo.set_labware(1, plate) + self.assertIs(bravo.get_labware(1), plate) + + async def test_clear_labware_empties_the_location(self): + bravo, _ = _new_bravo() + bravo.set_labware(1, _plate()) + bravo.clear_labware(1) + self.assertIsNone(bravo.get_labware(1)) + + async def test_get_labware_at_empty_location_is_none(self): + bravo, _ = _new_bravo() + self.assertIsNone(bravo.get_labware(2)) + + +class LiquidHandlingReachesTheEngineTests(unittest.IsolatedAsyncioTestCase): + """aspirate/dispense/mix build a task and run it through the engine.""" + + async def _bravo_with_plate(self): + bravo, ctrl = _new_bravo() + await bravo.setup() + bravo.set_labware(3, _plate()) + # 96_d_70 is a disposable-tip head: liquid handling requires tips on + # the head, so pick a set up before the test's own assertions. + bravo.set_labware(4, _tip_box()) + await bravo.tips_on(4) + ctrl.calls.clear() + return bravo, ctrl + + async def test_aspirate_moves_to_the_target_location(self): + bravo, ctrl = await self._bravo_with_plate() + await bravo.aspirate(3, 50.0) + moved_axes = { + m["axis"] for c in ctrl.calls if c["method"] == "move" for m in c["args"]["moves"] + } + self.assertIn("z", moved_axes) + self.assertIn("w", moved_axes) + + async def test_aspirate_distance_from_bottom_reaches_the_task(self): + """distance_from_bottom is a caller-supplied argument threaded through + to AspirateTask; pinned directly by checking two different values + produce two different Z targets, since a hardcoded pass-through would + still move Z (caught above) but wouldn't vary with the argument. + """ + + async def z_targets_for(distance_from_bottom): + bravo, ctrl = await self._bravo_with_plate() + await bravo.aspirate(3, 50.0, distance_from_bottom=distance_from_bottom) + return tuple( + m["position"] + for c in ctrl.calls + if c["method"] == "move" + for m in c["args"]["moves"] + if m["axis"] == "z" + ) + + near = await z_targets_for(1.0) + far = await z_targets_for(8.0) + self.assertNotEqual(near, far) + + async def test_dispense_moves_to_the_target_location(self): + bravo, ctrl = await self._bravo_with_plate() + await bravo.aspirate(3, 50.0) + ctrl.calls.clear() + await bravo.dispense(3, 50.0) + moved_axes = { + m["axis"] for c in ctrl.calls if c["method"] == "move" for m in c["args"]["moves"] + } + self.assertIn("w", moved_axes) + + async def test_mix_performs_the_configured_number_of_cycles(self): + bravo, ctrl = await self._bravo_with_plate() + await bravo.mix(3, 20.0, mix_cycles=2) + w_moves = [ + m for c in ctrl.calls if c["method"] == "move" for m in c["args"]["moves"] if m["axis"] == "w" + ] + # Each cycle aspirates then dispenses at the well, so at least 2 W moves per cycle. + self.assertGreaterEqual(len(w_moves), 4) + + async def test_aspirate_on_a_lidded_plate_raises_before_touching_the_controller(self): + bravo, ctrl = await self._bravo_with_plate() + lidded = bravo.get_labware(3) + lidded.is_lidded = True + with self.assertRaises(RuntimeError): + await bravo.aspirate(3, 50.0) + self.assertEqual(ctrl.calls, []) + + +class TipsTests(unittest.IsolatedAsyncioTestCase): + """tips_on/tips_off reach the engine and update tip state.""" + + async def _bravo_with_tipbox(self): + bravo, ctrl = _new_bravo() + await bravo.setup() + bravo.set_labware(4, _tip_box()) + return bravo, ctrl + + async def test_tips_on_marks_tips_on_head_and_moves(self): + bravo, ctrl = await self._bravo_with_tipbox() + await bravo.tips_on(4) + self.assertTrue(bravo._tips_on_head) + self.assertIsNotNone(bravo._attached_tip_length_mm) + moved_axes = { + m["axis"] for c in ctrl.calls if c["method"] == "move" for m in c["args"]["moves"] + } + self.assertIn("z", moved_axes) + + async def test_tips_on_twice_raises(self): + bravo, _ = await self._bravo_with_tipbox() + await bravo.tips_on(4) + with self.assertRaises(RuntimeError): + await bravo.tips_on(4) + + async def test_tips_off_clears_tip_state(self): + bravo, ctrl = await self._bravo_with_tipbox() + await bravo.tips_on(4) + await bravo.tips_off(4) + self.assertFalse(bravo._tips_on_head) + self.assertIsNone(bravo._attached_tip_length_mm) + + async def test_tips_on_consumes_tipbox_occupancy(self): + bravo, _ = await self._bravo_with_tipbox() + await bravo.tips_on(4) + self.assertEqual(bravo._occupied_tip_wells(4), set()) + + async def test_set_tip_selection_rejects_out_of_range_cell(self): + bravo, _ = await self._bravo_with_tipbox() + with self.assertRaises(RuntimeError): + bravo.set_tip_selection(4, 99, 0) + + +class PlateSelectionTests(unittest.IsolatedAsyncioTestCase): + """set_plate_selection.""" + + async def test_set_plate_selection_within_range_succeeds(self): + bravo, _ = _new_bravo() + bravo.set_labware(3, _plate()) + selection = bravo.set_plate_selection(3, 0, 0) + self.assertEqual((selection.row, selection.col), (0, 0)) + + async def test_set_plate_selection_out_of_range_raises(self): + bravo, _ = _new_bravo() + bravo.set_labware(3, _plate()) + with self.assertRaises(RuntimeError): + bravo.set_plate_selection(3, 99, 0) + + +class GripperTests(unittest.IsolatedAsyncioTestCase): + """gripper_pick/gripper_move/gripper_place hold state across three calls.""" + + async def _bravo_with_source_plate(self): + bravo, ctrl = _new_bravo(gripper=True) + await bravo.setup() + bravo.set_labware(1, _plate("source_plate")) + return bravo, ctrl + + async def test_full_cycle_moves_the_labware_between_locations(self): + bravo, ctrl = await self._bravo_with_source_plate() + await bravo.gripper_pick(1) + self.assertIsNotNone(bravo._gripper_held_task) + await bravo.gripper_move(2) + await bravo.gripper_place(2) + self.assertIsNone(bravo._gripper_held_task) + self.assertIsNone(bravo._gripper_pick_location) + self.assertIsNone(bravo.get_labware(1)) + self.assertIsNotNone(bravo.get_labware(2)) + self.assertEqual(bravo.get_labware(2).name, "source_plate") + + async def test_pick_grips_and_place_releases(self): + bravo, ctrl = await self._bravo_with_source_plate() + await bravo.gripper_pick(1) + self.assertIn("grip", [c["method"] for c in ctrl.calls]) + ctrl.calls.clear() + await bravo.gripper_place(2) + self.assertIn("open_gripper", [c["method"] for c in ctrl.calls]) + + async def test_pick_while_already_holding_raises(self): + bravo, _ = await self._bravo_with_source_plate() + await bravo.gripper_pick(1) + with self.assertRaises(RuntimeError): + await bravo.gripper_pick(1) + + async def test_move_without_holding_raises(self): + bravo, _ = await self._bravo_with_source_plate() + with self.assertRaises(RuntimeError): + await bravo.gripper_move(2) + + async def test_place_without_holding_raises(self): + bravo, _ = await self._bravo_with_source_plate() + with self.assertRaises(RuntimeError): + await bravo.gripper_place(2) + + async def test_pick_from_empty_location_raises(self): + bravo, _ = await self._bravo_with_source_plate() + with self.assertRaises(RuntimeError): + await bravo.gripper_pick(5) + + async def test_gripper_move_target_uses_the_documented_offset_formula(self): + """gripper_move's XY target never reaches a golden-frame comparison by + itself (it's one leg of a coordinated move) -- pinned directly against + the formula the docstring names: teachpoint Y + gripper.y_offset + + the head's Y offset constant. + """ + bravo, ctrl = await self._bravo_with_source_plate() + bravo._config.gripper.y_offset = 3.5 + await bravo.gripper_pick(1) + ctrl.calls.clear() + await bravo.gripper_move(2) + move_call = next(c for c in ctrl.calls if c["method"] == "move") + x_move = next(m for m in move_call["args"]["moves"] if m["axis"] == "x") + y_move = next(m for m in move_call["args"]["moves"] if m["axis"] == "y") + expected_x = bravo._teachpoints.get_teachpoint(2, "x") + expected_y = bravo._teachpoints.get_teachpoint(2, "y") + 3.5 + 0.0 # 96_d_70 head offset is 0 + self.assertAlmostEqual(x_move["position"], expected_x, places=6) + self.assertAlmostEqual(y_move["position"], expected_y, places=6) + + +if __name__ == "__main__": + unittest.main() From b968148ecf1307259cb4958d91b81e5d9344d72e Mon Sep 17 00:00:00 2001 From: kelsorj Date: Fri, 21 Aug 2026 10:59:59 -0700 Subject: [PATCH 9/9] Document the Agilent Bravo Adds the API reference entry, a hello-world guide covering connection, homing, the rectangular-block rule, tip handling, liquid handling, and plate movement, and the device registry entry. The guide runs end to end against the simulation controller with no hardware attached. --- docs/_static/devices.json | 16 + docs/api/pylabrobot.agilent.rst | 15 + .../agilent/bravo/hello-world.ipynb | 396 ++++++++++++++++++ docs/user_guide/agilent/index.md | 1 + 4 files changed, 428 insertions(+) create mode 100644 docs/user_guide/agilent/bravo/hello-world.ipynb diff --git a/docs/_static/devices.json b/docs/_static/devices.json index 56cb4cba7c6..063336e9cdb 100644 --- a/docs/_static/devices.json +++ b/docs/_static/devices.json @@ -15,6 +15,22 @@ "manager": "https://discuss.pylabrobot.org/u/rickwierenga", "notes": "BenchCel 4R four-stacker configuration; protocol verified with firmware 3.2.20.0." }, + { + "id": "agilent-bravo", + "vendor": "Agilent", + "name": "Bravo", + "kind": "liquid handler", + "capabilities": [ + "liquid handling", + "arm" + ], + "status": "wip", + "api": "pylabrobot.agilent.AgilentBravoBackend", + "api_version": "v1", + "code_slug": "agilent/bravo", + "doc_slug": "agilent/bravo/hello-world", + "notes": "Four hardware generations (Darwin, Agile 7612, Bravo SRT, legacy Agile) plus a simulation controller; the protocol and controller layers are exercised against real instruments, but the PyLabRobot transport, deck mapping, and backend are not." + }, { "id": "agilent-vspin", "vendor": "Agilent", diff --git a/docs/api/pylabrobot.agilent.rst b/docs/api/pylabrobot.agilent.rst index 4f03a36203b..c19cac4fbfa 100644 --- a/docs/api/pylabrobot.agilent.rst +++ b/docs/api/pylabrobot.agilent.rst @@ -86,6 +86,21 @@ BioTek Synergy H1 SynergyH1 +Bravo +----- + +.. currentmodule:: pylabrobot.agilent.bravo + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Bravo + AgilentBravoBackend + BravoDeck + + VSpin ----- diff --git a/docs/user_guide/agilent/bravo/hello-world.ipynb b/docs/user_guide/agilent/bravo/hello-world.ipynb new file mode 100644 index 00000000000..2e60f0c98cc --- /dev/null +++ b/docs/user_guide/agilent/bravo/hello-world.ipynb @@ -0,0 +1,396 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "bravo-intro", + "metadata": {}, + "source": [ + "# Agilent Bravo quickstart\n", + "\n", + "The Agilent Bravo is a fixed-head liquid handler: an interchangeable pipetting head moves over a fixed 3x3 grid of nine deck sites, picks up disposable tips, aspirates and dispenses, and -- on models with a gripper -- moves plates between sites. PyLabRobot drives it through `AgilentBravoBackend`, a `LiquidHandlerBackend` exported from `pylabrobot.agilent` alongside the `Bravo` device facade and the `BravoDeck` deck model.\n", + "\n", + "| Property | Value |\n", + "|---|---|\n", + "| Darwin | Ethernet TCP, port 7613. Gripper: yes. |\n", + "| Agile 7612 | Ethernet TCP, port 7612. Gripper: yes. |\n", + "| Bravo SRT | Ethernet TCP, port 7612 (same wire protocol as Agile 7612). Gripper: no. |\n", + "| Legacy Agile | Serial, or Ethernet TCP, port 10000. Gripper: yes. |\n", + "| Simulation | `SimulationController` -- no hardware or transport needed. |\n", + "| Head geometries | 8x1 and 16x1 disposable-tip heads at 9 mm and 4.5 mm barrel pitch; 8x12 at 9 mm pitch; 16x24 at 4.5 mm pitch. `num_channels` is 8, 16, 96, or 384. |\n", + "| Deck | Fixed 3x3 grid of nine sites, positioned from the instrument's own taught teachpoints. |\n", + "\n", + "```{note}\n", + "This driver is unverified against real Agilent Bravo hardware. The protocol and controller layers it drives (`pylabrobot.agilent.bravo.protocol`, `pylabrobot.agilent.bravo.controllers`, `pylabrobot.agilent.bravo.darwin`) are exercised against real Darwin, Agile 7612, Bravo SRT, and legacy Agile instruments. The PyLabRobot transport, the deck mapping (`pylabrobot.agilent.bravo.deck.resource`), and `AgilentBravoBackend` itself are not -- `AgilentBravoBackend.setup()` logs this warning every time it runs. Report anything you find at https://discuss.pylabrobot.org.\n", + "```\n" + ] + }, + { + "cell_type": "markdown", + "id": "bravo-device-card", + "metadata": {}, + "source": [ + "```{device-card} agilent-bravo\n", + "```\n" + ] + }, + { + "cell_type": "markdown", + "id": "bravo-communication", + "metadata": {}, + "source": [ + "## How it talks\n", + "\n", + "A Bravo speaks one of two unrelated binary protocols, depending on its controller generation:\n", + "\n", + "- **Gemini** (Darwin) -- a framed TCP protocol. One request is outstanding at a time under a lock; a background thread reads frames off the wire and wakes whichever call is waiting on a response.\n", + "- **V11/Agile** (Agile 7612, Bravo SRT, legacy Agile) -- a length-prefixed framing that carries 10-byte Agile packets to one or two motor controllers over an internal bus (X/Y/Z/W on one, the gripper's G/Zg on the other, where present). The Agile 7612 generation swaps the frame's command and length fields and adds a CRC-8/MAXIM check the legacy Agile generation does not have.\n", + "\n", + "Both protocols live entirely in `pylabrobot.agilent.bravo.protocol` and never open a connection themselves. A controller (`pylabrobot.agilent.bravo.controllers`, or `pylabrobot.agilent.bravo.darwin` for Gemini) drives one of them over a `pylabrobot.agilent.bravo.transport` connection you construct and hand it.\n" + ] + }, + { + "cell_type": "markdown", + "id": "bravo-physical-setup", + "metadata": {}, + "source": [ + "## Physical setup\n", + "\n", + "Connect over the network for Darwin, Agile 7612, and Bravo SRT, or over serial (or TCP port 10000) for a legacy Agile.\n", + "\n", + "```{warning}\n", + "The Bravo moves a pipetting head -- and, on gripper models, a plate gripper -- over a fixed deck under motor power. A wrong deck assignment, a bad teachpoint, or an untested protocol can break labware, crush the pipette head into a plate, or injure a hand in its path. Keep the workspace clear and the emergency stop reachable, and dry-run every protocol against `SimulationController` before running it against real hardware.\n", + "```\n" + ] + }, + { + "cell_type": "markdown", + "id": "bravo-setup-sim-md", + "metadata": {}, + "source": [ + "## Connect (simulation)\n", + "\n", + "`SimulationController` needs no transport or hardware: every axis tracks its position in memory and starts already homed. Build a `Bravo` facade around it, wrap it in `AgilentBravoBackend`, and hand that and a `BravoDeck` to a PyLabRobot `LiquidHandler`.\n", + "\n", + "`config.head.teach_tip_length_mm` records the measured length of the tip that was on the head when the deck's teachpoints were taught; liquid handling and gripper moves need it to compute a safe approach height.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bravo-setup-sim-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent import AgilentBravoBackend, Bravo\n", + "from pylabrobot.agilent.bravo.config import BravoMachineConfig\n", + "from pylabrobot.agilent.bravo.controllers.simulation import SimulationController\n", + "from pylabrobot.agilent.bravo.deck.resource import BravoDeck\n", + "from pylabrobot.legacy.liquid_handling import LiquidHandler\n", + "\n", + "controller = SimulationController(head_type=\"96_d_70\")\n", + "deck = BravoDeck(head_type=\"96_d_70\")\n", + "config = BravoMachineConfig()\n", + "config.head.teach_tip_length_mm = 19.9 # measured length of the tips this example uses\n", + "\n", + "bravo = Bravo(controller, config=config, deck=deck)\n", + "backend = AgilentBravoBackend(bravo)\n", + "lh = LiquidHandler(backend=backend, deck=deck)\n", + "\n", + "await lh.setup()\n" + ] + }, + { + "cell_type": "markdown", + "id": "bravo-setup-hardware-md", + "metadata": {}, + "source": [ + "## Connect (real hardware)\n", + "\n", + "Real hardware follows the same shape, with a real transport and the controller matching the instrument's generation: `DarwinController` for Darwin (over a `SocketTransport` at port 7613), `Agile7612Controller` for the Agile 7612 generation, `AgileSrtController` for a gripperless Bravo SRT (same wire protocol, port 7612), or `AgileController` for a legacy Agile (`SerialTransport`, or `SocketTransport` at port 10000). This cell is not run in this notebook -- it needs a real instrument at `BRAVO_IP`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bravo-setup-hardware-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent import AgilentBravoBackend, Bravo\n", + "from pylabrobot.agilent.bravo.config import BravoMachineConfig\n", + "from pylabrobot.agilent.bravo.controllers.agile_7612 import Agile7612Controller\n", + "from pylabrobot.agilent.bravo.deck.resource import BravoDeck\n", + "from pylabrobot.agilent.bravo.transport.socket import SocketTransport\n", + "from pylabrobot.legacy.liquid_handling import LiquidHandler\n", + "\n", + "BRAVO_IP = \"192.168.0.10\" # Replace with this instrument's address.\n", + "\n", + "transport = SocketTransport(\"bravo\", BRAVO_IP, 7612)\n", + "controller = Agile7612Controller(transport)\n", + "deck = BravoDeck(head_type=\"96_d_70\")\n", + "config = BravoMachineConfig()\n", + "config.head.teach_tip_length_mm = 19.9 # Replace with the taught tip's measured length.\n", + "\n", + "bravo = Bravo(controller, transport=transport, config=config, deck=deck)\n", + "backend = AgilentBravoBackend(bravo)\n", + "lh = LiquidHandler(backend=backend, deck=deck)\n", + "\n", + "await lh.setup()\n" + ] + }, + { + "cell_type": "markdown", + "id": "bravo-home-md", + "metadata": {}, + "source": [ + "## Home the machine\n", + "\n", + "Homing moves every axis to its reference position and must run with the deck and head path clear. `Bravo.home()` is reached through `backend.bravo`, since homing is not part of PyLabRobot's own `LiquidHandler` interface.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bravo-home-code", + "metadata": {}, + "outputs": [], + "source": [ + "homed_axes = await backend.bravo.home()\n", + "homed_axes\n" + ] + }, + { + "cell_type": "markdown", + "id": "bravo-deck-md", + "metadata": {}, + "source": [ + "## Deck and teachpoints\n", + "\n", + "`BravoDeck` models the instrument's fixed 3x3 grid of nine sites, each positioned at the X/Y/Z the instrument was taught for it (`deck.teachpoints`). Assign labware to a site with `assign_child_at_site`; the backend pushes it into the driver's own labware model the first time an operation touches that site.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bravo-deck-code", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import Trash, cor_96_wellplate_360uL_Fb, opentrons_96_tiprack_10ul\n", + "\n", + "tip_rack = opentrons_96_tiprack_10ul(name=\"tip_rack_1\")\n", + "deck.assign_child_at_site(tip_rack, 4)\n", + "\n", + "assay_plate = cor_96_wellplate_360uL_Fb(name=\"assay_plate_1\")\n", + "deck.assign_child_at_site(assay_plate, 5)\n", + "\n", + "trash = Trash(name=\"trash_1\", size_x=127.0, size_y=86.0, size_z=40.0)\n", + "deck.assign_child_at_site(trash, 9)\n", + "\n", + "deck.teachpoints.get_teachpoint(5, \"x\"), deck.teachpoints.get_teachpoint(5, \"y\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "bravo-block-rule-md", + "metadata": {}, + "source": [ + "## Tip pickup and the rectangular-block rule\n", + "\n", + "Every operation the Bravo head performs works on a contiguous rectangular block of barrels, anchored at one of the head's four corners: a single well, a full row or column, a rectangle, or the whole head. PyLabRobot lets you select any set of wells or tip spots, but the driver only accepts a selection whose identifiers form a complete rectangle -- a diagonal pair, an L-shape, or a rectangle with a hole in it is rejected, with an explanation of what would work instead.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bravo-block-reject-code", + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " await lh.pick_up_tips(tip_rack[\"A6\", \"B7\"])\n", + "except Exception as exc:\n", + " print(f\"{type(exc).__name__}: {exc}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "bravo-block-single-md", + "metadata": {}, + "source": [ + "The smallest legal block is a single well.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bravo-block-single-code", + "metadata": {}, + "outputs": [], + "source": [ + "await lh.pick_up_tips(tip_rack[\"H12\"])\n", + "backend.bravo.head_mode\n" + ] + }, + { + "cell_type": "markdown", + "id": "bravo-block-single-drop-md", + "metadata": {}, + "source": [ + "Drop it before picking up the column this notebook uses next -- the head can only ever hold one block of tips at a time.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bravo-block-single-drop-code", + "metadata": {}, + "outputs": [], + "source": [ + "await lh.drop_tips([trash])\n" + ] + }, + { + "cell_type": "markdown", + "id": "bravo-block-column-md", + "metadata": {}, + "source": [ + "A full column is a rectangular block too -- eight rows, one column. Note the colon inside the string, `\"A1:H1\"`: PyLabRobot's item-selection syntax is inclusive of both ends. The Python slice form, `tip_rack[\"A1\":\"H1\"]`, is exclusive of the stop and would only select seven wells here.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bravo-block-column-code", + "metadata": {}, + "outputs": [], + "source": [ + "await lh.pick_up_tips(tip_rack[\"A1:H1\"])\n", + "backend.bravo.head_mode\n" + ] + }, + { + "cell_type": "markdown", + "id": "bravo-aspirate-md", + "metadata": {}, + "source": [ + "## Aspirate\n", + "\n", + "Volume and flow rate are shared by every active channel in one call, since the whole head is driven by a single plunger.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bravo-aspirate-code", + "metadata": {}, + "outputs": [], + "source": [ + "await lh.aspirate(assay_plate[\"A1:H1\"], vols=[5.0] * 8)\n" + ] + }, + { + "cell_type": "markdown", + "id": "bravo-dispense-md", + "metadata": {}, + "source": [ + "## Dispense\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bravo-dispense-code", + "metadata": {}, + "outputs": [], + "source": [ + "await lh.dispense(assay_plate[\"A1:H1\"], vols=[5.0] * 8)\n" + ] + }, + { + "cell_type": "markdown", + "id": "bravo-tip-drop-md", + "metadata": {}, + "source": [ + "## Tip drop\n", + "\n", + "Drop to a tip box position to return tips, or to a `Trash` resource to discard them; either way, every channel in one call must target the same kind of resource. This notebook drops to trash: a tip box tracks its own depletion in full row/column bands, so only a block that spans the box's full width or height (a `row`, `column`, or `all_barrels` head mode) returns cleanly. A `single_barrel` or narrow `rectangle` return next to still-occupied neighbours is rejected -- even back to the exact cell a tip came from -- with an explanation of which head modes do round-trip.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bravo-tip-drop-code", + "metadata": {}, + "outputs": [], + "source": [ + "await lh.drop_tips([trash] * 8)\n" + ] + }, + { + "cell_type": "markdown", + "id": "bravo-gripper-md", + "metadata": {}, + "source": [ + "## Move a plate with the gripper\n", + "\n", + "`LiquidHandler.move_plate()` is the call a PyLabRobot user actually makes to move a plate. `AgilentBravoBackend` reports `num_arms=1` on a gripper-equipped model, so PyLabRobot routes the call through this backend's `pick_up_resource`/`move_picked_up_resource`/`drop_resource` as normal. On a gripperless Bravo SRT, the backend reports `num_arms=0` and the same call raises PyLabRobot's own `\"No robotic arm is installed on this liquid handler.\"` before ever reaching the backend -- the correct layering, not a Bravo-specific failure.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bravo-gripper-code", + "metadata": {}, + "outputs": [], + "source": [ + "target_site = deck.get_resource(f\"{deck.name}_site_6\")\n", + "await lh.move_plate(assay_plate, target_site)\n", + "deck.site_for_resource(assay_plate)\n" + ] + }, + { + "cell_type": "markdown", + "id": "bravo-limits-md", + "metadata": {}, + "source": [ + "## Limits\n", + "\n", + "- **384-channel heads** work through the per-channel path (`pick_up_tips`/`aspirate`/`dispense`/`drop_tips`) like any other head. The 96-head path (`pick_up_tips96`/`aspirate96`/`dispense96`/`drop_tips96`) is unreachable for one: PyLabRobot's own `LiquidHandler.pick_up_tips96` hard-requires exactly 96 populated tip positions before the backend is ever called.\n", + "- **The Bravo SRT has no gripper.** `AgilentBravoBackend.num_arms` is `0` for it, so `LiquidHandler.move_plate()`/`pick_up_resource()` raise PyLabRobot's own `\"No robotic arm is installed on this liquid handler.\"` before ever reaching the backend; a caller that bypasses `LiquidHandler` and calls `pick_up_resource`/`move_picked_up_resource`/`drop_resource` directly hits this backend's own gripper check instead.\n", + "- **`BravoDeck`'s well-grid sign convention is unconfirmed against real hardware.** The offset from a site's teachpoint to well A1 is taken directly from the resource's own item-grid metadata, un-negated; whether that matches where the instrument physically expects A1 has not been checked on a real Bravo. Under `BravoDeck`'s default teachpoints for a `96_d_70` head and a `cor_96_wellplate_360uL_Fb`, this leaves only deck sites 5 and 8 with every one of the plate's 96 wells reachable as a plate anchor -- sites 1, 2, and 3 have none reachable at all, and sites 4, 6, 7, and 9 have some but not all. See `pylabrobot/agilent/bravo/deck/resource.py` for the details.\n" + ] + }, + { + "cell_type": "markdown", + "id": "bravo-disconnect-md", + "metadata": {}, + "source": [ + "## Disconnect\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bravo-disconnect-code", + "metadata": {}, + "outputs": [], + "source": [ + "await lh.stop()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/agilent/index.md b/docs/user_guide/agilent/index.md index 31b65a9b774..b49d68a7f57 100644 --- a/docs/user_guide/agilent/index.md +++ b/docs/user_guide/agilent/index.md @@ -4,5 +4,6 @@ :maxdepth: 1 benchcel/hello-world +bravo/hello-world vspin/index ```