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 ``` 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/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/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() 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/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 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 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/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/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(" 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() 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() 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() 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()