From f280ea187531ca7ed94cceed28646f56406a202e Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 14 Sep 2026 20:59:24 +0100 Subject: [PATCH 1/2] `hamilton`: v1 STAR integration A STAR, STARlet and STAR+ driven through a single driver in `pylabrobot.hamilton`, with each fitted module as a feature of it: the pipetting channels, the 96- and 384-heads, the iSWAP, the autoload, the X-arms and the front cover. Setup discovers what is fitted, initializes only what reports itself down, and models every feature on the deck. A simulator answers from recorded device configurations, and a device's configuration can be saved and simulated from later. Resources: the STAR decks and CORE grippers move to their own modules with their old imports kept, the pipetting channels are modelled as `NChannelPipette` and `TipMountingShaft`, and `HamiltonDeck` places the parts the driver models. Co-Authored-By: Claude Opus 5 --- pylabrobot/hamilton/__init__.py | 5 + pylabrobot/hamilton/protocol/text/router.py | 107 +- .../hamilton/protocol/text/router_tests.py | 108 + pylabrobot/hamilton/star/conftest.py | 19 + pylabrobot/hamilton/star/device.py | 478 +++ pylabrobot/hamilton/star/device_tests.py | 145 + pylabrobot/hamilton/star/driver/__init__.py | 3 + .../hamilton/star/driver/configuration.py | 261 ++ pylabrobot/hamilton/star/driver/errors.py | 48 +- .../hamilton/star/driver/features/__init__.py | 32 + .../hamilton/star/driver/features/autoload.py | 1702 ++++++++++ .../star/driver/features/autoload_tests.py | 157 + .../hamilton/star/driver/features/cover.py | 92 + .../hamilton/star/driver/features/head.py | 1295 +++++++ .../hamilton/star/driver/features/head384.py | 206 ++ .../hamilton/star/driver/features/head96.py | 377 +++ .../star/driver/features/head_tests.py | 141 + .../hamilton/star/driver/features/iswap.py | 2982 +++++++++++++++++ .../star/driver/features/iswap_tests.py | 329 ++ .../hamilton/star/driver/features/pipettes.py | 1315 ++++++++ .../star/driver/features/pipettes_tests.py | 165 + .../hamilton/star/driver/features/x_arm.py | 603 ++++ .../star/driver/features/x_arm_tests.py | 416 +++ pylabrobot/hamilton/star/driver/lock.py | 71 + pylabrobot/hamilton/star/driver/master.py | 1817 ++++++++++ .../hamilton/star/driver/master_tests.py | 434 +++ .../hamilton/star/driver/recordings/README.md | 147 + ...ar_legacy_2021_8ch_head384_autoload1D.json | 491 +++ ...tar_legacy_2021_8ch_head96_autoload1D.json | 496 +++ ...et_legacy_2021_8ch_head384_autoload1D.json | 491 +++ ...let_legacy_2021_8ch_head96_autoload1D.json | 496 +++ .../starplus_legacy_2021_8ch_head96.json | 408 +++ pylabrobot/hamilton/star/driver/simulator.py | 1203 +++++++ pylabrobot/hamilton/star/lock.py | 76 - .../hamilton/star/resource_model/__init__.py | 18 + .../hamilton/star/resource_model/heads.py | 142 + .../hamilton/star/resource_model/iswap.py | 225 ++ pylabrobot/io/io.py | 8 + pylabrobot/resources/__init__.py | 1 + pylabrobot/resources/barcode.py | 12 + pylabrobot/resources/hamilton/__init__.py | 14 +- .../resources/hamilton/core_grippers.py | 74 + .../resources/hamilton/hamilton_deck_tests.py | 11 + .../resources/hamilton/hamilton_decks.py | 623 ++-- pylabrobot/resources/hamilton/star_decks.py | 254 ++ pylabrobot/resources/n_channel_pipettes.py | 247 ++ .../resources/n_channel_pipettes_tests.py | 20 + pyproject.toml | 7 +- 48 files changed, 18362 insertions(+), 410 deletions(-) create mode 100644 pylabrobot/hamilton/protocol/text/router_tests.py create mode 100644 pylabrobot/hamilton/star/conftest.py create mode 100644 pylabrobot/hamilton/star/device.py create mode 100644 pylabrobot/hamilton/star/device_tests.py create mode 100644 pylabrobot/hamilton/star/driver/configuration.py create mode 100644 pylabrobot/hamilton/star/driver/features/__init__.py create mode 100644 pylabrobot/hamilton/star/driver/features/autoload.py create mode 100644 pylabrobot/hamilton/star/driver/features/autoload_tests.py create mode 100644 pylabrobot/hamilton/star/driver/features/cover.py create mode 100644 pylabrobot/hamilton/star/driver/features/head.py create mode 100644 pylabrobot/hamilton/star/driver/features/head384.py create mode 100644 pylabrobot/hamilton/star/driver/features/head96.py create mode 100644 pylabrobot/hamilton/star/driver/features/head_tests.py create mode 100644 pylabrobot/hamilton/star/driver/features/iswap.py create mode 100644 pylabrobot/hamilton/star/driver/features/iswap_tests.py create mode 100644 pylabrobot/hamilton/star/driver/features/pipettes.py create mode 100644 pylabrobot/hamilton/star/driver/features/pipettes_tests.py create mode 100644 pylabrobot/hamilton/star/driver/features/x_arm.py create mode 100644 pylabrobot/hamilton/star/driver/features/x_arm_tests.py create mode 100644 pylabrobot/hamilton/star/driver/lock.py create mode 100644 pylabrobot/hamilton/star/driver/master.py create mode 100644 pylabrobot/hamilton/star/driver/master_tests.py create mode 100644 pylabrobot/hamilton/star/driver/recordings/README.md create mode 100644 pylabrobot/hamilton/star/driver/recordings/star_legacy_2021_8ch_head384_autoload1D.json create mode 100644 pylabrobot/hamilton/star/driver/recordings/star_legacy_2021_8ch_head96_autoload1D.json create mode 100644 pylabrobot/hamilton/star/driver/recordings/starlet_legacy_2021_8ch_head384_autoload1D.json create mode 100644 pylabrobot/hamilton/star/driver/recordings/starlet_legacy_2021_8ch_head96_autoload1D.json create mode 100644 pylabrobot/hamilton/star/driver/recordings/starplus_legacy_2021_8ch_head96.json create mode 100644 pylabrobot/hamilton/star/driver/simulator.py delete mode 100644 pylabrobot/hamilton/star/lock.py create mode 100644 pylabrobot/hamilton/star/resource_model/__init__.py create mode 100644 pylabrobot/hamilton/star/resource_model/heads.py create mode 100644 pylabrobot/hamilton/star/resource_model/iswap.py create mode 100644 pylabrobot/resources/hamilton/core_grippers.py create mode 100644 pylabrobot/resources/hamilton/star_decks.py create mode 100644 pylabrobot/resources/n_channel_pipettes.py create mode 100644 pylabrobot/resources/n_channel_pipettes_tests.py diff --git a/pylabrobot/hamilton/__init__.py b/pylabrobot/hamilton/__init__.py index e69de29bb2d..83eef68d2f7 100644 --- a/pylabrobot/hamilton/__init__.py +++ b/pylabrobot/hamilton/__init__.py @@ -0,0 +1,5 @@ +"""Hamilton devices.""" + +from .star.device import STAR, STARDevice, STARLet, STARPlus +from .star.driver.master import STARDriver +from .star.driver.simulator import STARSimulationDriver diff --git a/pylabrobot/hamilton/protocol/text/router.py b/pylabrobot/hamilton/protocol/text/router.py index 6ce49ed37ea..262b5649770 100644 --- a/pylabrobot/hamilton/protocol/text/router.py +++ b/pylabrobot/hamilton/protocol/text/router.py @@ -63,13 +63,16 @@ def __init__( self._parse_id = parse_id self._raise_for_error = raise_for_error + # TODO: not used by the reader yet. self.packet_read_timeout = packet_read_timeout self.read_timeout = read_timeout self.id_ = 0 self._reading_thread: Optional[threading.Thread] = None self._reading_thread_stop = threading.Event() + # The reading thread and the event loop both change this, so every change goes through the lock. self._waiting_tasks: List[HamiltonTask] = [] + self._waiting_tasks_lock = threading.Lock() def start(self) -> None: """Begin reading replies. The caller opens the transport first.""" @@ -83,11 +86,12 @@ def stop(self) -> None: if self._reading_thread is not None: self._reading_thread.join(timeout=10) self._reading_thread = None - for task in self._waiting_tasks: + with self._waiting_tasks_lock: + waiting, self._waiting_tasks = self._waiting_tasks, [] + for task in waiting: task.loop.call_soon_threadsafe( task.fut.set_exception, RuntimeError("Stopping the reply router.") ) - self._waiting_tasks.clear() def next_id(self) -> int: """continuously generate unique ids 0 <= x < 10000.""" @@ -115,17 +119,28 @@ async def send( Returns: The reply, or None when `wait` is False. """ - await self.io.write(cmd.encode(), timeout=write_timeout) - if not wait: + await self.io.write(cmd.encode(), timeout=write_timeout) return None if read_timeout is None: read_timeout = self.read_timeout + # Wait for the reply before asking for it. The reading thread runs alongside this one, and a + # device can answer before a task registered after the write would exist - the reply then + # arrives with nothing waiting for it, is dropped, and the command times out with its answer + # already gone past. Registering first closes that window; the task is taken back off again if + # the write never happens. loop = asyncio.get_event_loop() fut: asyncio.Future[str] = loop.create_future() - self._start_reading(id_, loop, fut, cmd, read_timeout) + task = self._start_reading(id_, loop, fut, cmd, read_timeout) + try: + await self.io.write(cmd.encode(), timeout=write_timeout) + except BaseException: + with self._waiting_tasks_lock: + if task in self._waiting_tasks: + self._waiting_tasks.remove(task) + raise return await fut async def send_raw( @@ -151,19 +166,25 @@ def _start_reading( fut: asyncio.Future, cmd: str, timeout: int, - ) -> None: - """Submit a task to the reading thread.""" + ) -> HamiltonTask: + """Submit a task to the reading thread, and hand it back so a caller can take it off again. + + Returns: + The task now waiting for a reply. + """ timeout_time = time.time() + timeout - self._waiting_tasks.append( - HamiltonTask(id_=id_, loop=loop, fut=fut, cmd=cmd, timeout_time=timeout_time) - ) + task = HamiltonTask(id_=id_, loop=loop, fut=fut, cmd=cmd, timeout_time=timeout_time) + with self._waiting_tasks_lock: + self._waiting_tasks.append(task) if self._reading_thread is None or not self._reading_thread.is_alive(): self._reading_thread_stop.clear() self._reading_thread = threading.Thread(target=self._reading_thread_main, daemon=True) self._reading_thread.start() + return task + def _reading_thread_main(self) -> None: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -182,17 +203,20 @@ async def _continuously_read(self) -> None: """ while not self._reading_thread_stop.is_set(): - for idx in range(len(self._waiting_tasks) - 1, -1, -1): # reverse order to allow deletion - task = self._waiting_tasks[idx] - if time.time() > task.timeout_time: - logger.warning("Timeout while waiting for response to command %s.", task.cmd) - task.loop.call_soon_threadsafe( - task.fut.set_exception, - TimeoutError(f"Timeout while waiting for response to command {task.cmd}."), - ) - del self._waiting_tasks[idx] - - if len(self._waiting_tasks) == 0: + with self._waiting_tasks_lock: + now = time.time() + timed_out = [task for task in self._waiting_tasks if now > task.timeout_time] + for task in timed_out: + self._waiting_tasks.remove(task) + waiting = len(self._waiting_tasks) + for task in timed_out: + logger.warning("Timeout while waiting for response to command %s.", task.cmd) + task.loop.call_soon_threadsafe( + task.fut.set_exception, + TimeoutError(f"Timeout while waiting for response to command {task.cmd}."), + ) + + if waiting == 0: await asyncio.sleep(0.01) continue @@ -212,17 +236,30 @@ async def _continuously_read(self) -> None: continue module_and_command = resp[: self.module_id_length + 2] - for idx in range(len(self._waiting_tasks)): - task = self._waiting_tasks[idx] - # if the command has no id, we have to check the command itself - if response_id == task.id_ or ( - task.id_ is None and task.cmd.startswith(module_and_command) - ): - try: - self._raise_for_error(resp) - except Exception as e: - task.loop.call_soon_threadsafe(task.fut.set_exception, e) - else: - task.loop.call_soon_threadsafe(task.fut.set_result, resp) - del self._waiting_tasks[idx] - break + with self._waiting_tasks_lock: + matched = next( + ( + task + for task in self._waiting_tasks + # if the command has no id, we have to check the command itself + if response_id == task.id_ + or (task.id_ is None and task.cmd.startswith(module_and_command)) + ), + None, + ) + if matched is not None: + self._waiting_tasks.remove(matched) + + if matched is None: + # Deliberately not guessed at. Handing it to whichever outstanding command shares its + # module and code answers that command with something that was never its answer, and a + # reply arriving with nothing waiting for it is worth seeing rather than repairing. + logger.warning("nothing was waiting for this reply, and it was dropped: %s", resp) + continue + + try: + self._raise_for_error(resp) + except Exception as e: + matched.loop.call_soon_threadsafe(matched.fut.set_exception, e) + else: + matched.loop.call_soon_threadsafe(matched.fut.set_result, resp) diff --git a/pylabrobot/hamilton/protocol/text/router_tests.py b/pylabrobot/hamilton/protocol/text/router_tests.py new file mode 100644 index 00000000000..1fe60d0761b --- /dev/null +++ b/pylabrobot/hamilton/protocol/text/router_tests.py @@ -0,0 +1,108 @@ +import asyncio +import queue +import unittest +from typing import List, Optional + +from pylabrobot.hamilton.protocol.text.framing import read_id +from pylabrobot.hamilton.protocol.text.router import ReplyRouter + + +class FakeTransport: + """A link whose replies are queued by the test, and whose writes can be made to fail.""" + + def __init__(self, fail_writes: bool = False): + self.replies: "queue.Queue[bytes]" = queue.Queue() + self.written: List[bytes] = [] + self.fail_writes = fail_writes + + async def write(self, data: bytes, timeout: Optional[int] = None) -> None: + if self.fail_writes: + raise ConnectionError("the write did not go out") + self.written.append(data) + + async def read(self) -> bytes: + try: + return self.replies.get(timeout=0.01) + except queue.Empty: + raise TimeoutError from None + + +def router(transport: FakeTransport, read_timeout: int = 5) -> ReplyRouter: + def raise_for_error(reply: str) -> None: + if "er99" in reply: + raise RuntimeError(reply) + + return ReplyRouter( + io=transport, # type: ignore[arg-type] + module_id_length=2, + parse_id=read_id, + raise_for_error=raise_for_error, + read_timeout=read_timeout, + ) + + +class TestReplyRouter(unittest.IsolatedAsyncioTestCase): + """Each reply reaches the command waiting for it, and nothing is left waiting that cannot be.""" + + async def test_a_reply_reaches_the_command_with_its_id(self): + transport = FakeTransport() + r = router(transport) + r.start() + try: + first = asyncio.ensure_future(r.send("C0RTid0001", id_=1)) + second = asyncio.ensure_future(r.send("C0RWid0002", id_=2)) + await asyncio.sleep(0.05) + transport.replies.put(b"C0RWid0002er00/00rw000") + transport.replies.put(b"C0RTid0001er00/00rt0") + self.assertEqual(await asyncio.wait_for(first, 2), "C0RTid0001er00/00rt0") + self.assertEqual(await asyncio.wait_for(second, 2), "C0RWid0002er00/00rw000") + self.assertEqual(r._waiting_tasks, []) + finally: + r.stop() + + async def test_an_error_reply_raises_on_its_command(self): + transport = FakeTransport() + r = router(transport) + r.start() + try: + sent = asyncio.ensure_future(r.send("C0ZAid0003", id_=3)) + await asyncio.sleep(0.05) + transport.replies.put(b"C0ZAid0003er99/00") + with self.assertRaises(RuntimeError): + await asyncio.wait_for(sent, 2) + finally: + r.stop() + + async def test_an_unanswered_command_times_out_and_is_taken_off(self): + r = router(FakeTransport(), read_timeout=0) + r.start() + try: + with self.assertRaises(TimeoutError): + await asyncio.wait_for(r.send("C0QWid0004", id_=4), 2) + self.assertEqual(r._waiting_tasks, []) + finally: + r.stop() + + async def test_a_failed_write_leaves_nothing_waiting(self): + r = router(FakeTransport(fail_writes=True)) + r.start() + try: + with self.assertRaises(ConnectionError): + await r.send("C0QWid0005", id_=5) + self.assertEqual(r._waiting_tasks, []) + finally: + r.stop() + + async def test_stop_fails_every_command_still_waiting(self): + r = router(FakeTransport()) + r.start() + sent = asyncio.ensure_future(r.send("C0QWid0006", id_=6)) + await asyncio.sleep(0.05) + r.stop() + with self.assertRaises(RuntimeError): + await asyncio.wait_for(sent, 2) + self.assertEqual(r._waiting_tasks, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/hamilton/star/conftest.py b/pylabrobot/hamilton/star/conftest.py new file mode 100644 index 00000000000..a33f7b72de8 --- /dev/null +++ b/pylabrobot/hamilton/star/conftest.py @@ -0,0 +1,19 @@ +"""Fixtures the STAR tests share. + +Here rather than beside the driver because nothing the package ships uses them: they exist to +build devices to test against, not to describe a device anything runs on. +""" + +from pylabrobot.hamilton.star.driver.features.x_arm import XArmConfiguration + +# An arm that carries nothing: the geometry of a STAR arm with none of its feature bits set. The +# firmware requires the two drives' bits to be disjoint, so a device with two arms has its features +# on one of them and an arm like this as the other. Its firmware is the arm's, since it stands in +# for a real one. +BARE_X_ARM = XArmConfiguration( + firmware_version="1.4S 2012-04-25", + width=354.0, + x_range=(95.0, 1340.2), + workspace_x_range=(-323.2, 1517.2), + wrap_size=595.2, +) diff --git a/pylabrobot/hamilton/star/device.py b/pylabrobot/hamilton/star/device.py new file mode 100644 index 00000000000..1331d1bf2e2 --- /dev/null +++ b/pylabrobot/hamilton/star/device.py @@ -0,0 +1,478 @@ +"""The STAR: the device, and what it knows about its own deck.""" + +import logging +import os +from typing import Optional + +from pylabrobot.hamilton.star.driver.features.autoload import Autoload +from pylabrobot.hamilton.star.driver.features.cover import FrontCover +from pylabrobot.hamilton.star.driver.features.head96 import Head96 +from pylabrobot.hamilton.star.driver.features.head384 import Head384 +from pylabrobot.hamilton.star.driver.features.iswap import iSWAP +from pylabrobot.hamilton.star.driver.features.pipettes import Pipettes +from pylabrobot.hamilton.star.driver.features.x_arm import XArm +from pylabrobot.hamilton.star.driver.master import STARDriver +from pylabrobot.hamilton.star.driver.simulator import STARSimulationDriver +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.hamilton import ( + HamiltonSTARDeck, + STARDeck, + STARLetDeck, + STARPlusDeck, +) +from pylabrobot.resources.resource import Resource + +# Where the recordings this package ships live, and which one stands for each frame. Only ever +# handed to a simulated device: a physical one is whatever it answers, and a declaration given for +# it is cross-checked against it rather than standing in for it. Recording another device is +# saving a file rather than editing code. +_RECORDINGS = os.path.join(os.path.dirname(__file__), "driver", "recordings") + +RECORDING_STAR = os.path.join(_RECORDINGS, "star_legacy_2021_8ch_head96_autoload1D.json") +RECORDING_STARLET = os.path.join(_RECORDINGS, "starlet_legacy_2021_8ch_head96_autoload1D.json") +RECORDING_STARPLUS = os.path.join(_RECORDINGS, "starplus_legacy_2021_8ch_head96.json") + +# The same two frames fitted with a 384-head instead. No frame function defaults to these: a +# device that has one is declared with it. +RECORDING_STAR_HEAD384 = os.path.join(_RECORDINGS, "star_legacy_2021_8ch_head384_autoload1D.json") +RECORDING_STARLET_HEAD384 = os.path.join( + _RECORDINGS, "starlet_legacy_2021_8ch_head384_autoload1D.json" +) + +logger = logging.getLogger(__name__) + +# How big each device is. Measured on the manufacturer's own 3D models of the three frames, +# with the optional front loader left off - see the factory functions below for what that excludes. +# The envelope is the hood: it sets the width, the depth at the back and the full height, with the +# front door setting the depth at the front. Depth and height come out the same on all three frames, +# which is what says the frames differ in width alone. +STAR_SIZE_X = 1_667.0 +STARLET_SIZE_X = 1_130.0 +STARPLUS_SIZE_X = 2_163.5 +# The left extension housing, measured on a CAD model of the part. It bolts to the left of the +# chassis and stands on the same bench, so it is a resource of its own at a negative x rather than +# something that grows the device: growing it would move the device origin, and with it +# everything measured from that origin including the chassis's own geometry. The same reasoning as +# the autoload's loading tray, which stands in front at a negative y. +# +# It is aligned by its TOP and its BACK, not by the bench and the front face: 48.0 mm shorter than +# the device and 6.8 mm shallower, so hanging it flush at the top leaves it clear of the bench, +# and pushing it flush at the back leaves its front inside the device's. +EXTENSION_HOUSING_SIZE = (265.0, 779.0, 855.0) + +# The chassis's own left side panel, which the extension housing REPLACES: the housing has no +# device-facing side, so on a device that has one this panel is not there. Mutually exclusive +# with `left_extension_housing`, and the reason both are resources rather than part of the chassis. +# Its y, z and size are the same on all three frames; only its x differs, so each factory passes +# its own. +SIDE_PANEL_SIZE = (4.0, 726.0, 682.0) +SIDE_PANEL_ORIGIN_YZ = (52.8, 180.5) +# One panel, one place. The manufacturer's three files put it at 5.0, 3.5 and 6.0 - a 2.5 mm spread +# on a part whose geometry is identical in all three to the last decimal, so the spread is how each +# file was drawn rather than how the devices differ. This is the median, and the STARlet's, which +# is the frame the rest of this is measured against. +SIDE_PANEL_X = 5.0 +# What it used to be, before there was a part to measure: an unsourced 245.0 that only widened the +# device. Kept as a name because the tests measure the housing against it. +EXTENSION_HOUSING_SIZE_X = EXTENSION_HOUSING_SIZE[0] +MANUAL_SIZE_Y = 785.8 +SIZE_Z = 903.0 +# The chassis stands on feet, and `SIZE_Z` is the whole envelope with them included. This is how +# tall they are, measured off the flat underside of the base plate - one surface, whose area scales +# with the frame's width - and corroborated by where the chassis footprint jumps from the part of +# the width the feet cover to the full width of the body. The same on all three frames. +FEET_SIZE_Z = 43.0 +# What stands clear of the bench: the device without what it stands on. +BODY_SIZE_Z = SIZE_Z - FEET_SIZE_Z +# The loading tray stands 221.2 mm proud of the front face - the manufacturer's models are 1007.0 +# deep with a loader fitted against 785.8 without, on all three frames. That is NOT added to the +# device here: the tray is its own resource, `autoload_loading_tray`, and a resource in front +# of its parent is exactly what a negative y describes. Adding it would move the device origin +# and with it everything measured from that origin, including the chassis's own geometry. +AUTOLOAD_TRAY_PROUD_Y = 221.2 + +# How far behind the device's front face the deck resource's origin sits. +# +# A carrier lands 63.0 mm behind that origin and is 497.0 mm long, and the deck ends at a full-width +# cable duct whose front face is 654.8 mm behind the device's front face. That face is not what +# a carrier stops against: it carries a row of stubs, 3.0 mm long, that reach into the back of the +# carrier to locate it, so the carrier seats 3.0 mm further back than the face alone would allow. +# Hence 654.8 - 497.0 - 63.0 + 3.0. +# +# The duct and the deck's own front edge, 181.3 mm behind the front face, read the same in the CAD +# master and in the manufacturer's chassis models, and the same on all three frames - so STAR and +# STARlet share this figure rather than differing as they used to. +# +# What this replaces: 116.0 on a STAR and 106.0 on a STARlet, derived as `795 - 119 - 560` and +# `790 - 124 - 560` from depths that have since been measured. Those drove every carrier 21.2 mm +# and 11.2 mm respectively into the duct. +DECK_ORIGIN_Y = 97.8 + +# How far right the deck sits from where a 2021 STAR was measured. Two parts the manufacturer draws +# against the rails both land right of where the measured value puts them - the autoload's 31 track +# guides by 6.215 mm each, and the waste block's left face by 8.700 mm - so the rails themselves sit +# further right than 210 mm from the left face. The guides set the figure: there are thirty-one of +# them, they agree to 0.022 mm, and unlike the waste block nothing here has placed them. +DECK_ORIGIN_X_CORRECTION = 6.215 + +# Where the deck sits inside the device. +# x 210 mm from the left face to the first carrier, measured on a 2021 STAR, plus the correction +# above. The measurement and the manufacturer's own geometry disagree by that much and the +# geometry is what the models are drawn from. +# y see `DECK_ORIGIN_Y` +# z the deck work surface sits 100 mm above the deck's origin, which the manual states, so the +# origin is 100 mm below the surface. The surface itself is measured on the manufacturer's +# models: the deck plate is 2.5 mm thick, its underside on the platform's top plate, and its +# TOP - which is what a carrier rests on - is 180.5 mm above the device's base. The same +# on all three frames. What this replaces is an unsourced 78.5, which put the work surface at +# 178.5 and so seated every carrier 2.0 mm inside the plate it stands on. +STAR_DECK_LOCATION = Coordinate(110.0 + DECK_ORIGIN_X_CORRECTION, DECK_ORIGIN_Y, 80.5) +# The STARlet's x is not measured: its chassis leaves the same 119 mm beyond its deck as the STAR's +# does, so it takes the same value until someone measures one. +STARLET_DECK_LOCATION = Coordinate(110.0 + DECK_ORIGIN_X_CORRECTION, DECK_ORIGIN_Y, 80.5) + + +class STARDevice(Resource): + """The complete modelling and control interface for a Hamilton Microlab STAR. + + The device is itself a resource and its deck is its child, so everything on the deck is a + descendant of the device carrying it: one tree, rooted here. + + Two tiers over one driver. `star.driver` speaks to the device in its own terms - tracks, + positions, millimetres - and stays reachable whatever is built on top of it. The device adds + what the driver cannot know: where things are, and so which of them a command is about. + """ + + def __init__( + self, + deck: HamiltonSTARDeck, + simulation: bool = False, + declared_configuration_json: Optional[str] = None, + driver: Optional[STARDriver] = None, + name: str = "Generic STAR Device", + size_x: Optional[float] = None, + size_y: Optional[float] = None, + size_z: Optional[float] = None, + extension_housing: bool = True, + left_side_panel_installed: bool = False, + deck_location: Optional[Coordinate] = None, + model: Optional[str] = None, + ): + """ + Args: + deck: the deck this device carries. It becomes a child of the device, so everything + assigned to it is a descendant of this device. + simulation: whether to build a simulated device, which answers without one being plugged + in. Superseded by `driver`, which says exactly what to drive. + declared_configuration_json: path to a declared configuration, passed to the driver this + builds. Read only when this builds one: a driver given outright brings its own. + driver: the driver to drive the device through. + name: what to call this device in the resource tree. + size_x: how wide the device is, in mm, BEFORE any extension housing. Defaults to the + deck's own width. + size_y: how deep it is, in mm. Defaults to the deck's own depth. + size_z: how tall it is, in mm. Defaults to the deck's own height. + extension_housing: whether the left extension housing is fitted. It becomes a resource of its + own, `left_extension_housing`, standing to the LEFT of the chassis at a negative x. It does + NOT change the device's size: see `EXTENSION_HOUSING_SIZE`. + left_side_panel_installed: whether the chassis's left side panel is on. It becomes a + resource of its own, `left_side_panel`, at `SIDE_PANEL_X`. Declared rather than + discovered: the panel bolts off in seconds and the device does not report it. Passed on + to the driver, which stops an arm short of a fitted one. + deck_location: where the deck sits inside it, BEFORE any extension housing. Defaults to the + device's own origin. + model: which device this is. Defaults to the class name, which says only that it is a STAR. + + Raises: + ValueError: If neither a driver nor simulation is given, since there is then nothing to + drive, or if both the extension housing and the left side panel are declared. + """ + if driver is None and not simulation: + raise ValueError("pass a driver, or `simulation=True` to build a simulated one") + if extension_housing and left_side_panel_installed: + raise ValueError( + "an device has the left extension housing or the left side panel, not both: the " + "housing stands where the panel would be" + ) + if driver is not None and simulation: + logger.warning("both a driver and simulation given; driving the driver") + + super().__init__( + name=name, + size_x=deck.get_absolute_size_x() if size_x is None else size_x, + size_y=deck.get_absolute_size_y() if size_y is None else size_y, + size_z=deck.get_absolute_size_z() if size_z is None else size_z, + category="device", + model=model if model is not None else self.__class__.__name__, + ) + self.extension_housing = extension_housing + self.left_side_panel_installed = left_side_panel_installed + + self.deck = deck + if driver is not None: + if declared_configuration_json is not None: + logger.warning("a driver was given, so it brings its own declared configuration") + self.driver: STARDriver = driver + else: + # The driver is what reads a declaration. Without one, a simulated device works out from its + # deck what frame it is on, and says what it assumed. + self.driver = STARSimulationDriver( + deck=deck, declared_configuration_json=declared_configuration_json + ) + + if self.driver.deck is not None and self.driver.deck is not deck: + logger.warning("the driver was given another deck; modelling into this device's instead") + + self.driver.deck = deck + if self.driver.left_side_panel_installed != left_side_panel_installed: + logger.warning("the driver was told otherwise about the left side panel; taking this one's") + self.driver.left_side_panel_installed = left_side_panel_installed + self.assign_child_resource( + deck, location=deck_location if deck_location is not None else Coordinate(0, 0, 0) + ) + + if left_side_panel_installed: + self.assign_child_resource( + Resource( + name="left_side_panel", + size_x=SIDE_PANEL_SIZE[0], + size_y=SIDE_PANEL_SIZE[1], + size_z=SIDE_PANEL_SIZE[2], + category="left_side_panel", + model="hamilton_star_left_side_panel", + ), + location=Coordinate(SIDE_PANEL_X, SIDE_PANEL_ORIGIN_YZ[0], SIDE_PANEL_ORIGIN_YZ[1]), + ) + + if extension_housing: + # To the left, hung so its top and its back are level with the device's. + self.assign_child_resource( + Resource( + name="left_extension_housing", + size_x=EXTENSION_HOUSING_SIZE[0], + size_y=EXTENSION_HOUSING_SIZE[1], + size_z=EXTENSION_HOUSING_SIZE[2], + category="left_extension_housing", + model="hamilton_star_left_extension_housing", + ), + location=Coordinate( + -EXTENSION_HOUSING_SIZE[0], + self.get_absolute_size_y() - EXTENSION_HOUSING_SIZE[1], + self.get_absolute_size_z() - EXTENSION_HOUSING_SIZE[2], + ), + ) + + # -- what the device carries ------------------------------------------------------------ + # Read through: the optional ones do not exist until discovery says what is fitted. + + @property + def left_x_arm(self) -> Optional[XArm]: + """The left X-arm, on a device that has one.""" + return self.driver.left_x_arm + + @property + def right_x_arm(self) -> Optional[XArm]: + """The right X-arm, on a device that has one.""" + return self.driver.right_x_arm + + @property + def x_arm(self) -> XArm: + """The X-arm, on a device that has only one. + + Returns: + The arm, whichever side it is installed on. + + Raises: + RuntimeError: If setup has not run. + ValueError: If the device has more than one arm. + """ + return self.driver.x_arm + + @property + def pipettes(self) -> Optional[Pipettes]: + """The pipetting channels, on a device that has some.""" + return self.driver.pipettes + + @property + def front_cover(self) -> Optional[FrontCover]: + """The front cover, on a device whose configuration has its monitoring installed.""" + return self.driver.front_cover + + @property + def head96(self) -> Optional[Head96]: + """The 96-head, on a device that has one.""" + return self.driver.head96 + + @property + def head384(self) -> Optional[Head384]: + """The 384-head, on a device that has one.""" + return self.driver.head384 + + @property + def iswap(self) -> Optional[iSWAP]: + """The iSWAP, on a device that has one.""" + return self.driver.iswap + + @property + def autoload(self) -> Optional[Autoload]: + """The autoload, on a device that has one.""" + return self.driver.autoload + + # -- session --------------------------------------------------------------- + + async def setup( + self, + skip_device_initialization: bool = False, + skip_pipettes: bool = False, + skip_iswap: bool = False, + skip_head96: bool = False, + skip_head384: bool = False, + skip_autoload: bool = False, + ): + """Bring the device up. + + Args: + skip_device_initialization: as `STARDriver.setup` takes it. + skip_pipettes: as `STARDriver.setup` takes it. + skip_iswap: as `STARDriver.setup` takes it. + skip_head96: as `STARDriver.setup` takes it. + skip_head384: as `STARDriver.setup` takes it. + skip_autoload: as `STARDriver.setup` takes it. + """ + await self.driver.setup( + skip_device_initialization=skip_device_initialization, + skip_pipettes=skip_pipettes, + skip_iswap=skip_iswap, + skip_head96=skip_head96, + skip_head384=skip_head384, + skip_autoload=skip_autoload, + ) + + async def stop(self): + """Put the device down.""" + await self.driver.stop() + + def __str__(self) -> str: + return f"{self.name}({self.driver.__class__.__name__}, {self.deck.num_tracks}-track deck)" + + +# # # # Complete STAR Devices Factory Functions, for convenience in building a configuration. + + +def STAR( + deck: Optional[HamiltonSTARDeck] = None, + simulation: bool = False, + declared_configuration_json: Optional[str] = None, + driver: Optional[STARDriver] = None, + name: str = "Hamilton STAR", + size_x: float = STAR_SIZE_X, + size_y: float = MANUAL_SIZE_Y, + size_z: float = SIZE_Z, + extension_housing: bool = True, + left_side_panel_installed: bool = False, +) -> STARDevice: + """A full-size STAR, on a full-size STAR deck.""" + if deck is None: + deck = STARDeck() + if simulation and driver is None and declared_configuration_json is None: + declared_configuration_json = RECORDING_STAR + if driver is None: + driver = ( + STARSimulationDriver(deck=deck, declared_configuration_json=declared_configuration_json) + if simulation + else STARDriver(deck=deck, declared_configuration_json=declared_configuration_json) + ) + return STARDevice( + deck=deck, + driver=driver, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + extension_housing=extension_housing, + left_side_panel_installed=left_side_panel_installed, + deck_location=STAR_DECK_LOCATION, + model=STAR.__name__, + ) + + +def STARLet( + deck: Optional[HamiltonSTARDeck] = None, + simulation: bool = False, + declared_configuration_json: Optional[str] = None, + driver: Optional[STARDriver] = None, + name: str = "Hamilton STARlet", + size_x: float = STARLET_SIZE_X, + size_y: float = MANUAL_SIZE_Y, + size_z: float = SIZE_Z, + extension_housing: bool = True, + left_side_panel_installed: bool = False, +) -> STARDevice: + """A STARlet, on a STARlet deck.""" + if deck is None: + deck = STARLetDeck() + if simulation and driver is None and declared_configuration_json is None: + declared_configuration_json = RECORDING_STARLET + if driver is None: + driver = ( + STARSimulationDriver(deck=deck, declared_configuration_json=declared_configuration_json) + if simulation + else STARDriver(deck=deck, declared_configuration_json=declared_configuration_json) + ) + return STARDevice( + deck=deck, + driver=driver, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + extension_housing=extension_housing, + left_side_panel_installed=left_side_panel_installed, + deck_location=STARLET_DECK_LOCATION, + model=STARLet.__name__, + ) + + +def STARPlus( + deck: Optional[HamiltonSTARDeck] = None, + simulation: bool = False, + declared_configuration_json: Optional[str] = None, + driver: Optional[STARDriver] = None, + name: str = "Hamilton STARplus", + size_x: float = STARPLUS_SIZE_X, + size_y: float = MANUAL_SIZE_Y, + size_z: float = SIZE_Z, + extension_housing: bool = True, + left_side_panel_installed: bool = False, +) -> STARDevice: + """A STARplus, on a STARplus deck. + + The width is measured on the manufacturer's own model, as the other two frames are. Its deck is + derived, as `STARPlusDeck` works through, because there is no + STARplus here to read one from, so its 78 rails should be confirmed against a real device. + + Returns: + The device, on a STARplus deck. + """ + if deck is None: + deck = STARPlusDeck() + if simulation and driver is None and declared_configuration_json is None: + declared_configuration_json = RECORDING_STARPLUS + if driver is None: + driver = ( + STARSimulationDriver(deck=deck, declared_configuration_json=declared_configuration_json) + if simulation + else STARDriver(deck=deck, declared_configuration_json=declared_configuration_json) + ) + return STARDevice( + deck=deck, + driver=driver, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + extension_housing=extension_housing, + left_side_panel_installed=left_side_panel_installed, + deck_location=STAR_DECK_LOCATION, + model=STARPlus.__name__, + ) diff --git a/pylabrobot/hamilton/star/device_tests.py b/pylabrobot/hamilton/star/device_tests.py new file mode 100644 index 00000000000..71510f007b5 --- /dev/null +++ b/pylabrobot/hamilton/star/device_tests.py @@ -0,0 +1,145 @@ +import dataclasses +import json +import pathlib +import tempfile +import unittest +from typing import cast + +from pylabrobot.hamilton.star.conftest import BARE_X_ARM +from pylabrobot.hamilton.star.device import ( + EXTENSION_HOUSING_SIZE_X, + RECORDING_STAR, + STAR, + STAR_DECK_LOCATION, + STAR_SIZE_X, + STARDevice, + STARLet, +) +from pylabrobot.hamilton.star.driver.configuration import ( + DeviceConfiguration, + read_configuration, + to_jsonable, +) +from pylabrobot.hamilton.star.driver.simulator import STARSimulationDriver +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.hamilton import STARDeck +from pylabrobot.resources.hamilton.hamilton_decks import STAR_NUM_TRACKS, STARLET_NUM_TRACKS + +# The device this package ships a recording of, read through the one reader there is: tests need a +# device to start from, and this is the one they stand in for. +RECORDED_DEVICE = cast(DeviceConfiguration, read_configuration(RECORDING_STAR)["device"]) + + +def declaring(**parts: object) -> str: + """The shipped recording with parts swapped out, written where it can be read back. + + A declaration is read from a file and nothing else, so a test that needs a device no recording + describes writes one. Everything not named here stays as the recorded STAR has it. + + Args: + parts: `device` for the device itself, or a feature name for something the left arm carries. + + Returns: + The path it was written to. + """ + tree = json.loads(pathlib.Path(RECORDING_STAR).read_text()) + for name, part in parts.items(): + if name == "device": + tree["device"] = to_jsonable(part) + else: + tree["arms"]["left"][name] = to_jsonable(part) + written = pathlib.Path(tempfile.mkdtemp()) / "declared.json" + written.write_text(json.dumps(tree)) + return str(written) + + +class TestConstruction(unittest.IsolatedAsyncioTestCase): + """What the device wires up when it is built.""" + + def test_the_device_deck_is_what_gets_modelled(self): + """The deck the device carries is its child and is what the driver models into, whether + the driver was built here or handed in pointing at another deck.""" + star = STAR(simulation=True) + self.assertIs(star.driver.deck, star.deck) + self.assertIn(star.deck, star.children) + + deck = STARDeck() + supplied = STARDevice( + deck=deck, + driver=STARSimulationDriver(deck=STARDeck(), declared_configuration_json=RECORDING_STAR), + ) + self.assertIs(supplied.driver.deck, deck) + + def test_needs_something_to_drive(self): + with self.assertRaises(ValueError): + STARDevice(deck=STARDeck()) + + +class TestFactories(unittest.IsolatedAsyncioTestCase): + """Each factory builds one device, on the deck that device has.""" + + def test_each_factory_builds_its_own_deck(self): + self.assertEqual(STAR(simulation=True).deck.num_tracks, STAR_NUM_TRACKS) + self.assertEqual(STARLet(simulation=True).deck.num_tracks, STARLET_NUM_TRACKS) + + def test_extension_housing_stands_to_the_left(self): + """The housing is a resource beside the chassis, not something that grows the device. + + It bolts to the left, so it sits at a negative x. Growing the device instead would move its + origin, and everything measured from that origin with it. + """ + star = STAR(simulation=True) + self.assertEqual(star.get_absolute_size_x(), STAR_SIZE_X) + self.assertEqual(cast(Coordinate, star.deck.location).x, STAR_DECK_LOCATION.x) + + housing = star.get_resource("left_extension_housing") + self.assertEqual(cast(Coordinate, housing.location).x, -EXTENSION_HOUSING_SIZE_X) + self.assertEqual(housing.get_absolute_size_x(), EXTENSION_HOUSING_SIZE_X) + + def test_extension_housing_is_fitted_unless_declined(self): + def fitted(star): + return any(child.name == "left_extension_housing" for child in star.children) + + self.assertTrue(fitted(STAR(simulation=True))) + self.assertFalse(fitted(STAR(simulation=True, extension_housing=False))) + + +class TestCapabilities(unittest.IsolatedAsyncioTestCase): + """The device reads its features through the driver, which builds only what discovery + found. A feature the device does not report is None rather than an object that cannot work.""" + + async def test_reads_through_to_the_driver(self): + star = STAR(simulation=True) + await star.setup() + for name in ( + "pipettes", + "head96", + "head384", + "iswap", + "autoload", + "left_x_arm", + "right_x_arm", + ): + self.assertIs(getattr(star, name), getattr(star.driver, name), name) + + async def test_absent_capabilities_are_none(self): + # An arm that carries nothing: what the device reports at device level and what each + # arm reports about itself agree on a real one, so the fixture makes them agree here. + bare = dataclasses.replace( + RECORDED_DEVICE, + num_pip_channels=0, + head96_installed=False, + autoload_installed=False, + left_arm=BARE_X_ARM, + right_arm=None, + ) + star = STARDevice( + deck=STARDeck(), + driver=STARSimulationDriver( + deck=STARDeck(), + declared_configuration_json=declaring(device=bare), + ), + ) + await star.setup() + for name in ("pipettes", "head96", "head384", "autoload", "right_x_arm", "front_cover"): + self.assertIsNone(getattr(star, name), name) diff --git a/pylabrobot/hamilton/star/driver/__init__.py b/pylabrobot/hamilton/star/driver/__init__.py index e69de29bb2d..ebd9be95af2 100644 --- a/pylabrobot/hamilton/star/driver/__init__.py +++ b/pylabrobot/hamilton/star/driver/__init__.py @@ -0,0 +1,3 @@ +"""The STAR driver.""" + +from .errors import STARFirmwareError, STARModuleError diff --git a/pylabrobot/hamilton/star/driver/configuration.py b/pylabrobot/hamilton/star/driver/configuration.py new file mode 100644 index 00000000000..8d60341d464 --- /dev/null +++ b/pylabrobot/hamilton/star/driver/configuration.py @@ -0,0 +1,261 @@ +import dataclasses +import datetime +import json +import typing +from dataclasses import dataclass +from typing import Any, Dict, Optional, Union + +from pylabrobot.hamilton.star.driver.features.autoload import AutoloadConfiguration +from pylabrobot.hamilton.star.driver.features.head96 import Head96Configuration +from pylabrobot.hamilton.star.driver.features.head384 import Head384Configuration +from pylabrobot.hamilton.star.driver.features.iswap import iSWAPConfiguration +from pylabrobot.hamilton.star.driver.features.pipettes import PipettesConfiguration +from pylabrobot.hamilton.star.driver.features.x_arm import XArmConfiguration + + +@dataclass +class DeviceConfiguration: + """The device's installed hardware and geometry. + + Holds both halves of what the master answers about the device it is on: the device-configuration + fields and the extended-configuration fields. + """ + + # -- which device this is, and what it is running -- + serial_number: Optional[str] = None + """What the device calls itself. Distinct from the USB serial a driver picks a device off the bus + with: this is what the device answers.""" + firmware_version: Optional[str] = None + """The master's firmware version.""" + firmware_date: Optional[datetime.date] = None + """The date in that version.""" + + # kb byte (configuration data 1) + pip_type_1000ul: bool = False + """Bit 0: PIP Type. False = 300ul, True = 1000ul.""" + kb_iswap_installed: bool = False + """Bit 1: ISWAP. False = none, True = installed.""" + main_front_cover_monitoring_installed: bool = False + """Bit 2: Main front cover monitoring. False = none, True = installed.""" + autoload_installed: bool = False + """Bit 3: Autoload. False = none, True = installed.""" + wash_station_1_installed: bool = False + """Bit 4: Wash station 1. False = none, True = installed.""" + wash_station_2_installed: bool = False + """Bit 5: Wash station 2. False = none, True = installed.""" + temp_controlled_carrier_1_installed: bool = False + """Bit 6: Temperature controlled carrier 1. False = none, True = installed.""" + temp_controlled_carrier_2_installed: bool = False + """Bit 7: Temperature controlled carrier 2. False = none, True = installed.""" + + num_pip_channels: int = 0 + """Number of PIP channels (kp). Range: 0..16.""" + + # ka (configuration data 2, 24-bit) + left_x_drive_large: bool = False + """Bit 0: Left X drive. False = small, True = large.""" + head96_installed: bool = False + """Bit 1: 96-head. False = none, True = installed.""" + right_x_drive_large: bool = False + """Bit 2: Right X drive. False = small, True = large.""" + pump_station_1_installed: bool = False + """Bit 3: Pump station 1. False = none, True = installed.""" + pump_station_2_installed: bool = False + """Bit 4: Pump station 2. False = none, True = installed.""" + wash_station_1_type_cr: bool = False + """Bit 5: Type wash station 1. False = G3, True = CR.""" + wash_station_2_type_cr: bool = False + """Bit 6: Type wash station 2. False = G3, True = CR.""" + left_cover_installed: bool = False + """Bit 7: Left cover. False = none, True = installed.""" + right_cover_installed: bool = False + """Bit 8: Right cover. False = none, True = installed.""" + additional_front_cover_monitoring_installed: bool = False + """Bit 9: Additional front cover monitoring. False = none, True = installed.""" + pump_station_3_installed: bool = False + """Bit 10: Pump station 3. False = none, True = installed.""" + multi_channel_nano_pipettor_installed: bool = False + """Bit 11: Multi channel nano pipettor. False = none, True = installed.""" + head384_installed: bool = False + """Bit 12: 384 dispensing head. False = none, True = installed.""" + xl_channels_installed: bool = False + """Bit 13: XL channels. False = none, True = installed.""" + tube_gripper_installed: bool = False + """Bit 14: Tube gripper. False = none, True = installed.""" + waste_direction_left: bool = False + """Bit 15: Waste direction. False = right, True = left.""" + iswap_gripper_wide: bool = False + """Bit 16: iSWAP gripper size. False = small, True = wide.""" + additional_channel_nano_pipettor_installed: bool = False + """Bit 17: Additional channel nano pipettor. False = none, True = installed.""" + imaging_channel_installed: bool = False + """Bit 18: Imaging channel. False = none, True = installed.""" + robotic_channel_installed: bool = False + """Bit 19: Robotic channel. False = none, True = installed.""" + channel_order_ox_first: bool = False + """Bit 20: Channel order. False = XL first, True = OX first.""" + x0_interface_ham_can: bool = False + """Bit 21: X0 interface. False = other, True = Ham CAN.""" + park_heads_with_iswap_off: bool = False + """Bit 22: Park heads with iSWAP. False = on, True = off.""" + + # ke (configuration data 3, 32-bit) + configuration_data_3: int = 0 + """Raw configuration data 3 (ke, 32-bit). Bit definitions are undocumented.""" + + instrument_size_slots: int = 54 + """Device size in slots, X range (xt). Default: 54.""" + autoload_size_slots: int = 54 + """Autoload size in slots (xa). Default: 54.""" + tip_waste_x_position: float = 1340.0 + """Tip waste X-position [mm] (xw). Default: 1340.0.""" + left_arm: Optional[XArmConfiguration] = None + """Left X-arm configuration (xl + xn).""" + right_arm: Optional[XArmConfiguration] = None + """Right X-arm configuration (xr + xo), or None when no right arm is installed.""" + min_iswap_collision_free_position: float = 350.0 + """Minimal iSWAP collision free position for direct X access [mm] (xm). Default: 350.0.""" + max_iswap_collision_free_position: float = 1140.0 + """Maximal iSWAP collision free position for direct X access [mm] (xx). Default: 1140.0.""" + left_x_arm_width: float = 370.0 + """Width of left X arm [mm] (xu). Default: 370.0.""" + right_x_arm_width: float = 370.0 + """Width of right X arm [mm] (xv). Default: 370.0.""" + num_xl_channels: int = 0 + """Number of XL channels (kc). Range: 0..8.""" + num_robotic_channels: int = 0 + """Number of Robotic channels (kr). Range: 0..8.""" + min_raster_pitch_pip_channels: float = 9.0 + """Minimal raster pitch of PIP channels [mm] (ys). Default: 9.0.""" + min_raster_pitch_xl_channels: float = 36.0 + """Minimal raster pitch of XL channels [mm] (kl). Default: 36.0.""" + min_raster_pitch_robotic_channels: float = 36.0 + """Minimal raster pitch of Robotic channels [mm] (km). Default: 36.0.""" + pip_maximal_y_position: float = 606.5 + """PIP maximal Y position [mm] (ym). Default: 606.5.""" + left_arm_min_y_position: float = 6.0 + """Left arm minimal Y position [mm] (yu). Default: 6.0.""" + right_arm_min_y_position: float = 6.0 + """Right arm minimal Y position [mm] (yx). Default: 6.0.""" + + +# -- reading and writing these as JSON ------------------------------------------------------------ +# JSON loses three things these configurations rely on: a tuple comes back a list, a dict key comes +# back a string, and a date comes back its own text. What each field is declared to be is enough to +# put all three back, so writing is `dataclasses.fields` and reading is the same walk against the +# declared types. + + +def to_jsonable(value: Any) -> Any: + """The value as JSON holds it. + + Args: + value: what to convert - a configuration, or anything one holds. + + Returns: + The same value in types `json.dump` accepts. + """ + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return { + field.name: to_jsonable(getattr(value, field.name)) for field in dataclasses.fields(value) + } + if isinstance(value, datetime.date): + return value.isoformat() + if isinstance(value, (list, tuple)): + return [to_jsonable(item) for item in value] + if isinstance(value, dict): + # Keys are written as text because JSON has no other kind. What they were is on the field. + return {str(key): to_jsonable(item) for key, item in value.items()} + return value + + +def _restore(hint: Any, value: Any) -> Any: + """One value, back in the type its field is declared to hold. + + Args: + hint: the declared type. + value: the value as JSON held it. + + Returns: + The value in the declared type. + """ + if value is None: + return None + + origin = typing.get_origin(hint) + args = typing.get_args(hint) + + if origin is Union: # Optional[X] is Union[X, None]; the None case returned above. + declared = [arg for arg in args if arg is not type(None)] + return _restore(declared[0], value) if len(declared) == 1 else value + if origin is tuple: + # Fixed-length tuples name a type per position; `Tuple[X, ...]` names one for all of them. + if len(args) == 2 and args[1] is Ellipsis: + return tuple(_restore(args[0], item) for item in value) + return tuple(_restore(arg, item) for arg, item in zip(args, value)) + if origin is list: + return [_restore(args[0], item) for item in value] + if origin is dict: + key_hint, value_hint = args + return {_restore(key_hint, key): _restore(value_hint, item) for key, item in value.items()} + if hint is int and isinstance(value, str): + # A dict keyed by int: JSON wrote the key as text, and the field says what it was. + return int(value) + if hint is datetime.date: + return datetime.date.fromisoformat(value) + if dataclasses.is_dataclass(hint) and isinstance(hint, type): + # A nested configuration: rebuilt field by field against what its own class declares. Names the + # class does not have are left out, so a file written by a driver that has since dropped a + # field still loads. + field_types = typing.get_type_hints(hint) + named = {field.name for field in dataclasses.fields(hint)} + return hint(**{n: _restore(field_types[n], v) for n, v in value.items() if n in named}) + return value + + +# What each name in a saved configuration is, so reading one back knows what to build. A feature an +# arm carries is looked up in the first; one fitted to the device itself in the second. +ARM_FEATURE_CONFIGURATIONS: Dict[str, type] = { + "pipettes": PipettesConfiguration, + "head96": Head96Configuration, + "head384": Head384Configuration, + "iswap": iSWAPConfiguration, +} +DEVICE_FEATURE_CONFIGURATIONS: Dict[str, type] = { + "autoload": AutoloadConfiguration, +} + + +def read_configuration(path: str) -> Dict[str, Any]: + """Read a saved configuration back into the dataclasses it was written from. + + Shaped as the device is: the device's own configuration, the features each arm carries under the + side that carries them, and the features fitted to the device itself beside them. Nothing here + says how many of anything there may be, so a device that grows a second head reads back without + this having to change. + + Args: + path: a file `STARDriver.save_configuration` wrote. + + Returns: + `{"device": DeviceConfiguration, "arms": {side: {name: configuration}}, : configuration}`. + Names this driver does not know are left out. + """ + with open(path, encoding="utf-8") as f: + saved = json.load(f) + + read: Dict[str, Any] = {} + if "device" in saved: + read["device"] = _restore(DeviceConfiguration, saved["device"]) + read["arms"] = { + side: { + name: _restore(ARM_FEATURE_CONFIGURATIONS[name], value) + for name, value in carried.items() + if name in ARM_FEATURE_CONFIGURATIONS + } + for side, carried in saved.get("arms", {}).items() + } + for name, configuration in DEVICE_FEATURE_CONFIGURATIONS.items(): + if name in saved: + read[name] = _restore(configuration, saved[name]) + return read diff --git a/pylabrobot/hamilton/star/driver/errors.py b/pylabrobot/hamilton/star/driver/errors.py index cf298c68ff2..48e37a42434 100644 --- a/pylabrobot/hamilton/star/driver/errors.py +++ b/pylabrobot/hamilton/star/driver/errors.py @@ -1,6 +1,7 @@ from abc import ABCMeta from typing import Dict, Optional, Type +from pylabrobot.hamilton.protocol.text.framing import find_error_fields from pylabrobot.resources.errors import ( HasTipError, NoTipError, @@ -529,6 +530,11 @@ class UnknownHamiltonError(STARModuleError): } +def _module_ids(): + """The module identifiers the device can report, in the order the master lists them.""" + return tuple(_MODULE_NAME_BY_ID) + + def _module_id_to_module_name(id_): """Convert a module ID to a module name.""" return _MODULE_NAME_BY_ID.get(id_, "Unknown Module") @@ -812,8 +818,8 @@ def trace_information_to_string(module_identifier: str, trace_information: int) 86: "Gripper drive: Auto adjustment of DMS digital potentiometer not possible", 89: "Gripper drive movement error: drive locked or incremental sensor fault during gripping", 90: "Gripper drive initialized failed", - 91: "iSWAP not initialized. Call STARBackend.initialize_iswap().", - 92: "Gripper drive movement error: drive locked or incremental sensor fault during release", + 91: "Gripper drive not initialized: a movement command was sent before the drive was", + 92: "Gripper drive movement error: drive locked or incremental sensor fault", 93: "Gripper drive movement error: position counter over/underflow", 94: "Plate not found", 96: "Plate not available", @@ -932,3 +938,41 @@ def convert_star_module_error_to_plr_error( return TooLittleVolumeError(error.message) return None + + +# Every module the master may report alongside itself, in the order it lists them in a reply. +STAR_MODULE_ID_LENGTH = 2 +STAR_MASTER_MODULE_ID = "C0" +STAR_OTHER_MODULE_IDS = tuple(m for m in _module_ids() if m != STAR_MASTER_MODULE_ID) + + +def check_fw_string_error(resp: str): + """Raise an error if the firmware response is an error response. + + Raises: + ValueError: if the format string is incompatible with the response. + HamiltonException: if the response contains an error. + """ + + errors_dict = find_error_fields( + resp, + module_id_length=STAR_MODULE_ID_LENGTH, + master_module_id=STAR_MASTER_MODULE_ID, + other_module_ids=STAR_OTHER_MODULE_IDS, + ) + if len(errors_dict) == 0: + return + + he = star_firmware_string_to_error(error_code_dict=errors_dict, raw_response=resp) + + # If there is a faulty parameter error, request which parameter that is. + for module_name, error in he.errors.items(): + if error.message == "Unknown parameter": + # temp. disabled until we figure out how to handle async in parse response (the + # background thread does not have an event loop, and I'm not sure if it should.) + # vp = await self.send_command(module=error.raw_module, command="VP", fmt="vp&&")["vp"] + # he[module_name].message += f" ({vp})" + + he.errors[module_name].message += " (call lh.backend.request_name_of_last_faulty_parameter)" + + raise he diff --git a/pylabrobot/hamilton/star/driver/features/__init__.py b/pylabrobot/hamilton/star/driver/features/__init__.py new file mode 100644 index 00000000000..080c1094274 --- /dev/null +++ b/pylabrobot/hamilton/star/driver/features/__init__.py @@ -0,0 +1,32 @@ +from pylabrobot.hamilton.star.driver.features.autoload import Autoload, AutoloadConfiguration +from pylabrobot.hamilton.star.driver.features.cover import FrontCover, FrontCoverConfiguration +from pylabrobot.hamilton.star.driver.features.head import Head, HeadConfiguration +from pylabrobot.hamilton.star.driver.features.head96 import Head96, Head96Configuration +from pylabrobot.hamilton.star.driver.features.head384 import Head384, Head384Configuration +from pylabrobot.hamilton.star.driver.features.iswap import iSWAP, iSWAPConfiguration +from pylabrobot.hamilton.star.driver.features.pipettes import ( + PipetteConfiguration, + Pipettes, + PipettesConfiguration, +) +from pylabrobot.hamilton.star.driver.features.x_arm import XArm, XArmConfiguration + +__all__ = [ + "Autoload", + "AutoloadConfiguration", + "FrontCover", + "FrontCoverConfiguration", + "Head", + "HeadConfiguration", + "Head96", + "Head96Configuration", + "Head384", + "Head384Configuration", + "PipetteConfiguration", + "Pipettes", + "PipettesConfiguration", + "XArm", + "XArmConfiguration", + "iSWAP", + "iSWAPConfiguration", +] diff --git a/pylabrobot/hamilton/star/driver/features/autoload.py b/pylabrobot/hamilton/star/driver/features/autoload.py new file mode 100644 index 00000000000..f0e4455fafd --- /dev/null +++ b/pylabrobot/hamilton/star/driver/features/autoload.py @@ -0,0 +1,1702 @@ +"""The autoload: the belt and wheel that pull carriers onto the deck and push them back out.""" + +import datetime +import logging +import string +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union, cast + +from pylabrobot.hamilton.protocol.text.framing import parse_firmware_version_date +from pylabrobot.resources.barcode import Barcode1DSymbology, Barcode2DSymbology +from pylabrobot.resources.carrier import Carrier +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.hamilton.hamilton_decks import track_for_x_coordinate +from pylabrobot.resources.resource import Resource + +if TYPE_CHECKING: + from pylabrobot.hamilton.star.driver.master import STARDriver + +logger = logging.getLogger(__name__) + +# Where the carrier drive can be sent by name. +YPosition = Literal["loading_tray", "carrier_identification", "deck"] + +ZPosition = Literal["below", "above"] + +ScannerRotation = Literal["vertical", "horizontal", "undefined"] + +# Which way a 2D reader looks. A 1D scanner has no such setting. +ScanDirection = Literal["vertical", "horizontal", "omnidirectional", "vertical and horizontal"] + +# The mask each symbology holds. `ANY 1D` is legacy's wildcard, the seven the master names. +SYMBOLOGY_MASKS_1D: Dict[Barcode1DSymbology, int] = { + "ISBT Standard": 0x01, + "Code 128 (Subset B and C)": 0x02, + "Code 39": 0x04, + "Codebar": 0x08, + "Code 2of5 Interleaved": 0x10, + "UPC A/E": 0x20, + "YESN/EAN 8": 0x40, + "ANY 1D": 0x7F, # bit 7 is left out: the master's table does not name it +} + +# The second mask a 2D reader takes. +SYMBOLOGY_MASKS_2D: Dict[Barcode2DSymbology, int] = { + "Data Matrix": 0x01, + "QR Code": 0x02, + "Maxi Code": 0x04, + "Aztec": 0x08, + "PDF 417": 0x10, + "Micro PDF 417": 0x20, + "GS1 DataBar": 0x40, + "EAN/UCC Comp": 0x80, + "ANY 2D": 0xFF, +} + +BarcodeReadingDirection = Literal["vertical", "horizontal"] + +# What kind of autoload is fitted, by the code the master answers with. Codes outside this are +# variants that have not been seen, and are returned as they came. +AUTOLOAD_TYPES: Dict[int, str] = { + 0: "1D barcode scanner", + 1: "XRP Lite", + 2: "2D barcode scanner", +} + + +def _tracks_from_presence_mask(mask: str) -> List[int]: + """The tracks a carrier-presence mask marks as occupied. + + Args: + mask: the mask as the device writes it, one hexadecimal digit per four tracks, the rightmost + digit holding tracks 1 to 4. + + Returns: + The occupied tracks, counted from 1, in order. + + Raises: + ValueError: If the mask is not hexadecimal. + """ + mask = mask.strip() + if mask == "" or any(character not in string.hexdigits for character in mask): + raise ValueError(f"not a hexadecimal carrier presence mask: {mask!r}") + return [ + digit_index * 4 + bit + 1 + for digit_index, digit in enumerate(reversed(mask)) + for bit in range(4) + if int(digit, 16) & (1 << bit) + ] + + +@dataclass +class AutoloadConfiguration: + """Device facts for the installed autoload. + + Three drives: + - X-drive of the entire autoload sled; + - Y drive (carrier handling wheel), which moves a carriers in and out; + - Z drive (carrier handling wheel), which raises and retracts the handling wheel. + """ + + module: str = "I0" + """What the autoload is on the bus.""" + + firmware_version: Optional[str] = None + firmware_date: Optional[datetime.date] = None + autoload_type: Optional[str] = None # see AUTOLOAD_TYPES + + # -- what each drive can be sent to by name, and the code it takes -- + y_positions: Dict[YPosition, int] = field( + default_factory=lambda: {"loading_tray": 0, "carrier_identification": 1, "deck": 2} + ) + z_positions: Dict[ZPosition, int] = field(default_factory=lambda: {"below": 0, "above": 1}) + scanner_rotations: Dict[ScannerRotation, int] = field( + default_factory=lambda: {"vertical": 0, "horizontal": 1, "undefined": 2} + ) + barcode_reading_directions: Dict[BarcodeReadingDirection, int] = field( + default_factory=lambda: {"vertical": 0, "horizontal": 1} + ) + barcode_symbologies: Optional[Dict[Barcode1DSymbology, int]] = None + """None when this autoload's type names no scanner.""" + barcode_2d_symbologies: Optional[Dict[Barcode2DSymbology, int]] = None + """None on a 1D scanner, which takes neither this mask nor a scan direction.""" + scan_directions: Dict[ScanDirection, int] = field( + default_factory=lambda: { + "vertical": 0, + "horizontal": 1, + "omnidirectional": 2, + "vertical and horizontal": 3, + } + ) + + # -- scanner X drive (along the deck) -- + x_drive_mm_per_increment: float = 0.1 + """How far one step moves the scanner, in mm. Read at discovery: a unit holds either 0.1 or + 0.125 in its own memory, and this default is only right for the units that hold the first.""" + loading_indicators_installed: Optional[bool] = None + + initialization_track: Optional[int] = None + """The track the X drive homes against, counted from 1. Where the sled initializes, not where it + is: `request_carrier_presence`'s track is the live one. Filled by `request_init_slot`.""" + + adjustment_date: Optional[datetime.date] = None + """When this module was last adjusted, as it reports it. Filled by + `request_adjustment_status`.""" + adjusted: Optional[bool] = None + """Whether the module considers itself adjusted. An unadjusted one's stored values are factory + defaults rather than this unit's own. Filled by `request_adjustment_status`.""" + """Whether this autoload has the per-track indicator LEDs. Read at discovery.""" + drive_zero_on_the_deck: float = 100.0 + """Where the drive counts from, on the deck: track 1, a hundred millimetres along it.""" + reference_point_from_sled_left_edge: float = 73.7 + """Where on the sled the drive's position refers to, as a distance from the sled's left edge in + mm. The drive reports the carrier-handling wheels, and their left edge stands this far along the + sled; it lines up with a track's origin, not its centre. + + Measured on the sled as modelled: the wheels are a pair of discs 7.1 mm thick, one at each face, + spanning 73.68 to 80.78 mm from the part's left edge. They turn about x, which is the axis a + carrier is drawn in along. + + This is a distance from the part's left edge, so it only means anything against the part it was + measured on. The 109.0 it replaces is the same feature measured on the manufacturer's model, + whose left end carried a thin tab the sled does not have - 35.3 mm of one, which is exactly what + separates the two readings. The 20.0 before that was measured on a narrower part again.""" + x_drive_range_increments: Tuple[int, int] = (0, 12_500) + x_drive_speed_range_increments: Tuple[int, int] = (20, 3_000) # steps per second + x_drive_speed_default: int = 2_500 + x_drive_acceleration_ramp_range: Tuple[int, int] = (1, 3) + x_drive_acceleration_ramp_default: int = 3 + + # -- carrier Z drive (handling wheel; the handling wheel, down or up) -- + z_drive_mm_per_increment: float = 0.004166666666666667 + z_drive_range_increments: Tuple[int, int] = (0, 3_000) + z_drive_speed_range_increments: Tuple[int, int] = (20, 2_000) + z_drive_speed_default: int = 1_750 + z_drive_acceleration_ramp_range: Tuple[int, int] = (1, 4) + z_drive_acceleration_ramp_default: int = 4 + + # -- carrier Y drive (handling wheel; in and out of the deck) -- + y_drive_mm_per_increment: float = 0.06404424 + y_drive_range_increments: Tuple[int, int] = (0, 9_999) + y_drive_speed_range_increments: Tuple[int, int] = (20, 2_500) + y_drive_speed_default: int = 2_000 + y_drive_acceleration_ramp_range: Tuple[int, int] = (1, 6) + y_drive_acceleration_ramp_default: int = 6 + + # -- shared by all three drives -- + motor_current_limit_range: Tuple[int, int] = (0, 7) # same for every drive + motor_current_limit_default: int = 7 + acceleration_ramp_increments_per_second_squared: int = 2_500 + + # -- conversions: the wire counts in steps, the driver speaks mm --------------------------- + + def x_drive_increments_to_mm(self, increments: int) -> float: + """How far along the deck the scanner is, in mm, from the steps the drive counts in.""" + return round(increments * self.x_drive_mm_per_increment, 2) + + def x_drive_mm_to_increments(self, mm: float) -> int: + """A scanner position in steps, from mm.""" + return round(mm / self.x_drive_mm_per_increment) + + def to_deck_frame(self, mm: float) -> float: + """A position the X drive reports, in the deck's frame. + + The X drive is the one thing here that does not count in the deck's coordinates: its zero sits + `drive_zero_on_the_deck` along the deck. Every crossing between the two frames goes through + here and `from_deck_frame`, so the offset is applied in one place. The other drives, and the + other features, need no such conversion - their axes are the deck's. + + Returns: + The same position in the deck's frame, in mm. + """ + return round(mm + self.drive_zero_on_the_deck, 2) + + def from_deck_frame(self, mm: float) -> float: + """A deck position, in the frame the X drive counts in.""" + return round(mm - self.drive_zero_on_the_deck, 2) + + def z_drive_increments_to_mm(self, increments: int) -> float: + """How high the handling wheel is, in mm, from steps.""" + return round(increments * self.z_drive_mm_per_increment, 2) + + def z_drive_mm_to_increments(self, mm: float) -> int: + """A wheel position in steps, from mm.""" + return round(mm / self.z_drive_mm_per_increment) + + def y_drive_increments_to_mm(self, increments: int) -> float: + """How far in or out a carrier is, in mm, from the steps the drive counts in.""" + return round(increments * self.y_drive_mm_per_increment, 2) + + def y_drive_mm_to_increments(self, mm: float) -> int: + """A carrier position in steps, from mm.""" + return round(mm / self.y_drive_mm_per_increment) + + +class Autoload: + """The autoload. + + Reached as `driver.autoload`, on a device that has one. + """ + + def __init__(self, driver: "STARDriver", configuration: Optional[AutoloadConfiguration] = None): + """ + Args: + driver: the driver to send commands through. + configuration: the autoload's device facts. Defaults to `AutoloadConfiguration()`. + """ + self._driver = driver + # The sled on the deck, when the driver was given one. Setup puts it there; moves keep it in + # step. Without a deck it stays None and nothing is modelled. + self.resource: Optional[Resource] = None + self.configuration = configuration or AutoloadConfiguration() + + @property + def track_range(self) -> range: + """The tracks it can be moved to, one for each slot the device has. + + Returns: + Every track this device has, counted from 1. + + Raises: + RuntimeError: If setup has not run, so the deck size is not known. + """ + if self._driver.configuration is None: + raise RuntimeError("no configuration read; have you called `star.setup()`?") + return range(1, self._driver.configuration.instrument_size_slots + 1) + + # -- session / discovery ------------------------------------------------------------------------- + + async def request_firmware_version(self) -> Tuple[str, datetime.date]: + """Request the autoload's firmware version and build date. + + Returns: + The version string and its build date. + """ + resp: str = await self._driver.send_command(module="I0", command="RF") + return resp.split("rf")[-1], parse_firmware_version_date(resp) + + async def request_autoload_type(self) -> str: + """Request which kind of autoload is fitted. + + Returns: + What it is, as named in `AUTOLOAD_TYPES`, or the code it answered with when that is not one + of them. + """ + resp = await self._driver.send_command(module="C0", command="CQ", subsystem="I0", fmt="cq#") + code = cast(int, resp["cq"]) + return AUTOLOAD_TYPES.get(code, str(code)) + + async def request_adjustment_status(self) -> Tuple[datetime.date, bool]: + """Request when this autoload was adjusted, and whether it has been. + + Records both on the configuration. + + Returns: + The date of the adjustment, and whether the module considers itself adjusted. An unadjusted + module's stored values are factory defaults rather than this unit's own. + """ + resp = await self._driver.send_command(module="I0", command="RJ", fmt="jd&&&&&&&&&&js#") + c = self.configuration + c.adjustment_date = datetime.date.fromisoformat(cast(str, resp["jd"])) + c.adjusted = cast(int, resp["js"]) == 1 + return c.adjustment_date, c.adjusted + + async def request_init_slot(self) -> int: + """Request the track the X drive initializes against. + + Records it on the configuration. + + Returns: + The track, counted from 1. + """ + resp = await self._driver.send_command(module="I0", command="QX", fmt="bx##") + self.configuration.initialization_track = cast(int, resp["bx"]) + return self.configuration.initialization_track + + async def request_adjustment_values(self) -> str: + """Request every adjustment value the module stores, as it writes them. + + Holds each drive's initialization position and the motor PWM tables. Returned unparsed: how + many fields come back varies by unit, as the 96-head's equivalent read showed, and the point of + this is to see what a unit actually holds. + + Returns: + The reply, as the module wrote it. + """ + return cast(str, await self._driver.send_command(module="I0", command="RK")) + + async def request_module_configuration(self) -> Tuple[float, bool]: + """Request what this autoload is built with: its scanner's step size, and its indicators. + + The step size is the one thing here that differs between units and cannot be derived, so it is + read rather than assumed. + + Returns: + How far one scanner step moves it, in mm, and whether the loading indicators are fitted. + """ + resp = await self._driver.send_command(module="I0", command="RA", ra="au", fmt="au# (n)") + configuration = cast(List[int], resp["au"]) + return 0.1 if configuration[0] == 0 else 0.125, configuration[1] == 0 + + async def request_parameter(self, parameter: str) -> str: + """Request one of the parameters the module stores, by name. + + The way the iSWAP's predefined-position tables are read, and the 96-head's drive parameters. + Returned unparsed: each name has its own shape, and this exists to see what a unit holds rather + than to drive it. + + Args: + parameter: the two-letter name, as the module's own command set writes it. + + Returns: + The reply, as the module wrote it. + """ + return cast(str, await self._driver.send_command(module="I0", command="RA", ra=parameter)) + + async def request_initialization_status(self) -> bool: + """Request whether the autoload reports itself initialized. + + Returns: + Whether it is initialized. It reports itself uninitialized again once the device's own + initialization has run. + """ + resp = await self._driver.send_command(module="I0", command="QW", fmt="qw#") + return cast(int, resp["qw"]) == 1 + + async def discover(self): + """Read what autoload this is. Read-only: nothing moves.""" + c = self.configuration + c.firmware_version, c.firmware_date = await self.request_firmware_version() + c.autoload_type = await self.request_autoload_type() + ( + c.x_drive_mm_per_increment, + c.loading_indicators_installed, + ) = await self.request_module_configuration() + # Both scanners read the 1D symbologies; only the 2D one also reads the 2D ones. An autoload + # that is neither has no scanner, and its symbologies stay unset. + if c.autoload_type in ("1D barcode scanner", "2D barcode scanner"): + c.barcode_symbologies = SYMBOLOGY_MASKS_1D + if c.autoload_type == "2D barcode scanner": + c.barcode_2d_symbologies = SYMBOLOGY_MASKS_2D + + # -- initialization ------------------------------------------------------------------------------ + + async def initialize(self, park_after: bool = True): + """Initialize the autoload and everything else that makes it operational. This moves it. + + Homing is skipped when it already reports itself initialized, so this can be called on any + device. The rest runs either way: the wheel goes to its safe Z. + + Reporting itself uninitialized after the device procedure has run is the device's + behaviour rather than a failed initialization: across 182 recorded runs it reported itself + initialized in every run where the procedure was skipped, and uninitialized in 60 of the 61 + where it ran. + + Args: + park_after: whether to park it once it is up, leaving it clear of the deck. + """ + if not await self.request_initialization_status(): + logger.debug("autoload reports itself uninitialized - homing its drives") + await self._send_command_and_update_sled_x(module="C0", command="II", subsystem="I0") + await self.wheel_move_to_safe_z() + + if park_after: + logger.debug("parking the autoload after initialization") + await self.park() + + # -- scanner X drive (along the deck) ------------------------------------------------------------ + + async def _record_where_it_stopped(self, axis: Literal["x", "y", "z"]) -> Optional[float]: + """Read where a drive came to rest, and record it. + + For a move's `finally`. A move that stopped part way left the drive somewhere no target + describes. Its own failure is logged and swallowed: it must not replace the move's exception, + which is the one that says what went wrong. + + Args: + axis: which drive the move drove - `x` the sled along the deck, `y` the carrier drive in and + out, `z` the carrier-handling wheel up and down. + + Returns: + Where the drive is, in mm, or None if it could not be read. + """ + try: + if axis == "x": + return await self.request_x_position() + if axis == "y": + return await self.wheel_request_y_position() + return await self.wheel_request_z_position() + except Exception: + logger.warning("could not read where the autoload stopped along %s; its model is stale", axis) + return None + + async def _raise_wheel_after_failed_move(self) -> None: + """Send the carrier-handling wheel to its safe Z after a sled move failed, unless it is there. + + For a move's `except`. A move that failed is no evidence of where the wheel stands, so it is + read: at the start of its Z travel it is already safe and is left alone, anywhere else it is + raised. A read that fails says nothing either, so the wheel is raised then too. + + Its own failure is logged and swallowed: it must not replace the move's exception, which is the + one that says what went wrong. + """ + try: + at_safe_z = await self.wheel_is_at_safe_z() + except Exception: + logger.warning("could not read the autoload's wheel height after a failed move; raising it") + at_safe_z = False + if at_safe_z: + return + try: + await self.wheel_move_to_safe_z() + except Exception: + logger.warning("could not raise the autoload's wheel to safe Z after a failed move") + + async def _send_command_and_update_sled_x(self, **kwargs: Any) -> Any: + """Send a command that moves the sled, then read back where along X it ended up. + + For the sled-moving commands that do not go through `move_to_track`, which keeps the model in + step itself. Every command that shifts the sled has to go through one or the other, or the + resource silently drifts from the device. + + Read afterwards either way: a command that failed part way leaves the sled somewhere neither + where it was nor where it was going, which is exactly when the model must not be trusted to + have stayed put. If that read also fails, the command's own error is the one to raise. + + Args: + kwargs: what to send, as `send_command` takes it. + """ + try: + return await self._driver.send_command(**kwargs) + finally: + # Whether the move succeeded or not: one that stopped part way left the sled somewhere no + # target describes, and this read is also how a successful move is recorded. + await self._record_where_it_stopped("x") + + async def request_track(self) -> int: + """Request the current track of the autoload's carrier handler. + + Returns: + The track, counted from 1, or 0 when it is at neither end of a track. + """ + resp = await self._driver.send_command(module="C0", command="QA", fmt="qa##") + return cast(int, resp["qa"]) + + async def request_x_position(self) -> float: + """Request where along the deck the scanner is. + + What the drive answers is recorded on the resource that models the sled. + + Returns: + The position along the deck, in mm. + """ + c = self.configuration + x = c.to_deck_frame( + c.x_drive_increments_to_mm(await self._request_drive_position("RX", digits=5)) + ) + self.update_location_by_reference_point(x) + return x + + def update_location_by_reference_point(self, x: float) -> None: + """Record where the sled is on the resource that models it. + + What the drive reports is where the carrier-handling wheel stands. The sled is placed around + the wheel, which sits back from its left edge. Does nothing when the driver was given no deck. + + Args: + x: where the wheel is along the deck, in mm. + """ + if self.resource is None or self.resource.location is None: + return + c = self.configuration + self.resource.location = Coordinate( + x - c.reference_point_from_sled_left_edge, + self.resource.location.y, + self.resource.location.z, + ) + + async def _request_drive_position(self, command: str, digits: int) -> int: + """Where one of the three drives is, in the steps it counts in. + + Each answers with two counters: the one the firmware keeps, and the one read off the hardware. + The hardware counter is the one returned. + + Args: + command: the read to send, which names the drive. + digits: how many digits each counter is written with. + + Returns: + The hardware counter, in the drive's own steps. + """ + field = command.lower() + resp = await self._driver.send_command( + module="I0", command=command, fmt=f"{field}{'#' * digits} (n)" + ) + _firmware_counter, hardware_counter = cast(List[int], resp[field]) + return hardware_counter + + def _check_reachable(self, axis: Literal["x", "y", "z"], value: float) -> None: + """Raise if a drive cannot be sent where it is being asked to go. + + Args: + axis: which drive - `x` the sled along the deck, `y` the handling wheel in and out, `z` the + handling wheel up and down. + value: where it would be sent, in mm. + + Raises: + ValueError: If the drive's travel does not reach it. + """ + c = self.configuration + if axis == "x": + low, high = c.x_drive_range_increments + low_mm = c.to_deck_frame(c.x_drive_increments_to_mm(low)) + high_mm = c.to_deck_frame(c.x_drive_increments_to_mm(high)) + increments = c.x_drive_mm_to_increments(c.from_deck_frame(value)) + elif axis == "y": + low, high = c.y_drive_range_increments + low_mm, high_mm = c.y_drive_increments_to_mm(low), c.y_drive_increments_to_mm(high) + increments = c.y_drive_mm_to_increments(value) + else: + low, high = c.z_drive_range_increments + low_mm, high_mm = c.z_drive_increments_to_mm(low), c.z_drive_increments_to_mm(high) + increments = c.z_drive_mm_to_increments(value) + if not low <= increments <= high: + raise ValueError(f"{axis} must be between {low_mm} and {high_mm} mm, is {value}") + + async def move_to_track( + self, + track: int, + speed: Optional[float] = None, + acceleration_ramp: Optional[int] = None, + current_limit: Optional[int] = None, + ): + """Move the autoload to a specific track position, raising the wheel first. + + Args: + track: which track to move to, counted from 1. + speed: how fast to travel, in mm/s. Defaults to + `configuration.x_drive_speed_default`. + acceleration_ramp: how hard to accelerate, in multiples of + `configuration.acceleration_ramp_increments_per_second_squared`. Defaults to + `configuration.x_drive_acceleration_ramp_default`. + current_limit: the motor current limit. Defaults to + `configuration.motor_current_limit_default`. + Raises: + ValueError: If the track is not one this device has, or an argument is outside what the + drive accepts. + RuntimeError: If setup has not run. + """ + c = self.configuration + tracks = self.track_range + + # -- precondition checks ---------------------------------------------------------------------- + if track not in tracks: + raise ValueError(f"track must be between {tracks[0]} and {tracks[-1]}, is {track}") + + # -- parameter resolution ---------------------------------------------------------------------- + speed = c.x_drive_increments_to_mm(c.x_drive_speed_default) if speed is None else speed + acceleration_ramp = ( + c.x_drive_acceleration_ramp_default if acceleration_ramp is None else acceleration_ramp + ) + current_limit = c.motor_current_limit_default if current_limit is None else current_limit + + # -- parameter validation ---------------------------------------------------------------------- + low, high = c.x_drive_speed_range_increments + speed_increments = c.x_drive_mm_to_increments(speed) + if not low <= speed_increments <= high: + raise ValueError( + f"speed must be between {c.x_drive_increments_to_mm(low)} and " + f"{c.x_drive_increments_to_mm(high)} mm/s, is {speed}" + ) + + low, high = c.x_drive_acceleration_ramp_range + if not low <= acceleration_ramp <= high: + raise ValueError( + f"acceleration_ramp must be between {low} and {high}, is {acceleration_ramp}" + ) + + low, high = c.motor_current_limit_range + if not low <= current_limit <= high: + raise ValueError(f"current_limit must be between {low} and {high}, is {current_limit}") + + # -- device preparation ---------------------------------------------------------------------- + if not await self.wheel_is_at_safe_z(): + logger.debug("retracting the handling wheel to its safe Z before moving to track %d", track) + await self.wheel_move_to_safe_z() + + try: + return await self._send_command_and_update_sled_x( + module="I0", + command="XP", + xp=f"{track:02}", + xv=f"{speed_increments:04}", + xr=f"{acceleration_ramp:01}", + xw=f"{current_limit:01}", + ) + except Exception: + await self._raise_wheel_after_failed_move() + raise + + async def move_x( + self, + x: float, + speed: Optional[float] = None, + acceleration_ramp: Optional[int] = None, + current_limit: Optional[int] = None, + ): + """Move the sled along the deck to a position, raising the wheel first. + + Where `move_to_track` can only reach the tracks, this reaches anywhere between them. + + Args: + x: where to send the wheel along the deck, in mm, as `request_x_position` reports it. + speed: how fast to travel, in mm/s. Defaults to + `configuration.x_drive_speed_default`. + acceleration_ramp: how hard to accelerate, in multiples of + `configuration.acceleration_ramp_increments_per_second_squared`. Defaults to + `configuration.x_drive_acceleration_ramp_default`. + current_limit: the motor current limit. Defaults to + `configuration.motor_current_limit_default`. + Raises: + ValueError: If the position is outside the drive's travel, or an argument is outside what the + drive accepts. + """ + c = self.configuration + + # -- parameter resolution ---------------------------------------------------------------------- + speed = c.x_drive_increments_to_mm(c.x_drive_speed_default) if speed is None else speed + acceleration_ramp = ( + c.x_drive_acceleration_ramp_default if acceleration_ramp is None else acceleration_ramp + ) + current_limit = c.motor_current_limit_default if current_limit is None else current_limit + + # -- parameter validation ---------------------------------------------------------------------- + self._check_reachable("x", x) + increments = c.x_drive_mm_to_increments(c.from_deck_frame(x)) + + low, high = c.x_drive_speed_range_increments + speed_increments = c.x_drive_mm_to_increments(speed) + if not low <= speed_increments <= high: + raise ValueError( + f"speed must be between {c.x_drive_increments_to_mm(low)} and " + f"{c.x_drive_increments_to_mm(high)} mm/s, is {speed}" + ) + + low, high = c.x_drive_acceleration_ramp_range + if not low <= acceleration_ramp <= high: + raise ValueError( + f"acceleration_ramp must be between {low} and {high}, is {acceleration_ramp}" + ) + + low, high = c.motor_current_limit_range + if not low <= current_limit <= high: + raise ValueError(f"current_limit must be between {low} and {high}, is {current_limit}") + + # -- device preparation ---------------------------------------------------------------------- + if not await self.wheel_is_at_safe_z(): + logger.debug("retracting the handling wheel to its safe Z before moving to %.3f mm", x) + await self.wheel_move_to_safe_z() + + try: + return await self._send_command_and_update_sled_x( + module="I0", + command="XA", + xa=f"{increments:05}", + xv=f"{speed_increments:04}", + xr=f"{acceleration_ramp:01}", + xw=f"{current_limit:01}", + ) + except Exception: + await self._raise_wheel_after_failed_move() + raise + + async def move_x_relative( + self, + distance: float, + speed: Optional[float] = None, + acceleration_ramp: Optional[int] = None, + current_limit: Optional[int] = None, + ): + """Move the sled by a distance from where it is now. + + Where the sled is is read from the device and the distance added to it, so a relative move is + an absolute move to a place worked out here - and is bounded by the drive's travel like any + other. + + Args: + distance: how far to move, in mm. Positive moves along the deck towards higher x, negative + back towards the deck's origin. + speed: how fast to travel, in mm/s. Defaults to + `configuration.x_drive_speed_default`. + acceleration_ramp: how hard to accelerate. Defaults to + `configuration.x_drive_acceleration_ramp_default`. + current_limit: the motor current limit. Defaults to + `configuration.motor_current_limit_default`. + Raises: + ValueError: If the sled would end up outside the drive's travel, or an argument is outside + what the drive accepts. + """ + return await self.move_x( + await self.request_x_position() + distance, + speed=speed, + acceleration_ramp=acceleration_ramp, + current_limit=current_limit, + ) + + async def park(self): + """Park the autoload at the last track this device has. + Raises: + RuntimeError: If setup has not run, so the deck size is not known. + """ + return await self.move_to_track(track=self.track_range[-1]) + + # -- Z drive (the carrier handling wheel) -------------------------------------------------------- + + async def wheel_request_z_position(self) -> float: + """Request how high the carrier-handling wheel is. + + Returns: + The position in mm, from the drive's zero. + """ + return self.configuration.z_drive_increments_to_mm( + await self._request_drive_position("RZ", digits=4) + ) + + async def wheel_is_at_safe_z(self) -> bool: + """Request whether the carrier-handling wheel is at its safe Z, the start of its Z travel. + + Returns: + True if the wheel reads the first increment of `z_drive_range_increments`. + """ + return ( + await self._request_drive_position("RZ", digits=4) + == self.configuration.z_drive_range_increments[0] + ) + + async def wheel_move_to_safe_z(self) -> float: + """Move the carrier-handling wheel to its safe Z, and read where that put it. + + Returns: + The wheel's Z position, in mm. + """ + await self._driver.send_command(module="C0", command="IV", subsystem="I0") + return await self.wheel_request_z_position() + + async def _unchecked_fw_move_to_z_position( + self, z: float, speed: float, acceleration_ramp: int, current_limit: int + ): + """Move the carrier-handling wheel to a Z position. Nothing is guarded. + + Args: + z: where to, in mm from the drive's zero. + speed: how fast to travel, in mm/s. + acceleration_ramp: how hard to accelerate. + current_limit: the motor current limit. + """ + c = self.configuration + return await self._driver.send_command( + module="I0", + command="ZA", + za=f"{c.z_drive_mm_to_increments(z):04}", + zv=f"{c.z_drive_mm_to_increments(speed):04}", + zr=f"{acceleration_ramp:01}", + zw=f"{current_limit:01}", + ) + + async def _unchecked_fw_move_to_predefined_z_position( + self, z: ZPosition, speed: float, acceleration_ramp: int, current_limit: int + ): + """Move the carrier-handling wheel to a Z position it knows by name. Nothing is guarded. + + Args: + z: which one: `below` or `above`. + speed: how fast to travel, in mm/s. + acceleration_ramp: how hard to accelerate. + current_limit: the motor current limit. + """ + c = self.configuration + return await self._driver.send_command( + module="I0", + command="ZP", + zp=f"{c.z_positions[z]:01}", + zv=f"{c.z_drive_mm_to_increments(speed):04}", + zr=f"{acceleration_ramp:01}", + zw=f"{current_limit:01}", + ) + + async def wheel_move_to_z_position( + self, + z: Union[float, ZPosition], + speed: Optional[float] = None, + acceleration_ramp: Optional[int] = None, + current_limit: Optional[int] = None, + ) -> float: + """Move the carrier-handling wheel to a Z position. + + Args: + z: where to: in mm from the drive's zero, or one of the positions it knows by name, + `below` or `above`. + speed: how fast to travel, in mm/s. Defaults to + `configuration.z_drive_speed_default`. + acceleration_ramp: how hard to accelerate, in multiples of + `configuration.acceleration_ramp_increments_per_second_squared`. Defaults to + `configuration.z_drive_acceleration_ramp_default`. + current_limit: the motor current limit. Defaults to + `configuration.motor_current_limit_default`. + Returns: + Where the wheel came to rest, in mm, as its drive reads it. + Raises: + ValueError: If the position is outside what the drive reaches or not a name it knows, or an + argument is outside what the drive accepts. + """ + c = self.configuration + if isinstance(z, str): + if z not in c.z_positions: + raise ValueError(f"z must be one of {list(c.z_positions)}, is {z!r}") + else: + self._check_reachable("z", z) + + # Every parameter is sent: what the drive does is written here, not left to it. + speed = c.z_drive_increments_to_mm(c.z_drive_speed_default) if speed is None else speed + acceleration_ramp = ( + c.z_drive_acceleration_ramp_default if acceleration_ramp is None else acceleration_ramp + ) + current_limit = c.motor_current_limit_default if current_limit is None else current_limit + + low, high = c.z_drive_speed_range_increments + speed_increments = c.z_drive_mm_to_increments(speed) + if not low <= speed_increments <= high: + raise ValueError( + f"speed must be between {c.z_drive_increments_to_mm(low)} and " + f"{c.z_drive_increments_to_mm(high)} mm/s, is {speed}" + ) + + low, high = c.z_drive_acceleration_ramp_range + if not low <= acceleration_ramp <= high: + raise ValueError( + f"acceleration_ramp must be between {low} and {high}, is {acceleration_ramp}" + ) + + low, high = c.motor_current_limit_range + if not low <= current_limit <= high: + raise ValueError(f"current_limit must be between {low} and {high}, is {current_limit}") + + try: + if isinstance(z, str): + await self._unchecked_fw_move_to_predefined_z_position( + z, speed, acceleration_ramp, current_limit + ) + else: + await self._unchecked_fw_move_to_z_position(z, speed, acceleration_ramp, current_limit) + finally: + # Whether the move succeeded or not: what the drive reads is where the wheel is. + z_reached = await self._record_where_it_stopped("z") + # The move answered but that read did not: read again, and let this one raise. + return await self.wheel_request_z_position() if z_reached is None else z_reached + + # -- Y drive (handling wheel moving carriers in and out of the deck) ----------------------- + + async def wheel_request_y_position(self) -> float: + """Request how far in or out the carrier drive is. + + Returns: + The position in mm, from the drive's zero. + """ + return self.configuration.y_drive_increments_to_mm( + await self._request_drive_position("RY", digits=4) + ) + + async def _unchecked_fw_move_to_y_position( + self, y: float, speed: float, acceleration_ramp: int, current_limit: int + ): + """Move the carrier drive to a Y position. Nothing is guarded. + + Args: + y: where to, in mm from the drive's zero. + speed: how fast to travel, in mm/s. + acceleration_ramp: how hard to accelerate. + current_limit: the motor current limit. + """ + c = self.configuration + return await self._driver.send_command( + module="I0", + command="YA", + ya=f"{c.y_drive_mm_to_increments(y):04}", + yv=f"{c.y_drive_mm_to_increments(speed):04}", + yr=f"{acceleration_ramp:01}", + yw=f"{current_limit:01}", + ) + + async def _unchecked_fw_move_to_predefined_y_position( + self, y: YPosition, speed: float, acceleration_ramp: int, current_limit: int + ): + """Move the carrier drive to a Y position it knows by name. Nothing is guarded. + + Args: + y: which one: `loading_tray`, `carrier_identification` or `deck`. + speed: how fast to travel, in mm/s. + acceleration_ramp: how hard to accelerate. + current_limit: the motor current limit. + """ + c = self.configuration + return await self._driver.send_command( + module="I0", + command="YP", + yp=f"{c.y_positions[y]:01}", + yv=f"{c.y_drive_mm_to_increments(speed):04}", + yr=f"{acceleration_ramp:01}", + yw=f"{current_limit:01}", + ) + + async def wheel_move_to_y_position( + self, + y: Union[float, YPosition], + speed: Optional[float] = None, + acceleration_ramp: Optional[int] = None, + current_limit: Optional[int] = None, + ) -> float: + """Move the carrier drive to a Y position, pulling a carrier in or pushing it out. + + Args: + y: where to: in mm from the drive's zero, or one of the positions it knows by name, + `loading_tray`, `carrier_identification` or `deck`. + speed: how fast to travel, in mm/s. Defaults to + `configuration.y_drive_speed_default`. + acceleration_ramp: how hard to accelerate, in multiples of + `configuration.acceleration_ramp_increments_per_second_squared`. Defaults to + `configuration.y_drive_acceleration_ramp_default`. + current_limit: the motor current limit. Defaults to + `configuration.motor_current_limit_default`. + Returns: + Where the carrier drive came to rest, in mm, as it reads it. + Raises: + ValueError: If the position is outside what the drive reaches or not a name it knows, or an + argument is outside what the drive accepts. + """ + c = self.configuration + if isinstance(y, str): + if y not in c.y_positions: + raise ValueError(f"y must be one of {list(c.y_positions)}, is {y!r}") + else: + self._check_reachable("y", y) + + speed = c.y_drive_increments_to_mm(c.y_drive_speed_default) if speed is None else speed + acceleration_ramp = ( + c.y_drive_acceleration_ramp_default if acceleration_ramp is None else acceleration_ramp + ) + current_limit = c.motor_current_limit_default if current_limit is None else current_limit + + low, high = c.y_drive_speed_range_increments + speed_increments = c.y_drive_mm_to_increments(speed) + if not low <= speed_increments <= high: + raise ValueError( + f"speed must be between {c.y_drive_increments_to_mm(low)} and " + f"{c.y_drive_increments_to_mm(high)} mm/s, is {speed}" + ) + + low, high = c.y_drive_acceleration_ramp_range + if not low <= acceleration_ramp <= high: + raise ValueError( + f"acceleration_ramp must be between {low} and {high}, is {acceleration_ramp}" + ) + + low, high = c.motor_current_limit_range + if not low <= current_limit <= high: + raise ValueError(f"current_limit must be between {low} and {high}, is {current_limit}") + + try: + if isinstance(y, str): + await self._unchecked_fw_move_to_predefined_y_position( + y, speed, acceleration_ramp, current_limit + ) + else: + await self._unchecked_fw_move_to_y_position(y, speed, acceleration_ramp, current_limit) + finally: + # Whether the move succeeded or not: what the drive reads is where the carrier drive is. + y_reached = await self._record_where_it_stopped("y") + # The move answered but that read did not: read again, and let this one raise. + return await self.wheel_request_y_position() if y_reached is None else y_reached + + # -- scanner rotation drive ---------------------------------------------------------------------- + + async def scanner_request_rotation(self) -> ScannerRotation: + """Request which way the scanner faces. + + Returns: + Which way it faces, or `undefined` when it sits at neither of the two stops. + """ + resp = await self._driver.send_command(module="I0", command="RS", fmt="rs#") + code = cast(int, resp["rs"]) + for name, value in self.configuration.scanner_rotations.items(): + if value == code: + return name + return "undefined" + + async def scanner_move_to_position(self, position: ScannerRotation, stop_torque: bool = False): + """Rotate the scanner to face one way or the other. + + Args: + position: which way to face. Only the two stops can be moved to, so `undefined` is refused. + stop_torque: whether to hold the drive there once it arrives. The drive's own default is + not to. + Raises: + ValueError: If the position is not one that can be moved to. + """ + rotations = self.configuration.scanner_rotations + if position == "undefined" or position not in rotations: + raise ValueError( + f"position must be one of {[n for n in rotations if n != 'undefined']}, is {position!r}" + ) + + return await self._driver.send_command( + module="I0", + command="SP", + sp=f"{rotations[position]:01}", + sh=f"{int(stop_torque):01}", + ) + + # -- carrier presence sensing, using magnetic proximity sensors ------------------------------------------- + + @staticmethod + def _presence_mask(resp: str, marker: str) -> str: + """The carrier-presence mask in a reply: what is written after `marker`.""" + if marker not in resp: + raise ValueError(f"no `{marker}` carrier presence mask in the reply: {resp!r}") + return resp.split(marker, 1)[1] + + async def sense_carrier_presence_on_deck(self) -> List[int]: + """Read the rear deck sensors and return where carriers are detected. + + The autoload does not move. + + One track per carrier: the one its right rail sits over, which is the track every command here + addresses it by. PyLabRobot places a carrier by its front-left, so the two differ by the + carrier's width - a six-track carrier placed at track 15 is reported, and addressed, at 20. + `HamiltonDeck.compute_right_track_of_carrier` converts, since it takes the resource model to + know how wide a carrier is. + + Returns: + The rightmost track of each carrier found, counted from 1, in order. + + Raises: + ValueError: If the device answered without a presence mask. + """ + resp = cast(str, await self._driver.send_command(module="C0", command="RC")) + return _tracks_from_presence_mask(self._presence_mask(resp, "ce")) + + async def sense_carrier_presence_on_single_loading_tray_track( + self, track: int, park_after: bool = True + ) -> bool: + """Check whether a specific loading-tray track contains a carrier. + + The sled moves to that track and reads its front-facing sensor. + `sense_carrier_presence_on_loading_tray` scans the whole tray instead. + + Args: + track: which track to look at, counted from 1. + park_after: whether to park the sled after reading the sensor. + + Returns: + True if a carrier is there. + + Raises: + ValueError: If the track is not one this device has. + RuntimeError: If setup has not run. + """ + tracks = self.track_range + if track not in tracks: + raise ValueError(f"track must be between {tracks[0]} and {tracks[-1]}, is {track}") + resp = await self._driver.send_command( + module="C0", command="CT", subsystem="I0", fmt="ct#", cp=f"{track:02}" + ) + + if park_after: + await self.park() + + return cast(int, resp["ct"]) == 1 + + async def sense_carrier_presence_on_loading_tray(self) -> List[int]: + """Move the autoload sled across the loading tray and read its front-facing sensors. + + This determines which tray positions contain carriers. + + Returns: + The tracks that hold a carrier, counted from 1. + + Raises: + ValueError: If the device answered without a presence mask. + """ + resp = cast(str, await self._driver.send_command(module="C0", command="CS", subsystem="I0")) + return _tracks_from_presence_mask(self._presence_mask(resp, "cd")) + + # -- barcode scanner ----------------------------------------------------------------------------- + + def _require_barcode_scanner(self) -> Dict[Barcode1DSymbology, int]: + """The symbologies the fitted scanner reads, and the mask each holds. + + Raises: + RuntimeError: If discovery has not run, or this autoload's type names no scanner. + """ + if self.configuration.barcode_symbologies is None: + raise RuntimeError( + f"no barcode scanner is recorded for this autoload ({self.configuration.autoload_type}); " + "have you called `star.setup()`?" + ) + return self.configuration.barcode_symbologies + + async def request_latest_barcode_read(self) -> Optional[str]: + """Request the barcode the scanner last read. + + Returns: + What it read, or None when it read nothing. + """ + self._require_barcode_scanner() + resp = cast(str, await self._driver.send_command(module="I0", command="RB")) + barcode = resp.split("rb", 1)[-1].strip().strip("'") + return barcode or None + + async def set_barcode_scanner_enabled( + self, + enabled: bool, + symbologies: Optional[List[Barcode1DSymbology]] = None, + symbologies_2d: Optional[List[Barcode2DSymbology]] = None, + scan_direction: ScanDirection = "horizontal", + ): + """Switch the barcode scanner on or off. Switching it on is what reads a barcode. + + Args: + enabled: whether to switch it on. + symbologies: which symbologies to read. Defaults to `ANY 1D`, every one it reads. + symbologies_2d: which 2D and stacked codes to read, on a reader that reads them. Defaults to + `ANY 2D` there, and is refused on a scanner that reads only 1D. + scan_direction: which way a 2D reader looks. Sent only by a reader that takes it. + Raises: + ValueError: If a symbology is not one it reads, or 2D codes are asked of a 1D scanner. + RuntimeError: If this autoload's type names no scanner. + """ + c = self.configuration + known = self._require_barcode_scanner() + symbologies = ["ANY 1D"] if symbologies is None else symbologies + unknown = [name for name in symbologies if name not in known] + if unknown: + raise ValueError(f"not symbologies this scanner reads: {unknown}; it reads {list(known)}") + mask = 0 + for name in symbologies: + mask |= known[name] + + # A 1D scanner's command has neither parameter below. + if c.barcode_2d_symbologies is None: + if symbologies_2d is not None: + raise ValueError(f"this scanner reads no 2D codes: {symbologies_2d}") + return await self._driver.send_command( + module="I0", command="AR", ar=f"{int(enabled):01}", bt=f"{mask:02X}" + ) + + known_2d = c.barcode_2d_symbologies + symbologies_2d = ["ANY 2D"] if symbologies_2d is None else symbologies_2d + unknown_2d = [name for name in symbologies_2d if name not in known_2d] + if unknown_2d: + raise ValueError(f"not 2D codes this reader reads: {unknown_2d}; it reads {list(known_2d)}") + mask_2d = 0 + for name_2d in symbologies_2d: + mask_2d |= known_2d[name_2d] + + if scan_direction not in c.scan_directions: + raise ValueError( + f"scan_direction must be one of {list(c.scan_directions)}, is {scan_direction!r}" + ) + + return await self._driver.send_command( + module="I0", + command="AR", + ar=f"{int(enabled):01}", + sp=f"{c.scan_directions[scan_direction]:01}", + bt=f"{mask:02X}", + mq=f"{mask_2d:02X}", + ) + + async def reset_barcode_scanner(self): + """Reset the barcode scanner.""" + self._require_barcode_scanner() + return await self._driver.send_command(module="I0", command="AF") + + # -- carrier identification ---------------------------------------------------------------------- + + async def set_barcode_symbologies(self, symbologies: List[Barcode1DSymbology]): + """Set the barcode symbologies for autoload barcode reading. + + Args: + symbologies: which symbologies to read. + Raises: + ValueError: If a type is not one it reads. + """ + known = self._require_barcode_scanner() + unknown = [name for name in symbologies if name not in known] + if unknown: + raise ValueError(f"not symbologies this scanner reads: {unknown}; it reads {list(known)}") + mask = 0 + for name in symbologies: + mask |= known[name] + return await self._driver.send_command( + module="C0", command="CB", subsystem="I0", bt=f"{mask:02X}" + ) + + async def load_carrier_from_tray_and_scan_carrier_barcode( + self, + track: int, + barcode_position: float = 4.3, + barcode_reading_window_width: float = 8.5, + reading_speed: float = 128.1, + ) -> Optional[str]: + """Load a carrier from the loading tray and scan its barcode. + + `unload_carrier_after_carrier_barcode_scanning` puts it back on the tray. + + Args: + track: the track the carrier ends at, counted from 1. + barcode_position: where along the carrier its barcode sits, in mm. + barcode_reading_window_width: how wide a window to read it in, in mm. A carrier's own label + is read in a narrow window, which is not the width the containers it carries are read in. + reading_speed: how fast to travel while reading, in mm/s. + + Returns: + The barcode, or None when nothing was read. + + Raises: + ValueError: If the track is not one this device has, or an argument is outside what the + command accepts. + RuntimeError: If setup has not run. + """ + tracks = self.track_range + if track not in tracks: + raise ValueError(f"track must be between {tracks[0]} and {tracks[-1]}, is {track}") + if not 0 <= barcode_position <= 470: + raise ValueError(f"barcode_position must be between 0 and 470 mm, is {barcode_position}") + if not 0.1 <= barcode_reading_window_width <= 99.9: + raise ValueError( + "barcode_reading_window_width must be between 0.1 and 99.9 mm, is " + f"{barcode_reading_window_width}" + ) + if not 1.5 <= reading_speed <= 160.0: + raise ValueError(f"reading_speed must be between 1.5 and 160.0 mm/s, is {reading_speed}") + + try: + resp = cast( + str, + await self._send_command_and_update_sled_x( + module="C0", + command="CI", + subsystem="I0", + cp=f"{track:02}", + bi=f"{round(barcode_position * 10):04}", + bw=f"{round(barcode_reading_window_width * 10):03}", + cv=f"{round(reading_speed * 10):04}", + ), + ) + except BaseException: + # The wheel is left wherever the failure stopped it, and nothing may travel with it down. + await self.wheel_move_to_safe_z() + raise + + if "bb/" not in resp: + return None + # What follows the marker is the barcode's length written in two digits, then the barcode. + read = resp.split("bb/", 1)[1].strip().strip("'") + return read[2:] or None + + async def unload_carrier_after_carrier_barcode_scanning(self): + """Unload the carrier currently engaged with the autoload sled, back to the loading tray. + + Sent after its barcode has been scanned. + """ + try: + return await self._send_command_and_update_sled_x(module="C0", command="CA", subsystem="I0") + except BaseException: + await self.wheel_move_to_safe_z() + raise + + async def take_carrier_out_to_autoload_belt(self, track: int): + """Take a carrier out to the identification position for barcode reading. + + The carrier is already on the deck. + + Args: + track: the track the carrier sits at, counted from 1. + Raises: + ValueError: If the track is not one this device has, or its carrier is on the loading tray + rather than the deck. + RuntimeError: If setup has not run. + """ + tracks = self.track_range + if track not in tracks: + raise ValueError(f"track must be between {tracks[0]} and {tracks[-1]}, is {track}") + if await self.sense_carrier_presence_on_single_loading_tray_track(track): + raise ValueError(f"the carrier at track {track} is on the loading tray, not the deck") + + try: + return await self._send_command_and_update_sled_x( + module="C0", command="CN", subsystem="I0", cp=f"{track:02}" + ) + except BaseException: + # The wheel is left wherever the failure stopped it, and nothing may travel with it down. + await self.wheel_move_to_safe_z() + raise + + async def load_carrier_from_autoload_belt( + self, + barcode_reading: bool = False, + barcode_reading_direction: BarcodeReadingDirection = "horizontal", + reading_position_of_first_barcode: float = 63.0, + containers_per_carrier: int = 5, + distance_between_containers: float = 96.0, + width_of_reading_window: float = 38.0, + reading_speed: float = 128.1, + park_after: bool = True, + ) -> Dict[int, Optional[str]]: + """Finish loading the carrier currently engaged with the autoload sled. + + It is the one at the identification position. Which barcode types are read is whatever + `set_barcode_symbologies` last set. + + Args: + barcode_reading: whether to read the containers at all. When False the scanner stays where it + is and nothing is read. + barcode_reading_direction: which way the scanner faces while reading: `vertical` or + `horizontal`. + reading_position_of_first_barcode: where along the carrier the first container's barcode + sits, in mm. + containers_per_carrier: how many containers to read. + distance_between_containers: how far apart they sit, in mm. + width_of_reading_window: how wide a window to read each in, in mm. + reading_speed: how fast to travel while reading, in mm/s. + park_after: whether to park the autoload once the carrier is in. + + Returns: + Each container's barcode by position, counted from 0, and None where nothing was read. Empty + when `barcode_reading` is False. + + Raises: + ValueError: If an argument is outside what the command accepts, or fewer barcodes come back + than were asked for. + RuntimeError: If setup has not run and the autoload has to be parked. + """ + directions = self.configuration.barcode_reading_directions + if barcode_reading_direction not in directions: + raise ValueError( + f"barcode_reading_direction must be one of {list(directions)}, is " + f"{barcode_reading_direction!r}" + ) + if not 0 <= reading_position_of_first_barcode <= 470: + raise ValueError( + "reading_position_of_first_barcode must be between 0 and 470 mm, is " + f"{reading_position_of_first_barcode}" + ) + if not 0 <= containers_per_carrier <= 32: + raise ValueError( + f"containers_per_carrier must be between 0 and 32, is {containers_per_carrier}" + ) + if not 0 <= distance_between_containers <= 470: + raise ValueError( + f"distance_between_containers must be between 0 and 470 mm, is {distance_between_containers}" + ) + if not 0.1 <= width_of_reading_window <= 99.9: + raise ValueError( + f"width_of_reading_window must be between 0.1 and 99.9 mm, is {width_of_reading_window}" + ) + if not 1.5 <= reading_speed <= 160.0: + raise ValueError(f"reading_speed must be between 1.5 and 160.0 mm/s, is {reading_speed}") + + # Reading nothing is asked for by facing the scanner away and asking for no containers, so the + # carrier travels in without the scanner moving. + direction = "vertical" if not barcode_reading else barcode_reading_direction + containers = containers_per_carrier if barcode_reading else 0 + + try: + resp = cast( + str, + await self._send_command_and_update_sled_x( + module="C0", + command="CL", + subsystem="I0", + bd=f"{directions[direction]:01}", + bp=f"{round(reading_position_of_first_barcode * 10):04}", + cn=f"{containers:02}", + co=f"{round(distance_between_containers * 10):04}", + cf=f"{round(width_of_reading_window * 10):03}", + cv=f"{round(reading_speed * 10):04}", + ), + ) + except BaseException: + await self.wheel_move_to_safe_z() + raise + + if park_after: + await self.park() + + if not barcode_reading: + return {} + + read = resp.split("bb/")[-1].split("/") + if len(read) < containers_per_carrier: + raise ValueError( + f"asked for {containers_per_carrier} barcodes, {len(read)} came back: {resp!r}" + ) + return { + position: None if read[position] == "00" else read[position] + for position in range(containers_per_carrier) + } + + async def unload_carrier( + self, + carrier: Carrier, + use_loading_indicators: bool = True, + perform_deck_presence_check: bool = True, + perform_tray_presence_check: bool = True, + park_after: bool = True, + ): + """Use the autoload to unload a carrier from the deck. + + Args: + carrier: the carrier to unload, as it sits on the deck. + use_loading_indicators: whether to use loading indicators during the unload process. + perform_deck_presence_check: whether to confirm the deck sensors see the carrier first. + perform_tray_presence_check: whether to confirm the loading tray is not already holding it. + park_after: whether to park the autoload once the carrier is out. + Raises: + ValueError: If the carrier ends outside the tracks this device has, if the deck sensors do + not see it, or if it is already on the loading tray. + RuntimeError: If setup has not run, or the driver was given no deck, so a carrier has no + track to unload from. + """ + deck = self._driver.deck + if deck is None: + raise RuntimeError("this driver has no deck, so a carrier has no track to unload from") + + # The autoload addresses a carrier by its rightmost track, which is the one the deck sensors + # report it at too. + track = deck.compute_right_track_of_carrier(carrier) + tracks = self.track_range + if track not in tracks: + raise ValueError(f"track must be between {tracks[0]} and {tracks[-1]}, is {track}") + + # The tracks the carrier covers, from the leftmost it sits over to the rightmost. + left_track = track_for_x_coordinate(carrier.get_location_wrt(deck).x) + covered_tracks = range(left_track, track + 1) + + if use_loading_indicators: + # Blinking, not steady: the operator has to take the carrier off the tray. + covered = [track_idx in covered_tracks for track_idx in tracks] + await self.set_loading_indicators(lit=covered, blinking=covered) + + try: + # Safety check - Is a carrier at that track? + if perform_deck_presence_check: + try: + carrier_presence_on_deck = await self.sense_carrier_presence_on_deck() + except Exception as e: + logger.warning( + "Deck's carrier sensor failed; you might require an engineer to check your sensors: %s", + e, + ) + else: + if track not in carrier_presence_on_deck: + raise ValueError( + f"the deck holds no carrier ending at track {track}, is it pushed all the way in?" + ) + + # Safety check - Is the loading tray already holding a carrier at that track? + if perform_tray_presence_check: + try: + carrier_presence_on_tray = [ + await self.sense_carrier_presence_on_single_loading_tray_track( + track=track_idx, park_after=False + ) + for track_idx in covered_tracks + ] + except Exception as e: + logger.warning( + "Tray's carrier sensor failed; you might require an engineer to check your sensors: %s", + e, + ) + else: + if any(carrier_presence_on_tray): + sensor_data = list(zip(covered_tracks, carrier_presence_on_tray)) + raise ValueError( + f"sensor data indicates the tray already holds a carrier: {sensor_data}" + ) + + resp = await self._send_command_and_update_sled_x( + module="C0", command="CR", subsystem="I0", cp=f"{track:02}" + ) + if park_after: + await self.park() + return resp + finally: + # However this ends - raised, cancelled or done - the lights it turned on go out again. + if use_loading_indicators: + await self.set_loading_indicators(lit=[False] * len(tracks), blinking=[False] * len(tracks)) + + async def unload_carrier_finally(self, track: int, park_after: bool = True): + """Unload the carrier at a track, from where it cannot be loaded again. + + Args: + track: the track the carrier sits at, counted from 1. + park_after: whether to park the autoload once the carrier is out. + Raises: + ValueError: If the track is not one this device has. + RuntimeError: If setup has not run. + """ + tracks = self.track_range + if track not in tracks: + raise ValueError(f"track must be between {tracks[0]} and {tracks[-1]}, is {track}") + + resp = await self._send_command_and_update_sled_x( + module="C0", command="CW", subsystem="I0", cp=f"{track:02}" + ) + if park_after: + await self.park() + return resp + + async def load_carrier( + self, + carrier: Carrier, + carrier_barcode_reading: bool = True, + barcode_reading: bool = False, + barcode_reading_direction: BarcodeReadingDirection = "horizontal", + containers_per_carrier: int = 5, + reading_position_of_first_barcode: float = 63.0, + distance_between_containers: float = 96.0, + width_of_reading_window: float = 38.0, + reading_speed: float = 128.1, + park_after: bool = True, + ) -> dict: + """Load a carrier from the loading tray onto the deck, reading what it carries on the way. + + The carrier goes to the track it is assigned to on the deck, so the resource model decides + where it lands. `unload_carrier` takes it back out again. + + Args: + carrier: the carrier to load, as it is to sit on the deck. + carrier_barcode_reading: whether to return the carrier's own barcode. It is scanned as the + carrier comes in either way. + barcode_reading: whether to read the barcode of each container it carries. + barcode_reading_direction: which way the scanner looks while reading those. + containers_per_carrier: how many container barcodes to read. + reading_position_of_first_barcode: where the first container sits along the carrier, in mm. + distance_between_containers: the spacing of the containers, in mm. + width_of_reading_window: how wide a window to read each barcode in, in mm. + reading_speed: how fast to travel while reading, in mm/s. + park_after: whether to park the autoload once the carrier is in. + + Returns: + The carrier's own barcode under "carrier_barcode", and one per container position under + "container_barcodes". + + Raises: + ValueError: If the carrier ends outside the tracks this device has, or the loading tray + holds no carrier at its track. + RuntimeError: If setup has not run, or the driver was given no deck, so a carrier has no + track to load to. + """ + deck = self._driver.deck + if deck is None: + raise RuntimeError("this driver has no deck, so a carrier has no track to load to") + + # The autoload addresses a carrier by its rightmost track, which is where it ends on the deck. + track = deck.compute_right_track_of_carrier(carrier) + tracks = self.track_range + if track not in tracks: + raise ValueError(f"track must be between {tracks[0]} and {tracks[-1]}, is {track}") + + if not await self.sense_carrier_presence_on_single_loading_tray_track(track): + raise ValueError(f"no carrier at track {track}; is it on the right loading tray position?") + + # The command that reads the carrier's barcode is also the one that pulls it in off the tray, + # so it runs either way, as in legacy; the flag only decides whether the barcode is returned. + carrier_barcode = await self.load_carrier_from_tray_and_scan_carrier_barcode(track) + if not carrier_barcode_reading: + carrier_barcode = None + + container_barcodes = await self.load_carrier_from_autoload_belt( + barcode_reading=barcode_reading, + barcode_reading_direction=barcode_reading_direction, + reading_position_of_first_barcode=reading_position_of_first_barcode, + containers_per_carrier=containers_per_carrier, + distance_between_containers=distance_between_containers, + width_of_reading_window=width_of_reading_window, + reading_speed=reading_speed, + park_after=False, + ) + + if park_after: + await self.park() + + return {"carrier_barcode": carrier_barcode, "container_barcodes": container_barcodes} + + # -- loading indicators -------------------------------------------------------------------------- + + async def light_tracks(self, tracks: List[int], blinking: Optional[List[bool]] = None) -> None: + """Light the indicators over the tracks named, and leave every other one dark. + + What `set_loading_indicators` takes is a pattern for the whole deck, one entry per track, which + a caller that knows which tracks it means has to build. This takes the tracks themselves. + + Args: + tracks: which tracks to light, counted from 1. + blinking: whether each of those tracks blinks rather than holding steady, one entry per + track named above and in the same order. All steady when not given. + + Raises: + ValueError: If a track is not one this device has, or the two lists are different lengths. + RuntimeError: If setup has not run, so the deck size is not known. + """ + every = self.track_range + unknown = [track for track in tracks if track not in every] + if unknown: + raise ValueError(f"not tracks this device has: {unknown}; it has {every[0]} to {every[-1]}") + if blinking is None: + blinking = [False] * len(tracks) + if len(blinking) != len(tracks): + raise ValueError( + f"blinking must have one entry per track, {len(tracks)}, has {len(blinking)}" + ) + + blinks = dict(zip(tracks, blinking)) + await self.set_loading_indicators( + lit=[track in blinks for track in every], + blinking=[bool(blinks.get(track, False)) for track in every], + ) + + async def clear_loading_indicators(self) -> None: + """Put every indicator out. + + Raises: + RuntimeError: If setup has not run, so the deck size is not known. + """ + await self.light_tracks([]) + + async def set_loading_indicators(self, lit: List[bool], blinking: List[bool]): + """Set the loading indicators (LEDs), one per track. + + Args: + lit: whether each track's light is on, counted from track 1. + blinking: whether each track's light blinks rather than stays steady. + Raises: + ValueError: If either pattern does not have one entry per track. + RuntimeError: If setup has not run, so the deck size is not known. + """ + tracks = len(self.track_range) + for name, pattern in (("lit", lit), ("blinking", blinking)): + if len(pattern) != tracks: + raise ValueError(f"{name} must have {tracks} entries, one per track, has {len(pattern)}") + + def as_hex(pattern: List[bool]) -> str: + # Track 1 is the lowest bit the master reads, so the pattern is written out backwards: given + # in track order it lights the mirror of what was asked for, tracks 16 to 21 coming up as + # 34 to 39 on a 54-track deck. + bits = "".join("1" if on else "0" for on in reversed(pattern)) + return f"{int(bits, base=2):014X}" + + return await self._driver.send_command( + module="C0", command="CP", subsystem="I0", cl=as_hex(lit), cb=as_hex(blinking) + ) diff --git a/pylabrobot/hamilton/star/driver/features/autoload_tests.py b/pylabrobot/hamilton/star/driver/features/autoload_tests.py new file mode 100644 index 00000000000..13b5e1e9d6f --- /dev/null +++ b/pylabrobot/hamilton/star/driver/features/autoload_tests.py @@ -0,0 +1,157 @@ +import unittest +from typing import Any, List, Optional, Set, Tuple +from unittest.mock import AsyncMock, patch + +from pylabrobot.hamilton.star.device import RECORDING_STAR +from pylabrobot.hamilton.star.driver.features.autoload import Autoload +from pylabrobot.hamilton.star.driver.simulator import STARSimulationDriver +from pylabrobot.resources.hamilton import PLT_CAR_L5AC_A00, STARDeck + +# Where the simulated wheel reads, in mm, when a test wants it standing below its safe Z. +LOWERED = "pylabrobot.hamilton.star.driver.simulator.SIMULATED_AUTOLOAD_Z_POSITION" + + +class DriveFault(Exception): + """What a sled drive that stops part way answers with, standing in for the firmware's error.""" + + +async def autoload(failing: Set[str]) -> Tuple[Autoload, List[str]]: + """The autoload of a simulated device, whose `failing` commands raise once sent. + + Args: + failing: the commands, as module and command joined (`"I0XP"`), that fail. + + Returns: + The feature, and every command it sends from here on, in the same form. + """ + driver = STARSimulationDriver(deck=STARDeck(), declared_configuration_json=RECORDING_STAR) + await driver.setup() + feature = driver.autoload + assert feature is not None + + sent: List[str] = [] + answer = driver.send_command + + async def recorded( + module: str, + command: str, + fmt: Optional[Any] = None, + subsystem: Optional[str] = None, + **kwargs: Any, + ): + sent.append(module + command) + if module + command in failing: + raise DriveFault(f"{module}{command} stopped part way") + return await answer(module=module, command=command, fmt=fmt, subsystem=subsystem, **kwargs) + + driver.send_command = recorded # type: ignore[assignment] + return feature, sent + + +def after(sent: List[str], command: str) -> List[str]: + """What was sent from `command` on.""" + return sent[sent.index(command) :] + + +class TestAFailedSledMoveRaisesTheWheel(unittest.IsolatedAsyncioTestCase): + """A sled move that fails leaves the wheel at its safe Z, and says why the move failed.""" + + async def test_a_failed_park_raises_a_wheel_below_safe_z(self): + feature, sent = await autoload(failing={"I0XP"}) + + with patch(LOWERED, 10.0), self.assertRaises(DriveFault): + await feature.park() + + self.assertIn("C0IV", after(sent, "I0XP")) + + async def test_a_failed_move_along_x_raises_a_wheel_below_safe_z(self): + feature, sent = await autoload(failing={"I0XA"}) + c = feature.configuration + low, high = c.x_drive_range_increments + middle = c.to_deck_frame(c.x_drive_increments_to_mm((low + high) // 2)) + + with patch(LOWERED, 10.0), self.assertRaises(DriveFault): + await feature.move_x(middle) + + self.assertIn("C0IV", after(sent, "I0XA")) + + async def test_a_failed_park_reads_a_wheel_already_at_safe_z_and_leaves_it(self): + feature, sent = await autoload(failing={"I0XP"}) + + with self.assertRaises(DriveFault): + await feature.park() + + self.assertIn("I0RZ", after(sent, "I0XP")) + self.assertNotIn("C0IV", after(sent, "I0XP")) + + async def test_a_wheel_whose_height_cannot_be_read_is_raised(self): + feature, sent = await autoload(failing={"I0XP"}) + feature_read = feature._request_drive_position + + async def unreadable(command: str, digits: int) -> int: + if command == "RZ" and "I0XP" in sent: + raise DriveFault("I0RZ unanswered") + return await feature_read(command, digits) + + with patch.object(feature, "_request_drive_position", unreadable): + with self.assertRaisesRegex(DriveFault, "I0XP"): + await feature.park() + + self.assertIn("C0IV", after(sent, "I0XP")) + + async def test_a_wheel_that_will_not_rise_does_not_hide_why_the_move_failed(self): + feature, sent = await autoload(failing={"I0XP", "C0IV"}) + + async def lowered_once_the_move_failed() -> bool: + return "I0XP" not in sent + + with patch.object(feature, "wheel_is_at_safe_z", lowered_once_the_move_failed): + with self.assertRaisesRegex(DriveFault, "I0XP"): + await feature.park() + + self.assertIn("C0IV", after(sent, "I0XP")) + + async def test_a_park_that_succeeds_leaves_the_wheel_alone(self): + feature, sent = await autoload(failing=set()) + + await feature.park() + + self.assertNotIn("C0IV", sent) + + async def test_a_park_raises_a_wheel_below_safe_z_before_moving(self): + feature, sent = await autoload(failing=set()) + + with patch(LOWERED, 10.0): + await feature.park() + + self.assertLess(sent.index("C0IV"), sent.index("I0XP")) + + +class TestLoadCarrier(unittest.IsolatedAsyncioTestCase): + """The command that reads a carrier's barcode is the one that pulls it in off the tray.""" + + async def test_a_carrier_is_pulled_in_whether_or_not_its_barcode_is_wanted(self): + feature, _ = await autoload(failing=set()) + deck = feature._driver.deck + assert deck is not None + carrier = PLT_CAR_L5AC_A00(name="carrier") + deck.assign_child_resource(carrier, track=20) + + on_the_tray = AsyncMock(return_value=True) + for wanted in (True, False): + pulled_in = AsyncMock(return_value="read") + with patch.object( + feature, "sense_carrier_presence_on_single_loading_tray_track", on_the_tray + ): + with patch.object(feature, "load_carrier_from_tray_and_scan_carrier_barcode", pulled_in): + loaded = await feature.load_carrier( + carrier, carrier_barcode_reading=wanted, park_after=False + ) + + with self.subTest(carrier_barcode_reading=wanted): + pulled_in.assert_awaited_once() + self.assertEqual(loaded["carrier_barcode"], "read" if wanted else None) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/hamilton/star/driver/features/cover.py b/pylabrobot/hamilton/star/driver/features/cover.py new file mode 100644 index 00000000000..4a9c89db94d --- /dev/null +++ b/pylabrobot/hamilton/star/driver/features/cover.py @@ -0,0 +1,92 @@ +"""The front cover: the hinged window over the deck, and whether the device may move with it open.""" + +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Dict, Literal, Optional, cast + +if TYPE_CHECKING: + from pylabrobot.hamilton.star.driver.master import STARDriver + +logger = logging.getLogger(__name__) + +# Whether the cover is shut, as the master answers. +CoverPosition = Literal["open", "closed"] + +# What the master answers for each position. The master's own protocol rather than anything the +# cover reports: it has no module of its own, so there is nothing to read and nothing to vary. +COVER_POSITION_CODES: Dict[CoverPosition, int] = {"open": 0, "closed": 1} + + +@dataclass +class FrontCoverConfiguration: + """The front cover's device facts. + + The master's protocol, not the cover's: it has no module of its own and answers nothing about + itself, so none of this is read off it and none of it is saved with a configuration. + """ + + position_codes: Dict[CoverPosition, int] = field( + default_factory=lambda: dict(COVER_POSITION_CODES) + ) + """Which code the master answers for each position.""" + + +class FrontCover: + """The front cover. + + Reached as `driver.front_cover`. + + Control module(s): `C0`/master only (no module of its own and no firmware version to report). + """ + + def __init__(self, driver: "STARDriver", configuration: Optional[FrontCoverConfiguration] = None): + """ + Args: + driver: the driver to send commands through. + configuration: the cover's device facts. Defaults to `FrontCoverConfiguration()`. + """ + self._driver = driver + self.configuration = configuration or FrontCoverConfiguration() + + # -- position -------------------------------------------------------------- + + async def request_state(self) -> CoverPosition: + """Request whether the cover is open or shut. + + Returns: + Which one, as named in `configuration.position_codes`. + """ + resp = await self._driver.send_command(module="C0", command="QC", fmt="qc#") + code = cast(int, resp["qc"]) + return "closed" if code == self.configuration.position_codes["closed"] else "open" + + # -- the lock -------------------------------------------------------------- + # TODO: verify whether lock mentioned in firmware actually exists on hardware + # async def lock(self): + # """Lock the cover. + + # Raises: + # STARFirmwareError: If it is not shut, which the master answers as a cover close error. + # """ + # return await self._driver.send_command(module="C0", command="CO", subsystem="C0") + + # async def unlock(self): + # """Unlock the cover.""" + # return await self._driver.send_command(module="C0", command="HO", subsystem="C0") + + # -- firmware-based enforcement of cover being closed during operation ------ + + async def enable_control(self): + """Enable cover control: the interlock. + + With it enabled, a motion command sent while the cover is open is refused. Nothing reports + whether it is on, so a caller that needs to know has to track what it set. + """ + return await self._driver.send_command(module="C0", command="CE", subsystem="C0") + + async def disable_control(self): + """Disable cover control, so motion is no longer refused while the cover is open. + + This removes the interlock, and nothing reports that it is gone. + """ + return await self._driver.send_command(module="C0", command="CD", subsystem="C0") diff --git a/pylabrobot/hamilton/star/driver/features/head.py b/pylabrobot/hamilton/star/driver/features/head.py new file mode 100644 index 00000000000..5d4c837a4fc --- /dev/null +++ b/pylabrobot/hamilton/star/driver/features/head.py @@ -0,0 +1,1295 @@ +"""What the 96-head and the 384-head share. + +At their own modules the 96-head and the 384-head are the same device twice over: the same four +drives, the same liquid level detection down to its two channels and their and/or logic, the same +macros, reached by commands that differ only in their constants - which module answers, how wide +each parameter is written, how much one increment is worth. What is theirs alone at this level is +what their configuration bytes mean, and what resolves their drive windows: firmware generation for +one, which head is fitted for the other. That is the layer this holds. + +At the master they are not the same device. The commands that pick up tips and move liquid carry +about thirty parameters each, and between the two heads every one is named differently, the volumes +are counted in units that differ by a factor of ten, and the features themselves do not match - +only the 96-head can mask individual channels, only the 384-head takes a gain and offset for its +cLLD. Each head carries those itself. Renaming thirty parameters through here would be a +translation table wearing a base class rather than a shared implementation. + +The line is roughly how much of a command has to be restated to share it. One or two names, as the +initialization command needs, is a constant. Thirty is a different command. +""" + +import datetime +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, cast + +from pylabrobot.hamilton.protocol.text.framing import parse_firmware_version_date +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.n_channel_pipettes import NChannelPipette + +if TYPE_CHECKING: + from pylabrobot.hamilton.star.driver.features.x_arm import XArm + from pylabrobot.hamilton.star.driver.master import STARDriver + +logger = logging.getLogger(__name__) + +# The shaft the drives report: every head is positioned by its first channel. +HEAD_REFERENCE_SHAFT = "A1" + + +@dataclass +class HeadConfiguration: + """Device facts shared by the heads. + + Two kinds of value: what the head reports about itself, which is None until read; and device + facts that are defaulted, of which the windows in standard units are computed from the increment + windows on access rather than stored. + + A head supplies the six members below that are left unimplemented here - the four increment + windows its drives accept, and what one dispensing or squeezer increment is worth. Each may be a + plain field where it is constant for that head, or a property where the head resolves it from + what it read about itself. + """ + + # What this head is on the bus, and the commands, parameter names and field widths that reach + # it. Stated without defaults: a head that named none of them would address whichever module + # happened to answer. + module: str + retract_command: str + initialize_command: str + tip_presence_command: str + position_command: str + # What the master's commands for this head call its Y, its Z, and the height it leaves the head + # at. Shared by the commands that move it and by the query that reports where it is. + y_parameter: str + z_parameter: str + z_end_parameter: str + x_offset_parameter: str + head_types: Dict[int, str] + """What each head-type code means.""" + drive_parameters: Dict[str, int] + """The drive parameters this head stores - each drive's speed and acceleration - and how many + digits each is written in. Reads use these as well as writes: a read one digit short truncates + the value silently rather than failing.""" + first_documented_firmware_year: int + """The generation this head's windows were taken from; an older one may document different ones.""" + + firmware_version: Optional[str] = None + firmware_date: Optional[datetime.date] = None + x_offset: Optional[float] = None + """Deck X distance from the X-arm carriage center to head channel A1 (mm), read from + master EEPROM at setup. Mirrors the iSWAP's rotation-drive x-offset.""" + + channel_pitch: float = 9.0 + channel_columns: int = 12 + channel_rows: int = 8 + body_size_z: float = 140.0 # size of modelled head body; its lowest feature to its top (mm). + min_x_clear_of_left_side_panel: float = -100.0 + """The leftmost channel A1 may go without the head striking the left side panel, in deck mm. + + A judgement about clearance rather than a measurement, and not read from anywhere: the panel is + bolted on and off in seconds, so whether one is fitted is declared, and how close the head may + come to it is ours to choose.""" + + min_tool_bottom_z: float = 99.98 + """The lowest Z the head may place the bottom of what it carries at, in deck mm. + + A declared limit rather than anything the head reports: its drive is commanded in stop-disc terms + and knows nothing about what hangs below it, so how far down a tip may be taken is ours to + choose. The default is the floor the channels' own Z drive documents, which is what bounded this + move before.""" + + supports_clot_monitoring_clld: Optional[bool] = None + head_type: Optional[str] = None + + tip_discard_location: Optional[Coordinate] = None + """Where this head's trash is: head channel A1, in deck mm. + + Where tips go when a discard is not told otherwise, and where the head ejects when it is + initialized - initializing throws off whatever is mounted, so it has to happen somewhere tips may + be dropped. Depends on where the waste sits on the deck, so it has no default, and a run that + moves the waste sets it again.""" + + z_range: Tuple[float, float] = (180.5, 336.0) + """Z-drive position window (mm). + + Conservative across both heads and both generations: the highest floor any of them documents + and the lowest ceiling, so a head is never commanded past what its own drive reaches before + setup has read it. Setup replaces the ceiling with what `probe_z_max` measured off this head + and keeps the floor, as it does for the channels.""" + + # Encoder resolutions (defaulted device facts). A drive that counts its acceleration in + # thousands of increments says so here, by carrying a resolution a thousand times its own. + z_drive_mm_per_increment: float = 0.005 + y_drive_mm_per_increment: float = 0.015625 + y_drive_acceleration_mm_per_increment: float = 0.015625 + z_drive_acceleration_mm_per_increment: float = 0.005 + dispensing_drive_mm_per_increment: float = 0.001025641026 + + # The Z windows both heads share. The Y ones differ, so each head states its own. + z_speed_range_increments: Tuple[int, int] = (50, 20000) + z_acceleration_range_increments: Tuple[int, int] = (5000, 100000) + + # What the drive adds to each position it has stored, so a stored value is an offset from here + # rather than a position in its own right. Zero where the head stores positions outright. + predefined_y_position_origin: int = 0 + predefined_z_position_origin: int = 0 + + predefined_y_slots: Tuple[str, ...] = ( + "home", + "predefined_1", + "predefined_2", + "predefined_3", + "predefined_4", + "predefined_5", + "predefined_6", + "predefined_7", + "predefined_8", + "predefined_9", + ) + """What the head's stored Y table holds, slot by slot.""" + + predefined_y_positions_increments: Optional[Dict[str, int]] = None + """Each Y position the head has stored, in increments, keyed as `configuration.predefined_y_slots` names + them. Filled by `request_predefined_y_positions`, which discovery does not call: what a head + parks at is read when it is needed rather than at every setup.""" + predefined_z_slots: Tuple[str, ...] = ( + "home", + "predefined_1", + "predefined_2", + "predefined_3", + "predefined_4", + "predefined_5", + "predefined_6", + "predefined_7", + "predefined_8", + "predefined_9", + ) + """The same for its Z table, which the head keeps separately.""" + + predefined_z_positions_increments: Optional[Dict[str, int]] = None + """The same along Z, filled by `request_predefined_z_positions`.""" + + traversal_z_position: float = 245.0 + """How high the head travels when a command is not told otherwise, in mm. Not a device fact: a + height chosen to clear what sits on the deck, which is why every command that uses it takes it as + an argument too.""" + + # What the driver sends when a move names no current limit, and what the drives accept. + y_drive_current_limit_default: int = 15 + z_drive_current_limit_default: int = 15 + current_limit_range: Tuple[int, int] = (0, 15) + + # What each drive starts from, in the increments it is written in. A head that documents + # something else states its own. + y_speed_default_increments: int = 25000 + y_acceleration_default_increments: int = 35000 + z_speed_default_increments: int = 17000 + z_acceleration_default_increments: int = 80000 + + # What the head reported holding, which stands in front of the defaults above. None until + # discovery has read it, and on a simulated device. + y_drive_speed_firmware_reported: Optional[float] = None + y_drive_acceleration_firmware_reported: Optional[float] = None + z_drive_speed_firmware_reported: Optional[float] = None + z_drive_acceleration_firmware_reported: Optional[float] = None + + # -- what each head supplies ------------------------------------------------------------------- + + @property + def y_range_increments(self) -> Tuple[int, int]: + """Y-drive position window in increments, at channel A1.""" + raise NotImplementedError("a head states the Y positions its drive accepts") + + @property + def y_speed_range_increments(self) -> Tuple[int, int]: + """Y-drive speed window, in the increments per second the drive counts in.""" + raise NotImplementedError("a head states the Y speeds its drive accepts") + + @property + def y_acceleration_range_increments(self) -> Tuple[int, int]: + """Y-drive acceleration window, in the increments the drive counts acceleration in.""" + raise NotImplementedError("a head states the Y accelerations its drive accepts") + + @property + def z_range_increments(self) -> Tuple[int, int]: + """Z-drive position window in increments, at the head's lowest fixed feature.""" + raise NotImplementedError("a head states the Z positions its drive accepts") + + @property + def dispensing_drive_uL_per_increment(self) -> float: + """What one increment of the dispensing drive holds, in uL.""" + raise NotImplementedError("a head states what one dispensing increment holds") + + @property + def squeezer_drive_mm_per_increment(self) -> float: + """How far one increment of the squeezer drive travels, in mm.""" + raise NotImplementedError("a head states how far one squeezer increment travels") + + # -- what each drive starts from, preferring what the head reported over what it documents ----- + + @property + def y_drive_speed_default(self) -> float: + """Y-drive speed a move uses when the caller names none (mm/s).""" + if self.y_drive_speed_firmware_reported is not None: + return self.y_drive_speed_firmware_reported + return self.y_drive_increments_to_mm(self.y_speed_default_increments) + + @property + def y_drive_acceleration_default(self) -> float: + """Y-drive acceleration a move uses when the caller names none (mm/s2).""" + if self.y_drive_acceleration_firmware_reported is not None: + return self.y_drive_acceleration_firmware_reported + return self.y_drive_acceleration_increments_to_mm(self.y_acceleration_default_increments) + + @property + def z_drive_speed_default(self) -> float: + """Z-drive speed a move uses when the caller names none (mm/s).""" + if self.z_drive_speed_firmware_reported is not None: + return self.z_drive_speed_firmware_reported + return self.z_drive_increments_to_mm(self.z_speed_default_increments) + + @property + def z_drive_acceleration_default(self) -> float: + """Z-drive acceleration a move uses when the caller names none (mm/s2).""" + if self.z_drive_acceleration_firmware_reported is not None: + return self.z_drive_acceleration_firmware_reported + return self.z_drive_acceleration_increments_to_mm(self.z_acceleration_default_increments) + + # -- the windows the driver works in, from the increments the drives accept -------------------- + + @property + def y_range(self) -> Tuple[float, float]: + """Y-drive position window (mm), at channel A1. + + What the command accepts, which is wider than what a given device allows: what an arm reaches + depends on what else is mounted on it. + + Returns: + The (lowest, highest) Y position the drive reaches, in mm. + """ + low, high = self.y_range_increments + return (self.y_drive_increments_to_mm(low), self.y_drive_increments_to_mm(high)) + + @property + def y_speed_range(self) -> Tuple[float, float]: + """Y-drive speed window (mm/s).""" + low, high = self.y_speed_range_increments + return (self.y_drive_increments_to_mm(low), self.y_drive_increments_to_mm(high)) + + @property + def y_acceleration_range(self) -> Tuple[float, float]: + """Y-drive acceleration window (mm/s2).""" + low, high = self.y_acceleration_range_increments + return ( + self.y_drive_acceleration_increments_to_mm(low), + self.y_drive_acceleration_increments_to_mm(high), + ) + + @property + def z_speed_range(self) -> Tuple[float, float]: + """Z-drive speed window (mm/s).""" + low, high = self.z_speed_range_increments + return (self.z_drive_increments_to_mm(low), self.z_drive_increments_to_mm(high)) + + @property + def z_acceleration_range(self) -> Tuple[float, float]: + """Z-drive acceleration window (mm/s2).""" + low, high = self.z_acceleration_range_increments + return ( + self.z_drive_acceleration_increments_to_mm(low), + self.z_drive_acceleration_increments_to_mm(high), + ) + + @property + def channel_array_size_x(self) -> float: + """How wide the channel array is: the first column's centre to the last's, in mm. + + Returns: + The width, in mm. + """ + return (self.channel_columns - 1) * self.channel_pitch + + @property + def channel_array_size_y(self) -> float: + """How deep the channel array is: the first row's centre to the last's, in mm.""" + return (self.channel_rows - 1) * self.channel_pitch + + # -- conversions: the wire counts in increments, the driver speaks mm and uL ------------------- + + def y_drive_increments_to_mm(self, increments: int) -> float: + """A Y-drive position in mm, from the increments the drive counts in.""" + return round(increments * self.y_drive_mm_per_increment, 2) + + def y_drive_mm_to_increments(self, mm: float) -> int: + """A Y-drive position in increments, from mm.""" + return round(mm / self.y_drive_mm_per_increment) + + def y_drive_acceleration_increments_to_mm(self, increments: int) -> float: + """A Y-drive acceleration in mm/s2, from the increments the drive counts it in.""" + return round(increments * self.y_drive_acceleration_mm_per_increment, 2) + + def y_drive_acceleration_mm_to_increments(self, mm: float) -> int: + """A Y-drive acceleration in the increments the drive counts it in, from mm/s2.""" + return round(mm / self.y_drive_acceleration_mm_per_increment) + + def z_drive_increments_to_mm(self, increments: int) -> float: + """A Z-drive position in mm, from increments.""" + return round(increments * self.z_drive_mm_per_increment, 2) + + def z_drive_mm_to_increments(self, mm: float) -> int: + """A Z-drive position in increments, from mm.""" + return round(mm / self.z_drive_mm_per_increment) + + def z_drive_acceleration_increments_to_mm(self, increments: int) -> float: + """A Z-drive acceleration in mm/s2, from the increments the drive counts it in.""" + return round(increments * self.z_drive_acceleration_mm_per_increment, 2) + + def z_drive_acceleration_mm_to_increments(self, mm: float) -> int: + """A Z-drive acceleration in the increments the drive counts it in, from mm/s2.""" + return round(mm / self.z_drive_acceleration_mm_per_increment) + + def dispensing_drive_increments_to_uL(self, increments: int) -> float: + """A dispensing-drive position as the volume it holds, from increments.""" + return round(increments * self.dispensing_drive_uL_per_increment, 2) + + def dispensing_drive_uL_to_increments(self, uL: float) -> int: + """A dispensing-drive position in increments, from the volume to hold.""" + return round(uL / self.dispensing_drive_uL_per_increment) + + def dispensing_drive_increments_to_mm(self, increments: int) -> float: + """A dispensing-drive position as how far the piston has travelled, from increments.""" + return round(increments * self.dispensing_drive_mm_per_increment, 2) + + def dispensing_drive_mm_to_increments(self, mm: float) -> int: + """A dispensing-drive position in increments, from how far the piston should travel.""" + return round(mm / self.dispensing_drive_mm_per_increment) + + def squeezer_drive_increments_to_mm(self, increments: int) -> float: + """A squeezer-drive position in mm, from increments.""" + return round(increments * self.squeezer_drive_mm_per_increment, 2) + + def squeezer_drive_mm_to_increments(self, mm: float) -> int: + """A squeezer-drive position in increments, from mm.""" + return round(mm / self.squeezer_drive_mm_per_increment) + + +class Head: + """A head: the block of channels that works a whole plate at once. + + A head is addressed as its own module, but the commands that move it as a whole go to the + master, so this feature speaks to both. What differs between heads at this level its + configuration states - down to which module answers for it and how wide each parameter is + written; the commands themselves are the same. Tip handling and liquid handling are not at this + level and are not the same between the two, so each head carries its own. + """ + + configuration: HeadConfiguration + """The head's device facts. Each head narrows this to its own, so a subclass reads and writes + what only it has without anything having to be cast.""" + + def __init__(self, driver: "STARDriver", configuration: HeadConfiguration): + """ + Args: + driver: the driver to send commands through. + configuration: the head's device facts. + """ + self._driver = driver + # The head on the deck, when the driver was given one. Setup puts it there, as a child of the + # arm it rides; moves keep it in step. Without a deck it stays None and nothing is modelled. + self.resource: Optional[NChannelPipette] = None + self.configuration = configuration + + def require_drive_parameter(self, parameter: str) -> int: + """The width one of the head's stored drive parameters is written in. + + Args: + parameter: `yv` or `yr` for the Y drive's speed and acceleration, `zv` or `zr` for the Z + drive's. + + Returns: + How many digits it takes on the wire. + + Raises: + ValueError: If it is not one of those four. + """ + if parameter not in self.configuration.drive_parameters: + raise ValueError( + f"unknown drive parameter {parameter!r}, expected one of {tuple(self.configuration.drive_parameters)}" + ) + return self.configuration.drive_parameters[parameter] + + # ---------------------------------------- + # Setup + # ---------------------------------------- + + # -- discovery --------------------------------------------------------------------------------- + + async def request_firmware_version(self) -> Tuple[str, datetime.date]: + """Request the head's firmware version and build date. + + Returns: + The version string and its build date. + """ + resp = await self._driver.send_command(module=self.configuration.module, command="RF") + return resp.split("rf")[-1], parse_firmware_version_date(resp) + + async def request_hardware(self) -> List[str]: + """Request the head's configuration, undecoded. + + The head returns ten blank-separated decimal values, of which index 0 is clot monitoring with + cLLD on every head. What the rest mean is the head's own; `_record_hardware` decodes them. + + Returns: + The positional tokens, as reported. + """ + resp: str = await self._driver.send_command(module=self.configuration.module, command="QU") + return resp.split("au")[-1].split() + + def _apply_firmware_generation(self) -> None: + """Correct whatever depends on which firmware generation this head runs. + + Called once the version is known and before any drive is read, since a generation can change + what an increment is worth and how wide a parameter is written. + """ + + def _record_hardware(self, hardware: List[str]) -> None: + """Record what this head's configuration bytes mean, past the clot-monitoring flag at index 0. + + Args: + hardware: the tokens `request_hardware` read. + """ + raise NotImplementedError("a head decodes its own configuration bytes") + + async def request_head_type(self) -> str: + """Request which head is fitted. + + Returns: + The head type, or "unknown" for a code this driver does not know. + """ + resp = await self._driver.send_command( + module=self.configuration.module, command="QG", fmt="qg#" + ) + return self.configuration.head_types.get(cast(int, resp["qg"]), "unknown") + + async def request_x_offset(self) -> float: + """Request the X distance from the X-arm carriage center to head channel A1. + + Stored in the master EEPROM and read with the generic master-EEPROM read, mirroring the + iSWAP's rotation-drive offset. Needed to derive the carriage X from a target A1 X. + + Returns: + The offset in mm. + """ + # 4-digit field: a head's offset is ~10x the iSWAP's (hundreds of mm against ~34 mm), so it + # exceeds 3 digits in 0.1 mm units - a 3-digit field silently truncates 3684 -> 368. + parameter = self.configuration.x_offset_parameter + resp = await self._driver.send_command( + module="C0", command="RA", ra=parameter, fmt=f"{parameter}####" + ) + return cast(int, resp[parameter]) / 10.0 + + def _drive_parameter_to_mm(self, parameter: str, increments: int) -> float: + """One of the head's stored drive parameters in mm/s or mm/s2, from what the drive counts in. + + Args: + parameter: which parameter the value belongs to. + increments: the value as read. + + Returns: + The value in standard units. + """ + c = self.configuration + if parameter == "yv": + return c.y_drive_increments_to_mm(increments) + if parameter == "yr": + return c.y_drive_acceleration_increments_to_mm(increments) + if parameter == "zv": + return c.z_drive_increments_to_mm(increments) + if parameter in ("dv", "dr"): + return c.dispensing_drive_increments_to_uL(increments) + if parameter in ("sv", "sr"): + return c.squeezer_drive_increments_to_mm(increments) + return c.z_drive_acceleration_increments_to_mm(increments) + + def _drive_parameter_to_increments(self, parameter: str, value: float) -> int: + """One of the head's stored drive parameters in what the drive counts in, from mm/s or mm/s2. + + Args: + parameter: which parameter the value belongs to. + value: the value in standard units. + + Returns: + The value in increments. + """ + c = self.configuration + if parameter == "yv": + return c.y_drive_mm_to_increments(value) + if parameter == "yr": + return c.y_drive_acceleration_mm_to_increments(value) + if parameter == "zv": + return c.z_drive_mm_to_increments(value) + if parameter in ("dv", "dr"): + return c.dispensing_drive_uL_to_increments(value) + if parameter in ("sv", "sr"): + return c.squeezer_drive_mm_to_increments(value) + return c.z_drive_acceleration_mm_to_increments(value) + + async def request_drive_parameter(self, parameter: str) -> float: + """Request one of the head's stored drive parameters. + + Args: + parameter: the parameter to read - `yv` and `yr` for Y-drive speed and acceleration, `zv` + and `zr` for the Z drive. + + Returns: + The value in mm/s or mm/s2, converted from the increments the drive counts in. + + Raises: + ValueError: If the parameter is not one of the four drive parameters. + """ + width = self.require_drive_parameter(parameter) + resp = await self._driver.send_command( + module=self.configuration.module, command="RA", ra=parameter, fmt=f"{parameter}{'#' * width}" + ) + return self._drive_parameter_to_mm(parameter, cast(int, resp[parameter])) + + async def set_drive_parameter(self, parameter: str, value: float) -> None: + """Write one of the head's stored drive parameters. + + Args: + parameter: the parameter to write, named as `request_drive_parameter` names it. + value: the value in mm/s or mm/s2, converted to the increments the drive counts in. + + Raises: + ValueError: If the parameter is not one of the four drive parameters. + """ + width = self.require_drive_parameter(parameter) + increments = self._drive_parameter_to_increments(parameter, value) + written: Dict[str, Any] = {parameter: f"{increments:0{width}}"} + await self._driver.send_command(module=self.configuration.module, command="AA", **written) + + async def _reported_drive_parameter(self, parameter: str) -> Optional[float]: + """What the head currently holds for one drive parameter, or None if it will not say. + + A head that refuses keeps what its firmware documents rather than failing setup over a default. + """ + try: + return await self.request_drive_parameter(parameter) + except Exception: + logger.warning("the head did not report %s; keeping what its firmware documents", parameter) + return None + + async def discover(self): + """Read what head this is and what it can do. Read-only: nothing moves.""" + c = self.configuration + c.firmware_version, firmware_date = await self.request_firmware_version() + c.firmware_date = firmware_date + # Before anything reads a drive: what a parameter is worth, and how wide it is written, can + # depend on which generation this head runs, and the reads below use both. + self._apply_firmware_generation() + if firmware_date.year < self.configuration.first_documented_firmware_year: + logger.warning( + "this head reports %s firmware, older than the generation the drive windows and encoder " + "resolutions here were taken from. What its drives accept, the volumes it reports, and " + "the windows derived from them may be wrong. Set them on its configuration to correct it.", + firmware_date, + ) + + hardware = await self.request_hardware() + c.supports_clot_monitoring_clld = bool(int(hardware[0])) + self._record_hardware(hardware) + c.head_type = await self.request_head_type() + c.x_offset = await self.request_x_offset() + + c.y_drive_speed_firmware_reported = await self._reported_drive_parameter("yv") + c.y_drive_acceleration_firmware_reported = await self._reported_drive_parameter("yr") + c.z_drive_speed_firmware_reported = await self._reported_drive_parameter("zv") + c.z_drive_acceleration_firmware_reported = await self._reported_drive_parameter("zr") + + # The stored position tables are read here, not only by the two methods that return them in + # mm, so a configuration saved after setup carries them. Left out, they save as nothing, and + # a simulated head built from that file cannot answer where its drives are. + await self.request_predefined_y_positions() + await self.request_predefined_z_positions() + + def require_tip_discard_location(self, location: Optional[Coordinate]) -> Coordinate: + """Where tips are to be dropped, falling back to this head's configured trash. + + Args: + location: what the caller asked for, in deck mm at head channel A1, or None to use the + configured trash. + + Returns: + Where to drop them. + + Raises: + ValueError: If nothing was given and no trash is configured. + """ + if location is None: + location = self.configuration.tip_discard_location + if location is None: + raise ValueError( + "nowhere to discard tips: this head has no trash configured. Pass a location, or set " + "`configuration.tip_discard_location` to where its waste sits, at head channel A1." + ) + return location + + # -- initialization ---------------------------------------------------------------------------- + + async def initialize( + self, + tip_discard_location: Optional[Coordinate] = None, + z_position_at_the_command_end: Optional[float] = None, + read_timeout: int = 60, + ): + """Initialize the head, discarding whatever is mounted on it. + + This moves the head: it travels to the position given and ejects there, so that position must + be somewhere tips may be dropped. The firmware wants the location of the head's channel A1. + + Args: + tip_discard_location: where to eject, in deck mm, at head channel A1. Defaults to + `configuration.tip_discard_location`. + z_position_at_the_command_end: Z to leave the head at, in mm. Defaults to + `configuration.traversal_z_position`. + Raises: + ValueError: If no position was given and none is configured. + """ + if z_position_at_the_command_end is None: + z_position_at_the_command_end = self.configuration.traversal_z_position + tip_discard_location = self.require_tip_discard_location(tip_discard_location) + parameters: Dict[str, Any] = { + "xs": f"{abs(round(tip_discard_location.x * 10)):05}", + "xd": 0 if tip_discard_location.x >= 0 else 1, + self.configuration.y_parameter: f"{abs(round(tip_discard_location.y * 10)):04}", + self.configuration.z_parameter: f"{round(tip_discard_location.z * 10):04}", + self.configuration.z_end_parameter: f"{round(z_position_at_the_command_end * 10):04}", + } + return await self._driver.send_command( + module="C0", + command=self.configuration.initialize_command, + subsystem=self.configuration.module, + read_timeout=read_timeout, + **parameters, + ) + + # ---------------------------------------- + # Movement + # ---------------------------------------- + + # -- tips -------------------------------------------------------------------------------------- + + async def request_tip_presence(self) -> bool: + """Request what the firmware holds about the head carrying tips. + + Requested, not sensed: the head has no sleeve sensor, so this is the state the firmware wrote + for itself rather than a measurement of what is on the head. The channels have sensors and + `Pipettes.sense_tip_presence` measures; these two are deliberately different words for + deliberately different things. + + One bit for the whole head: the device counts tips as a rack, not as channels, so it can + say that some are mounted and never which. A model that tracks them per channel is finer than + anything this can confirm, and this is what it has to be reconciled against. + + Returns: + Whether the firmware holds that tips are mounted. + """ + command = self.configuration.tip_presence_command + field = command.lower() + resp = await self._driver.send_command( + module="C0", command=command, subsystem=self.configuration.module, fmt=f"{field}#" + ) + return cast(int, resp[field]) == 1 + + async def request_location(self) -> Coordinate: + """Measure where head channel A1 is, with whatever it carries taken into account. + + The master answers with the tip bottom rather than the drive's own reference, so with tips on + this reads lower than `request_z_position` by however far they stand proud of the head. With + none on, the two agree. Nothing is recorded: `request_z_position` is what the model follows, + and this is the reading it is checked against. + + Returns: + Where channel A1 is, in deck mm, at the bottom of whatever is mounted. + """ + c = self.configuration + resp = await self._driver.send_command( + module="C0", + command=c.position_command, + subsystem=self.configuration.module, + fmt=f"xs#####xd#{c.y_parameter}####{c.z_parameter}####", + ) + x = cast(int, resp["xs"]) / 10 + return Coordinate( + x=x if resp["xd"] == 0 else -x, + y=cast(int, resp[c.y_parameter]) / 10, + z=cast(int, resp[c.z_parameter]) / 10, + ) + + async def request_tip_overhang(self) -> float: + """Measure how far the tips the head carries stand below its own reference point. + + Both readings are of the same head at the same moment, so the difference is the overhang + without anything having to move: `request_z_position` reports the head's lowest fixed feature, + `request_location` reports the bottom of what is mounted on it. This is what a Z target has to + be offset by for the tip end, rather than the head, to land where it is wanted. + + Returns: + The overhang in mm. Legacy's tip length is this plus the tip's fitting depth. + + Raises: + RuntimeError: If the head is carrying no tips, so there is nothing to measure. + """ + if not await self.request_tip_presence(): + raise RuntimeError("the head reports no tips mounted, so there is no overhang to measure") + reference = await self.request_z_position() + tip_bottom = (await self.request_location()).z + return round(reference - tip_bottom, 2) + + # -- x position, carried by the arm the head rides --------------------------------------------- + + @property + def arm(self) -> Optional["XArm"]: + """The arm carrying this head, on a device that has put it on one. + + Not whichever arm is present: on a device with two, the head is on one of them and its X, its + travel and anything it might collide with are that one's. + + Returns: + The arm carrying this head, or None when nothing carries it. + """ + return next((a for a in self._driver.arms if a.head96 is self or a.head384 is self), None) + + async def request_x_position(self) -> float: + """Request where along X channel A1 is, in deck mm. + + The head has no X drive of its own: it rides the arm, and sits `configuration.x_offset` left of + the carriage reference point. So this asks the arm and applies the offset, rather than reading a + drive. Nothing is recorded either - the resource modelling the head is a child of the arm's, so + its X follows the arm without anything having to write it. + + Returns: + The position in mm. + + Raises: + RuntimeError: If no arm is installed, or the head's X offset was not read at discovery. + """ + arm = self.arm + if arm is None: + raise RuntimeError("this head is not on either arm; have you called `star.setup()`?") + if self.configuration.x_offset is None: + raise RuntimeError("the head's X offset was not read; have you called `star.setup()`?") + return round(await arm.request_position() - self.configuration.x_offset, 2) + + async def move_to_x_position( + self, + x: float, + acceleration_level: int = 3, + current_limit: int = 7, + settle_reads: int = 20, + ): + """Move channel A1 along X. The whole arm travels, with everything else it carries. + + The head has no X drive. It rides the arm and sits `configuration.x_offset` left of the + carriage reference point, so the arm is sent to the carriage position that puts A1 at `x`. + + Args: + x: where to put channel A1, in mm. + acceleration_level: how hard to accelerate, 1 to 4. + current_limit: the motor current limit, 1 to 7. + settle_reads: how many reads to take before calling the arm stopped. + + Raises: + ValueError: If the head cannot reach it. + RuntimeError: If no arm is installed, or the head's X offset was not read at discovery. + """ + self._check_reachable("x", x) + arm = self.arm + if arm is None: + raise RuntimeError("this head is not on either arm; have you called `star.setup()`?") + if self.configuration.x_offset is None: + raise RuntimeError("the head's X offset was not read; have you called `star.setup()`?") + return await arm.move_x( + round(x + self.configuration.x_offset, 2), + acceleration_level=acceleration_level, + current_limit=current_limit, + settle_reads=settle_reads, + ) + + # -- y position -------------------------------------------------------------------------------- + + async def request_y_position(self) -> float: + """Request where along Y the head is. + + The drive answers with two counters, the firmware's and the hardware's; the hardware's is what + this returns, as the Z read does. + + Returns: + The position in mm. + """ + resp = await self._driver.send_command( + module=self.configuration.module, command="RY", fmt="ry##### (n)" + ) + increments = cast(List[int], resp["ry"])[1] + y = self.configuration.y_drive_increments_to_mm(increments) + self.update_location_by_reference_point(y=y) + return y + + async def request_predefined_y_positions(self) -> List[float]: + """Request the Y positions the head has stored, in mm. + + The head keeps ten of them in non-volatile memory. The first is the home position the Y drive + parks at; the rest are further slots this feature sends no command against, so they are + returned as read rather than named. A head that stores them as offsets says so through + `configuration.predefined_y_position_origin`, which is added here so what comes back is + comparable with `request_y_position`. + + Records what came back on the configuration, so a head read once carries its table. + + Returns: + The ten stored positions in mm, the first being home. + """ + c = self.configuration + resp = await self._driver.send_command( + module=self.configuration.module, command="RA", ra="py", fmt="py##### (n)" + ) + increments = cast(List[int], resp["py"]) + c.predefined_y_positions_increments = dict(zip(c.predefined_y_slots, increments)) + return [c.y_drive_increments_to_mm(i + c.predefined_y_position_origin) for i in increments] + + async def park( + self, + speed: Optional[float] = None, + acceleration: Optional[float] = None, + ) -> float: + """Send the head to its park position. This moves it in Z, then in Y. + + In that order and separately, rather than through the firmware's own home command: the head + crosses the deck to get there, so it is raised clear first and only then moved across. Where it + parks is the first of the Y positions the head has stored, read rather than assumed, since an + adjusted head parks where its own memory says. + + Args: + speed: how fast to travel in Y, in mm/s. Defaults to the drive's own. + acceleration: how hard, in mm/s2. Defaults to the drive's own. + + Returns: + Where it parked, in mm. + """ + await self.move_to_safe_z() + park_position = (await self.request_predefined_y_positions())[0] + await self.move_to_y_position(park_position, speed=speed, acceleration=acceleration) + return park_position + + def update_location_by_reference_point( + self, y: Optional[float] = None, z: Optional[float] = None + ) -> None: + """Record where the head is on the resource that models it. + + Y and Z only: the head rides the arm, so its resource is a child of the arm's and follows it in + X without anything having to record that. Both drives report channel A1, and a resource is + located by its left front bottom corner, so what is recorded is offset by where A1's mounting + shaft sits inside the head - which is a measurement of the head, not its array's edge. + + Both drives answer in the deck's frame, and a resource's location is measured from its parent - + which for the head is the arm, not the deck. The two differ by wherever the arm sits, so the + arm's own position is taken out before either value is recorded. Does nothing when the driver + was given no deck, and so has nothing to model. + + Args: + y: where channel A1 is now, in mm on the deck. Left as it was when None. + z: where the head's lowest fixed feature is now, in mm on the deck. Left as it was when None. + """ + deck = self._driver.deck + if self.resource is None or self.resource.location is None or deck is None: + return + arm = self.resource.parent + if arm is None: + return + shaft = self.resource.get_item(HEAD_REFERENCE_SHAFT).location + if shaft is None: + return + here, on_the_arm = self.resource.location, arm.get_location_wrt(deck) + self.resource.location = Coordinate( + here.x, + here.y if y is None else y - on_the_arm.y - shaft.y, + here.z if z is None else z - on_the_arm.z - shaft.z, + ) + + def _check_reachable(self, axis: Literal["x", "y", "z"], value: float) -> None: + """Raise if the head cannot be sent where it is being asked to go. + + The one gate every position passes through, so that what the head is allowed to do is decided + in one place: travel limits now, and whatever else has to be true before it moves - a declared + side panel, what the other arm is doing, what is on the deck - as they are added. + + Each axis is bounded at its own reference point: channel A1 across X and Y, the head's lowest + fixed feature along Z. X is the arm's travel rather than a drive of the head's own, since the + head rides the arm and the arm's window already has any left side panel taken out of it. + + Args: + axis: which axis - `x` along the rail, `y` across the arm, `z` up and down. + value: where it would be sent, in mm. + + Raises: + ValueError: If the head cannot reach it. + RuntimeError: If the window was not resolved, so how far this head reaches is unknown. + """ + if axis == "x": + arm, x_offset = self.arm, self.configuration.x_offset + if arm is None or arm.configuration.x_range is None or x_offset is None: + raise RuntimeError("the head's X travel is not known; have you called `star.setup()`?") + # The arm's travel is its carriage's; A1 rides that far to the left of it. + arm_low, arm_high = arm.configuration.x_range + low, high = round(arm_low - x_offset, 2), round(arm_high - x_offset, 2) + elif axis == "y": + low, high = self.configuration.y_range + else: + low, high = self.configuration.z_range + if not low <= value <= high: + raise ValueError(f"{axis} must be between {low} and {high}, is {value}") + + def _check_move( + self, + axis: Literal["y", "z"], + value: float, + speed: float, + acceleration: float, + current_limit: int, + ) -> None: + """Raise unless every part of a move is inside what the drive accepts. + + Args: + axis: which drive - `y` across the arm, `z` up and down. + value: where it would be sent, in mm. + speed: how fast, in mm/s. + acceleration: how hard, in mm/s2. + current_limit: the motor current limit. + + Raises: + ValueError: If any of them is outside the drive's window. + RuntimeError: If the Z window was not resolved. + """ + c = self.configuration + self._check_reachable(axis, value) + speed_range = c.y_speed_range if axis == "y" else c.z_speed_range + acceleration_range = c.y_acceleration_range if axis == "y" else c.z_acceleration_range + for checked, (low, high), name in ( + (speed, speed_range, "speed"), + (acceleration, acceleration_range, "acceleration"), + ): + if not low <= checked <= high: + raise ValueError(f"{name} must be between {low} and {high}, is {checked}") + low_limit, high_limit = c.current_limit_range + if not low_limit <= current_limit <= high_limit: + raise ValueError( + f"current_limit must be between {low_limit} and {high_limit}, is {current_limit}" + ) + + async def _record_where_it_stopped(self, axis: Literal["y", "z"]) -> None: + """Read where this head came to rest along one axis, and record it. + + For a move's `finally`. A move that failed part way left the head somewhere no target + describes. Its own failure is logged and swallowed: it must not replace the move's exception, + which is the one that says what went wrong. + + Args: + axis: which axis the move drove - `y` across the arm, `z` up and down. + """ + try: + if axis == "y": + await self.request_y_position() + else: + await self.request_z_position() + except Exception: + logger.warning("could not read where the head stopped along %s; its model is stale", axis) + + async def move_to_y_position( + self, + y: float, + speed: Optional[float] = None, + acceleration: Optional[float] = None, + current_limit: Optional[int] = None, + read_timeout: int = 30, + ): + """Move the head along Y. This moves it, and nothing else on the arm. + + The move writes its speed and acceleration into the drive's volatile register, where later + moves would inherit them, so what was there is read first and put back afterwards - skipping + the write where the move's value already matches. + + Args: + y: where to move to, in mm. + speed: how fast, in mm/s. Defaults to `configuration.y_drive_speed_default`. + acceleration: how hard, in mm/s2. Defaults to `configuration.y_drive_acceleration_default`. + current_limit: the motor current limit. Defaults to + `configuration.y_drive_current_limit_default`. + Raises: + ValueError: If an argument is outside what the drive accepts. + """ + c = self.configuration + if speed is None: + speed = c.y_drive_speed_default + if acceleration is None: + acceleration = c.y_drive_acceleration_default + if current_limit is None: + current_limit = c.y_drive_current_limit_default + + self._check_move("y", y, speed, acceleration, current_limit) + was_speed = await self.request_drive_parameter("yv") + was_acceleration = await self.request_drive_parameter("yr") + try: + return await self._driver.send_command( + module=self.configuration.module, + command="YA", + ya=f"{c.y_drive_mm_to_increments(y):05}", + yv=f"{c.y_drive_mm_to_increments(speed):0{self.configuration.drive_parameters['yv']}}", + yr=f"{c.y_drive_acceleration_mm_to_increments(acceleration):0{self.configuration.drive_parameters['yr']}}", + yw=f"{current_limit:0{len(str(c.current_limit_range[1]))}}", + read_timeout=read_timeout, + ) + finally: + await self._record_where_it_stopped("y") + await self._restore_drive_parameter("yv", speed, was_speed) + await self._restore_drive_parameter("yr", acceleration, was_acceleration) + + async def _restore_drive_parameter(self, parameter: str, written: float, was: float) -> None: + """Put back what a move overwrote in the drive's volatile register. + + Compared in increments rather than in mm, because that is what the drive holds: two values + that round to the same increment are the same write. + + Args: + parameter: the parameter the move wrote. + written: what the move wrote, in standard units. + was: what was there before, in standard units. + """ + if self._drive_parameter_to_increments( + parameter, written + ) != self._drive_parameter_to_increments(parameter, was): + await self.set_drive_parameter(parameter, was) + + # -- z position -------------------------------------------------------------------------------- + + async def request_z_position(self) -> float: + """Request the head's Z-drive position, at its lowest fixed feature. + + This is the raw drive position regardless of tip state, not the tip bottom. + + Returns: + The position in mm. + """ + resp = await self._driver.send_command( + module=self.configuration.module, command="RZ", fmt="rz##### (n)" + ) + increments = cast(List[int], resp["rz"])[1] # [0] = firmware counter, [1] = hardware counter + z = self.configuration.z_drive_increments_to_mm(increments) + self.update_location_by_reference_point(z=z) + return z + + async def request_predefined_z_positions(self) -> List[float]: + """Request the Z positions the head has stored, in mm. + + The head keeps ten of them in non-volatile memory. The first is the home position the Z drive + parks at; the rest are further slots this feature sends no command against, so they are + returned as read rather than named. A head that stores them as offsets says so through + `configuration.predefined_z_position_origin`, which is added here. These are positions of the + head's lowest fixed feature, as `request_z_position` is. + + Records what came back on the configuration, so a head read once carries its table. + + Returns: + The ten stored positions in mm, the first being home. + """ + c = self.configuration + resp = await self._driver.send_command( + module=self.configuration.module, command="RA", ra="pz", fmt="pz##### (n)" + ) + increments = cast(List[int], resp["pz"]) + c.predefined_z_positions_increments = dict(zip(c.predefined_z_slots, increments)) + return [c.z_drive_increments_to_mm(i + c.predefined_z_position_origin) for i in increments] + + async def probe_z_max(self, read_timeout: int = 30) -> float: + """Retracts the head with the firmware's own retract and reads its stop disc z-position. + + Informs the max of `configuration.z_range` during setup. + + Args: + read_timeout: how long to wait for the retract, in seconds. + + Returns: + float: The z-position of the head's stop disc, in mm. + """ + await self._driver.send_command( + module="C0", + command=self.configuration.retract_command, + subsystem=self.configuration.module, + read_timeout=read_timeout, + ) + return await self.request_z_position() + + async def _unchecked_fw_move_to_coordinate( + self, + coordinate: Coordinate, + minimum_height_at_beginning_of_a_command: float = 342.5, + ): + """Move the head to a defined coordinate. Nothing is guarded and nothing is recorded. + + One command for all three axes, where this driver sends one per axis. Kept for cross-testing + the two against each other on a device. + + Args: + coordinate: coordinate of A1 in mm - the tip bottom on a head carrying tips, the channel + bottom on one that is not. + minimum_height_at_beginning_of_a_command: the height every channel is at before it travels, + in mm, whatever the tip pattern says. + """ + return await self._driver.send_command( + module="C0", + command="EM", + xs=f"{abs(round(coordinate.x * 10)):05}", + xd="0" if coordinate.x >= 0 else "1", + yh=f"{round(coordinate.y * 10):04}", + za=f"{round(coordinate.z * 10):04}", + zh=f"{round(minimum_height_at_beginning_of_a_command * 10):04}", + ) + + async def move_stop_disc_to_z_position( + self, + z: float, + speed: Optional[float] = None, + acceleration: Optional[float] = None, + current_limit: Optional[int] = None, + read_timeout: int = 30, + ): + """Move the head's stop disc along Z. This moves it, and nothing else on the arm. + + The head's own drive, which is commanded in stop-disc terms whatever is mounted on it. Use it + for moves with nothing on the head; `move_tool_bottom_to_z_position` places the bottom of the + tips it carries. + + The move writes its speed and acceleration into the drive's volatile register, where later + moves would inherit them, so what was there is read first and put back afterwards - skipping + the write where the move's value already matches. + + Args: + z: where to move the head's lowest fixed feature to, in mm. + speed: how fast, in mm/s. Defaults to `configuration.z_drive_speed_default`. + acceleration: how hard, in mm/s2. Defaults to `configuration.z_drive_acceleration_default`. + current_limit: the motor current limit. Defaults to + `configuration.z_drive_current_limit_default`. + Raises: + ValueError: If an argument is outside what the drive accepts. + """ + c = self.configuration + if speed is None: + speed = c.z_drive_speed_default + if acceleration is None: + acceleration = c.z_drive_acceleration_default + if current_limit is None: + current_limit = c.z_drive_current_limit_default + + self._check_move("z", z, speed, acceleration, current_limit) + was_speed = await self.request_drive_parameter("zv") + was_acceleration = await self.request_drive_parameter("zr") + try: + return await self._driver.send_command( + module=self.configuration.module, + command="ZA", + za=f"{c.z_drive_mm_to_increments(z):05}", + zv=f"{c.z_drive_mm_to_increments(speed):0{self.configuration.drive_parameters['zv']}}", + zr=f"{c.z_drive_acceleration_mm_to_increments(acceleration):0{self.configuration.drive_parameters['zr']}}", + zw=f"{current_limit:0{len(str(c.current_limit_range[1]))}}", + read_timeout=read_timeout, + ) + finally: + await self._record_where_it_stopped("z") + await self._restore_drive_parameter("zv", speed, was_speed) + await self._restore_drive_parameter("zr", acceleration, was_acceleration) + + async def move_tool_bottom_to_z_position( + self, + z: float, + speed: Optional[float] = None, + acceleration: Optional[float] = None, + current_limit: Optional[int] = None, + read_timeout: int = 30, + ): + """Move the bottom of what the head carries along Z. This moves it, and nothing else on the arm. + + The drive is commanded in stop-disc terms whatever is mounted, so this measures how far the + tips stand below the head and offsets the target by that: the same move as + `move_stop_disc_to_z_position`, named at the end that reaches into a well. It needs tips on the + head, which are what give it a bottom distinct from its own. + + Args: + z: where to move the bottom of the mounted tips to, in deck mm. + speed: how fast, in mm/s. Defaults to `configuration.z_drive_speed_default`. + acceleration: how hard, in mm/s2. Defaults to `configuration.z_drive_acceleration_default`. + current_limit: the motor current limit. Defaults to + `configuration.z_drive_current_limit_default`. + read_timeout: how long to wait for the move, in seconds. + + Raises: + ValueError: If the head carries no tips, or it cannot put their bottom at `z`. + """ + c = self.configuration + try: + overhang = await self.request_tip_overhang() + except RuntimeError as no_tips: + raise ValueError( + "the head carries no tips, so it has no tool bottom to place; " + "`move_stop_disc_to_z_position` is the move for a head with nothing on it" + ) from no_tips + + # The drive works in stop-disc terms over `z_range`, so what the tip bottom reaches is that + # window shifted down by the overhang, and no lower than the head may put one. + low = round(max(c.z_range[0] - overhang, c.min_tool_bottom_z), 2) + high = round(c.z_range[1] - overhang, 2) + if not low <= z <= high: + raise ValueError( + f"the tool bottom reaches {low} to {high} mm with a {overhang} mm overhang, not {z}" + ) + + return await self.move_stop_disc_to_z_position( + z + overhang, + speed=speed, + acceleration=acceleration, + current_limit=current_limit, + read_timeout=read_timeout, + ) + + async def move_to_safe_z( + self, + speed: Optional[float] = None, + acceleration: Optional[float] = None, + ) -> float: + """Move the head up to its safe Z: the top of the window setup probed. + + The precondition for any lateral move, so it runs often. An ordinary Z move to a known height, + not a command of its own - so it is bounded, and its speed and acceleration are the caller's + like any other move. The firmware's own retract, which `probe_z_max` sends, is what + establishes the height this moves to; with no window probed yet this falls back to it. + + Args: + speed: how fast, in mm/s. Defaults to `configuration.z_drive_speed_default`. + acceleration: how hard, in mm/s2. Defaults to `configuration.z_drive_acceleration_default`. + + Returns: + The Z position at the safety height, in mm. + """ + z_range = self.configuration.z_range + await self.move_stop_disc_to_z_position(z_range[1], speed=speed, acceleration=acceleration) + return await self.request_z_position() + + # -- dispensing drive -------------------------------------------------------------------------- + + # ---------------------------------------- + # Probing + # ---------------------------------------- + + # -- z probing (capacitive) -------------------------------------------------------------------- + + # TODO: _unchecked_fw_ vs tip-presence-guarded versions (caveat: the head has no sleeve sensor, + # so it only knows tip state based on firmware-written/stored state) diff --git a/pylabrobot/hamilton/star/driver/features/head384.py b/pylabrobot/hamilton/star/driver/features/head384.py new file mode 100644 index 00000000000..730b33c4c87 --- /dev/null +++ b/pylabrobot/hamilton/star/driver/features/head384.py @@ -0,0 +1,206 @@ +"""The 384-head: the block of 384 dispensing channels that works a whole plate at once.""" + +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple + +from pylabrobot.hamilton.star.driver.features.head import Head, HeadConfiguration + +if TYPE_CHECKING: + from pylabrobot.hamilton.star.driver.master import STARDriver + +logger = logging.getLogger(__name__) + + +@dataclass +class Head384Configuration(HeadConfiguration): + """Device facts for the installed 384-head. + + What this head adds to `HeadConfiguration` is what it reports about itself beyond the shared + flags, and the head type that resolves what a dispensing or squeezer increment is worth: the + three heads share a piston travel but not a bore, and are geared differently. + + Its drive windows do not move with firmware, so they are plain values rather than properties - + what varies here is which head is fitted, not the generation. + """ + + module: str = "D0" + """What the drive documents; no 384-head has been probed.""" + retract_command: str = "JV" + initialize_command: str = "JI" + tip_presence_command: str = "QK" + position_command: str = "QJ" + y_parameter: str = "yk" + z_parameter: str = "je" + z_end_parameter: str = "zg" + x_offset_parameter: str = "kd" + head_types: Dict[int, str] = field( + default_factory=lambda: { + 0: "Low volume head", + 1: "High volume head", + 2: "STP head", # shifted tip pickup + } + ) + drive_parameters: Dict[str, int] = field( + default_factory=lambda: {"yv": 5, "yr": 3, "zv": 5, "zr": 3} + ) + # The generation the drive windows below were taken from. A head older than this documents + # different ones, and nothing here resolves them per generation. + first_documented_firmware_year: int = 2009 + + supports_lld_absolute_threshold_check: Optional[bool] = None + + channel_pitch: float = 4.5 + channel_columns: int = 24 + channel_rows: int = 16 + + dispensing_drive_mm_per_increment: float = 0.00063333 + + # This head's Y and Z drives count acceleration in thousands of increments per second squared, + # unlike the positions and speeds they count in single ones, so these are 1000x the position + # resolutions. + y_drive_acceleration_mm_per_increment: float = 15.625 + z_drive_acceleration_mm_per_increment: float = 5.0 + + y_range_increments: Tuple[int, int] = (7100, 36100) # type: ignore[assignment] + y_speed_range_increments: Tuple[int, int] = (50, 20000) # type: ignore[assignment] + y_acceleration_range_increments: Tuple[int, int] = (5, 32) # type: ignore[assignment] + z_range_increments: Tuple[int, int] = (33200, 67200) # type: ignore[assignment] + z_acceleration_range_increments: Tuple[int, int] = (5, 100) + + # What this head's drives start from. Its accelerations are counted in thousands, so those two + # are written small where the 96-head's are not. + y_speed_default_increments: int = 20000 + y_acceleration_default_increments: int = 32 + z_acceleration_default_increments: int = 80 + + predefined_y_position_origin: int = 22000 + predefined_z_position_origin: int = 35000 + + y_drive_current_limit_default: int = 4 + z_drive_current_limit_default: int = 7 + current_limit_range: Tuple[int, int] = (0, 7) + + def _require_head_type(self) -> str: + """The head type, for the facts only it decides. + + Returns: + Which head is fitted. + + Raises: + RuntimeError: If it has not been read, or is one this driver does not know. + """ + if self.head_type is None or self.head_type == "unknown": + raise RuntimeError( + "the 384-head's type is not known, and it is what decides how much a dispensing or " + "squeezer increment is worth; have you called `star.setup()`?" + ) + return self.head_type + + @property + def dispensing_drive_uL_per_increment(self) -> float: + """What one increment of the dispensing drive holds, in uL. + + The three heads share a piston travel but not a bore, so this is the head type's to decide and + is not known until the head has said which it is - guessing would mis-volume every aspirate. + + Returns: + The volume one increment holds, in uL. + + Raises: + RuntimeError: If the head type has not been read. + """ + head_type = self._require_head_type() + if head_type == "Low volume head": + return 0.000974941 + if head_type == "High volume head": + return 0.00143754 + return 0.00186531 + + @property + def squeezer_drive_mm_per_increment(self) -> float: + """How far one increment of the squeezer drive travels, in mm. + + Geared differently on the low volume head, so this is the head type's to decide as the + dispensing volume above is. + + Returns: + The distance one increment travels, in mm. + + Raises: + RuntimeError: If the head type has not been read. + """ + return 0.00091813 if self._require_head_type() == "Low volume head" else 0.00035866 + + # -- windows the dispensing and squeezer drives work in ---------------------------------------- + + @property + def dispensing_drive_range(self) -> Tuple[float, float]: + """Aspirate/dispense piston volume window (uL); applies to both aspirate and dispense.""" + return (0.0, self.dispensing_drive_increments_to_uL(60950)) + + @property + def dispensing_drive_speed_range(self) -> Tuple[float, float]: + """Dispensing-drive speed window (uL/s).""" + # The drive counts its speed in tens of increments per second, so both ends are scaled. + return ( + self.dispensing_drive_increments_to_uL(5 * 10), + self.dispensing_drive_increments_to_uL(25000 * 10), + ) + + @property + def dispensing_drive_speed_default(self) -> float: + """Dispensing-drive default speed (uL/s).""" + return self.dispensing_drive_increments_to_uL(50000) + + @property + def dispensing_drive_acceleration_default(self) -> float: + """Dispensing-drive default acceleration (uL/s2).""" + return self.dispensing_drive_increments_to_uL(9000000) + + @property + def squeezer_drive_speed_default(self) -> float: + """Squeezer-drive default speed (mm/s); the low volume head runs slower.""" + increments = 16000 if self._require_head_type() == "Low volume head" else 40000 + return self.squeezer_drive_increments_to_mm(increments) + + @property + def squeezer_drive_acceleration_default(self) -> float: + """Squeezer-drive default acceleration (mm/s2); the low volume head runs gentler.""" + increments = 100000 if self._require_head_type() == "Low volume head" else 250000 + return self.squeezer_drive_increments_to_mm(increments) + + +class Head384(Head): + """The 384-head. + + Reached as `driver.head384`, on a device that has one. It is addressed as `D0`, but the + commands that move it go to the master, so this feature speaks to both. + """ + + configuration: Head384Configuration + + def __init__(self, driver: "STARDriver", configuration: Optional[Head384Configuration] = None): + """ + Args: + driver: the driver to send commands through. + configuration: the head's device facts. Defaults to `Head384Configuration()`. + """ + super().__init__(driver, configuration or Head384Configuration()) + + # ---------------------------------------- + # Setup + # ---------------------------------------- + + # -- discovery --------------------------------------------------------------------------------- + + def _record_hardware(self, hardware: List[str]) -> None: + """Record whether this head runs the absolute-threshold cLLD check. + + Index 1 was reserve until 2015, so a head older than that reads back 0 there whether or not it + would do the check. + + Args: + hardware: the tokens `request_hardware` read. + """ + self.configuration.supports_lld_absolute_threshold_check = bool(int(hardware[1])) diff --git a/pylabrobot/hamilton/star/driver/features/head96.py b/pylabrobot/hamilton/star/driver/features/head96.py new file mode 100644 index 00000000000..481ab748e9f --- /dev/null +++ b/pylabrobot/hamilton/star/driver/features/head96.py @@ -0,0 +1,377 @@ +"""The 96-head: the block of 96 pipettes that works a whole plate at once.""" + +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple + +from pylabrobot.hamilton.star.driver.features.head import Head, HeadConfiguration +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.resource import Resource + +if TYPE_CHECKING: + from pylabrobot.hamilton.star.driver.master import STARDriver + +logger = logging.getLogger(__name__) + +StopDiscType = Literal["core_i", "core_ii"] +InstrumentType = Literal["legacy", "FM-STAR"] + + +@dataclass +class Head96Configuration(HeadConfiguration): + """Device facts for the installed 96-head. + + Ported from the legacy `Head96Information`. What this head adds to `HeadConfiguration` is what + it reports about itself beyond the shared flags, and the firmware generation that resolves its + drive windows: the encodings shifted at 2013, and a head reports which side of that it is on. + """ + + module: str = "H0" + """What a real 96-head reached when it was probed.""" + retract_command: str = "EV" + initialize_command: str = "EI" + tip_presence_command: str = "QH" + position_command: str = "QI" + y_parameter: str = "yh" + z_parameter: str = "za" + z_end_parameter: str = "ze" + x_offset_parameter: str = "kf" + head_types: Dict[int, str] = field( + default_factory=lambda: { + 0: "Low volume head", + 1: "High volume head", + 2: "96 head II", + 3: "96 head TADM", + } + ) + # The 2013-or-later widths. A 2008 head writes its accelerations narrower, and + # `_apply_firmware_generation` swaps these for that head's. + drive_parameters: Dict[str, int] = field( + default_factory=lambda: {"yv": 5, "yr": 5, "zv": 5, "zr": 6, "dv": 5, "dr": 6, "sv": 5, "sr": 6} + ) + # The generation the dispensing and squeezer resolutions below were taken from. A head older + # than this has different ones, and `_apply_firmware_generation` resolves them. + first_documented_firmware_year: int = 2010 + + # As on the base: what the head reported, standing in front of the derived values below. + dispensing_drive_speed_firmware_reported: Optional[float] = None + dispensing_drive_acceleration_firmware_reported: Optional[float] = None + squeezer_drive_speed_firmware_reported: Optional[float] = None + squeezer_drive_acceleration_firmware_reported: Optional[float] = None + + stop_disc_type: Optional[StopDiscType] = None + instrument_type: Optional[InstrumentType] = None + + # Encoder resolutions of the 2013-or-later generation; a 2008-era head's differ, and nothing + # resolves them per generation, so they are left settable. + dispensing_drive_uL_per_increment: float = 0.019340933 # type: ignore[assignment] + squeezer_drive_mm_per_increment: float = 0.0002086672009 # type: ignore[assignment] + + # The Y window the master's tip commands accept, in deck mm at head channel A1. Narrower than + # what the Y drive itself reaches, and narrower than the initialization command's own window, so + # it is stated here rather than taken from `y_range`. + tip_command_y_range: Tuple[float, float] = (108.0, 560.0) + + # How far the tips a rack holds stand proud of it once mounted, by tip size. The head has to + # descend by the tip's length past its fitting depth to seat it, and the two odd sizes need a + # correction on top. + tip_engage_correction_low_volume: float = 2.0 + tip_engage_correction_other: float = -2.0 + # How far above a tip rack's own top the head releases tips onto it, in mm. + tip_drop_clearance: float = 1.45 + + # Where the dispensing drive is sent before tips are collected off a rack, as a piston volume in + # uL. The device does not lower the drive itself, so a head left with its piston up would + # mount tips against it. + dispensing_drive_position_before_rack_pickup: float = 218.19 + + y_increment_floor: int = 6528 + """The lowest Y the drive accepts, in increments - 102.000 mm exactly. + + Found empirically, not from any document or read: the command's own window starts at 6000, but + the drive refuses everything below this as outside its permitted area. Bisecting on a 2021 head + put the edge here, with 6527 refused and 6528 accepted. + + It is hardcoded because nothing on the device reports it. Every parameter the head and the + master will answer for was read - 499 of them - and none carries this value in any encoding, so a + head that enforces a different floor has to have it set here. That it lands on a round number of + millimetres, where a stored adjustment would land anywhere, is the reason to expect it constant + across heads rather than particular to this one.""" + + @property + def firmware_year(self) -> int: + """The year the head's firmware was built, which resolves the windows below. + + Returns: + The year, as the firmware's own date gives it. + + Raises: + RuntimeError: If the firmware version has not been read. + """ + if self.firmware_date is None: + raise RuntimeError("96-head firmware version not read; have you called `star.setup()`?") + return self.firmware_date.year + + # -- what the head supplies to the shared windows ---------------------------------------------- + + @property + def z_range_increments(self) -> Tuple[int, int]: + """Z-drive position window in increments; FM-STAR reaches both further down and further up. + + Returns: + The (lowest, highest) Z position, in increments. + """ + if self.instrument_type == "FM-STAR": + return (24200, 76200) + return (36100, 68500) + + @property + def y_range_increments(self) -> Tuple[int, int]: + """Y-drive position window in increments, at channel A1. + + The floor is `y_increment_floor` rather than the 6000 the command documents, because the drive + refuses everything below it. The 2008 range is as documented and has not been measured. + + Returns: + The (lowest, highest) Y position, in increments. + """ + if self.firmware_year >= 2010: + return (self.y_increment_floor, 36000) + return (7000, 36200) + + @property + def y_speed_range_increments(self) -> Tuple[int, int]: + """Y-drive speed window in increments. The pre-2021 max (25000, the firmware default) is an + empirical, deck-tested cap; per firmware version the maxima are 20000 (2008) and 40000 (2013+). + + Returns: + The (lowest, highest) speed, in increments. + Verify on a pre-2021 head before raising it.""" + return (50, 25000 if self.firmware_year <= 2021 else 40000) + + @property + def y_acceleration_range_increments(self) -> Tuple[int, int]: + """Y-drive acceleration window in increments. The min is constant; the max rose from 32000 + + Returns: + The (lowest, highest) acceleration, in increments. + (2008) to 50000 (2013+), so it tracks firmware like the Y range / speed.""" + return (5000, 50000 if self.firmware_year >= 2010 else 32000) + + # -- windows the dispensing and squeezer drives work in ---------------------------------------- + + @property + def dispensing_drive_range(self) -> Tuple[float, float]: + """Aspirate/dispense piston volume window (uL); applies to both aspirate and dispense. 2013 + + Returns: + The (lowest, highest) volume, in uL. + firmware widened the max from 62130 inc.""" + max_inc = 64350 if self.firmware_year >= 2010 else 62130 + return (0.0, self.dispensing_drive_increments_to_uL(max_inc)) + + @property + def dispensing_drive_speed_range(self) -> Tuple[float, float]: + """Dispensing-drive speed window (uL/s); 2013 firmware widened the max from 52000 inc.""" + min_inc = 5 # firmware dv minimum (00005 increments/second) + max_inc = 55000 if self.firmware_year >= 2010 else 52000 + return ( + self.dispensing_drive_increments_to_uL(min_inc), + self.dispensing_drive_increments_to_uL(max_inc), + ) + + @property + def dispensing_drive_speed_default(self) -> float: + """Dispensing-drive default speed (uL/s); constant across firmware.""" + if self.dispensing_drive_speed_firmware_reported is not None: + return self.dispensing_drive_speed_firmware_reported + return 261.1 + + @property + def dispensing_drive_acceleration_range(self) -> Tuple[float, float]: + """Dispensing-drive acceleration window (uL/s2); its max is the default 2013 firmware raised.""" + max_inc = 900000 if self.firmware_year >= 2010 else 150000 + return ( + self.dispensing_drive_increments_to_uL(5000), + self.dispensing_drive_increments_to_uL(max_inc), + ) + + @property + def dispensing_drive_acceleration_default(self) -> float: + """Dispensing-drive default acceleration (uL/s2); 2013 firmware raised it.""" + if self.dispensing_drive_acceleration_firmware_reported is not None: + return self.dispensing_drive_acceleration_firmware_reported + increments = 900000 if self.firmware_year >= 2010 else 150000 + return self.dispensing_drive_increments_to_uL(increments) + + @property + def squeezer_drive_speed_default(self) -> float: + """Squeezer-drive default speed (mm/s); 2013 firmware raised it.""" + if self.squeezer_drive_speed_firmware_reported is not None: + return self.squeezer_drive_speed_firmware_reported + increments = 76000 if self.firmware_year >= 2010 else 16000 + return self.squeezer_drive_increments_to_mm(increments) + + @property + def squeezer_drive_acceleration_default(self) -> float: + """Squeezer-drive default acceleration (mm/s2); 2013 firmware raised it.""" + if self.squeezer_drive_acceleration_firmware_reported is not None: + return self.squeezer_drive_acceleration_firmware_reported + increments = 300000 if self.firmware_year >= 2010 else 100000 + return self.squeezer_drive_increments_to_mm(increments) + + +class Head96(Head): + """The 96-head. + + Reached as `driver.head96`, on a device that has one. It is addressed as `H0`, but the + commands that move it go to the master, so this feature speaks to both. + """ + + configuration: Head96Configuration + + def __init__(self, driver: "STARDriver", configuration: Optional[Head96Configuration] = None): + """ + Args: + driver: the driver to send commands through. + configuration: the head's device facts. Defaults to `Head96Configuration()`. + """ + super().__init__(driver, configuration or Head96Configuration()) + + # ---------------------------------------- + # Setup + # ---------------------------------------- + + # -- discovery --------------------------------------------------------------------------------- + + def _apply_firmware_generation(self) -> None: + """Put the pre-2013 encodings in place on a head that runs them. + + Those heads count every drive's acceleration in thousands of increments per second squared and + write it in a narrower field; from 2013 the same parameter is single increments in a wider one. + Nothing else about the head announces which it is, so the firmware date decides. + """ + c = self.configuration + if c.firmware_year >= 2010: + return + c.y_drive_acceleration_mm_per_increment = c.y_drive_mm_per_increment * 1000 + c.z_drive_acceleration_mm_per_increment = c.z_drive_mm_per_increment * 1000 + c.drive_parameters = {"yv": 5, "yr": 3, "zv": 5, "zr": 3, "dv": 5, "dr": 4, "sv": 5, "sr": 3} + + async def discover(self): + """Read what head this is, then take its dispensing and squeezer defaults from the head.""" + await super().discover() + c = self.configuration + c.dispensing_drive_speed_firmware_reported = await self._reported_drive_parameter("dv") + c.dispensing_drive_acceleration_firmware_reported = await self._reported_drive_parameter("dr") + c.squeezer_drive_speed_firmware_reported = await self._reported_drive_parameter("sv") + c.squeezer_drive_acceleration_firmware_reported = await self._reported_drive_parameter("sr") + + def _record_hardware(self, hardware: List[str]) -> None: + """Record the stop disc and device type this head reports. + + Index 1 is populated on firmware at least back to 2021. Whether index 2 is reliably populated + on every build, or on some falls back to reserve (read back as 0 -> legacy), is unverified; + confirm on an FM-STAR head before relying on it to unlock the FM-STAR z-range extension. + + Args: + hardware: the tokens `request_hardware` read. + """ + c = self.configuration + c.stop_disc_type = "core_i" if hardware[1] == "0" else "core_ii" + c.instrument_type = "legacy" if hardware[2] == "0" else "FM-STAR" + + # ---------------------------------------- + # Movement + # ---------------------------------------- + + # -- dispensing drive -------------------------------------------------------------------------- + + # ---------------------------------------- + # Tip pickup and drop + # ---------------------------------------- + + # -- where the head goes ----------------------------------------------------------------------- + + def _position_centred_in(self, resource: Resource) -> Coordinate: + """Where head channel A1 lands with the head centred over a resource, in deck mm. + + The head is rigid and the resource is whatever it is being pointed at, so the array is put in + the middle of it and A1 falls half a channel pitch in from the array's own corner. + + Args: + resource: what to centre over. + + Returns: + The A1 position, in deck mm, at the resource's own Z. + + Raises: + RuntimeError: If the driver was given no deck, so the resource has no deck position. + """ + deck = self._driver.deck + if deck is None: + raise RuntimeError("this driver has no deck, so a resource has no position to centre in") + c = self.configuration + location = resource.get_location_wrt(deck) + return Coordinate( + location.x + (resource.get_size_x() - c.channel_array_size_x) / 2 + c.channel_pitch / 2, + location.y + (resource.get_size_y() - c.channel_array_size_y) / 2 + c.channel_pitch / 2, + location.z, + ) + + def _resolve_tip_command_heights( + self, + minimum_traverse_z_position_at_the_command_start: Optional[float], + minimum_z_position_at_the_command_end: Optional[float], + ) -> Tuple[float, float]: + """The two heights a tip command travels at, defaulted where the caller named neither. + + Args: + minimum_traverse_z_position_at_the_command_start: how high the head travels to get there. + minimum_z_position_at_the_command_end: the height to leave the head at. + + Returns: + The two, in mm, with `configuration.traversal_z_position` where None was given. + """ + traversal = self.configuration.traversal_z_position + if minimum_traverse_z_position_at_the_command_start is None: + minimum_traverse_z_position_at_the_command_start = traversal + if minimum_z_position_at_the_command_end is None: + minimum_z_position_at_the_command_end = traversal + return ( + minimum_traverse_z_position_at_the_command_start, + minimum_z_position_at_the_command_end, + ) + + def _check_tip_command( + self, location: Coordinate, traverse_z: float, end_z: float, skip_z: bool = False + ) -> None: + """Raise unless a tip command may run where it is being pointed. + + Reachability is `_check_reachable`'s to answer, so X, Z and the two heights go through it. What + is left here is the one thing it does not cover: the Y window these commands accept is narrower + than what the Y drive reaches, so a position the head could physically get to may still be + refused by the command. + + Args: + location: where the command would send head channel A1, in deck mm. + traverse_z: the traverse height it would use, in mm. + end_z: the height it would leave the head at, in mm. + skip_z: leave the position's Z unchecked, for a command that resolves it separately. + + Raises: + ValueError: If a position is out of reach or outside the command's Y window. + RuntimeError: If the windows were not resolved. + """ + self._check_reachable("x", location.x) + if not skip_z: + self._check_reachable("z", location.z) + self._check_reachable("z", traverse_z) + self._check_reachable("z", end_z) + low, high = self.configuration.tip_command_y_range + if not low <= location.y <= high: + raise ValueError(f"y must be between {low} and {high}, is {location.y}") + + # -- pickup ------------------------------------------------------------------------------------ + + # -- drop -------------------------------------------------------------------------------------- diff --git a/pylabrobot/hamilton/star/driver/features/head_tests.py b/pylabrobot/hamilton/star/driver/features/head_tests.py new file mode 100644 index 00000000000..8a9b05d8061 --- /dev/null +++ b/pylabrobot/hamilton/star/driver/features/head_tests.py @@ -0,0 +1,141 @@ +import dataclasses +import json +import pathlib +import tempfile +import unittest +from typing import cast + +from pylabrobot.hamilton.star.device import RECORDING_STAR +from pylabrobot.hamilton.star.driver.configuration import read_configuration, to_jsonable +from pylabrobot.hamilton.star.driver.features.head96 import Head96, Head96Configuration +from pylabrobot.hamilton.star.driver.features.x_arm import XArm +from pylabrobot.hamilton.star.driver.simulator import STARSimulationDriver +from pylabrobot.resources.hamilton import STARDeck + +# The 96-head on the device this package ships a recording of. +RECORDED_HEAD96 = cast( + Head96Configuration, read_configuration(RECORDING_STAR)["arms"]["left"]["head96"] +) + + +def declaring(**parts: object) -> str: + """The shipped recording with parts swapped out, written where it can be read back. + + A declaration is read from a file and nothing else, so a test that needs a device no recording + describes writes one. Everything not named here stays as the recorded STAR has it. + + Args: + parts: `device` for the device itself, or a feature name for something the left arm carries. + + Returns: + The path it was written to. + """ + tree = json.loads(pathlib.Path(RECORDING_STAR).read_text()) + for name, part in parts.items(): + if name == "device": + tree["device"] = to_jsonable(part) + else: + tree["arms"]["left"][name] = to_jsonable(part) + written = pathlib.Path(tempfile.mkdtemp()) / "declared.json" + written.write_text(json.dumps(tree)) + return str(written) + + +class TestDriveDefaults(unittest.IsolatedAsyncioTestCase): + """Where the value a move uses when the caller names none comes from: what the head reported, + falling back to what its firmware documents.""" + + async def test_the_defaults_are_what_the_head_reported(self): + """Discovery reads the four Y and Z drive parameters off the head, and the defaults answer with + them. Read from a head declaring values its firmware does not, so a default that ignored the + head and computed the documented one instead could not pass. Four distinct values, so a read + stored under the wrong name fails this too.""" + declared = dataclasses.replace( + RECORDED_HEAD96, + y_drive_speed_firmware_reported=200.0, + y_drive_acceleration_firmware_reported=300.0, + z_drive_speed_firmware_reported=50.0, + z_drive_acceleration_firmware_reported=250.0, + ) + driver = STARSimulationDriver( + deck=STARDeck(), declared_configuration_json=declaring(head96=declared) + ) + await driver.setup() + + c = cast(Head96, driver.x_arm.head96).configuration + self.assertEqual( + ( + c.y_drive_speed_default, + c.y_drive_acceleration_default, + c.z_drive_speed_default, + c.z_drive_acceleration_default, + ), + (200.0, 300.0, 50.0, 250.0), + ) + + async def test_the_96_head_takes_its_dispensing_and_squeezer_defaults_too(self): + """`Head96.discover` reads four drive parameters on top of the Y and Z ones every head shares, + and its defaults answer with what it reported for them. Apart from the test above because it + covers the override rather than the base: the 384-head adds no reads of its own. + + Compared to a tenth rather than exactly: the drive counts in increments, so a value that does + not fall on one comes back as the nearest that does - 400.0 mm/s reads back as 400.01. That is + what a head does, and what the simulated one does now that its answer crosses the link and is + decoded rather than handed over whole.""" + declared = dataclasses.replace( + RECORDED_HEAD96, + dispensing_drive_speed_firmware_reported=400.0, + dispensing_drive_acceleration_firmware_reported=9000.0, + squeezer_drive_speed_firmware_reported=12.0, + squeezer_drive_acceleration_firmware_reported=50.0, + ) + driver = STARSimulationDriver( + deck=STARDeck(), declared_configuration_json=declaring(head96=declared) + ) + await driver.setup() + + c = cast(Head96, driver.x_arm.head96).configuration + for read, declared_value in zip( + ( + c.dispensing_drive_speed_default, + c.dispensing_drive_acceleration_default, + c.squeezer_drive_speed_default, + c.squeezer_drive_acceleration_default, + ), + (400.0, 9000.0, 12.0, 50.0), + ): + self.assertAlmostEqual(read, declared_value, places=1) + + async def test_a_head_that_will_not_say_keeps_what_its_firmware_documents(self): + """A head that refuses the read leaves discovery with nothing to record, and the defaults fall + back to the increments its firmware documents rather than the read failing setup. Driven + through `discover` alone: the rest of setup moves the head, and reads these same parameters to + do it.""" + driver = STARSimulationDriver(deck=STARDeck(), declared_configuration_json=RECORDING_STAR) + head = cast(Head96, cast(XArm, driver.left_x_arm).head96) + + async def refuse(parameter: str) -> float: + raise RuntimeError("this head does not answer for its drives") + + head.request_drive_parameter = refuse # type: ignore[method-assign] + await head.discover() + + c = head.configuration + self.assertEqual( + ( + c.y_drive_speed_firmware_reported, + c.y_drive_acceleration_firmware_reported, + c.z_drive_speed_firmware_reported, + c.z_drive_acceleration_firmware_reported, + ), + (None, None, None, None), + ) + self.assertEqual( + ( + c.y_drive_speed_default, + c.y_drive_acceleration_default, + c.z_drive_speed_default, + c.z_drive_acceleration_default, + ), + (390.62, 546.88, 85.0, 400.0), + ) diff --git a/pylabrobot/hamilton/star/driver/features/iswap.py b/pylabrobot/hamilton/star/driver/features/iswap.py new file mode 100644 index 00000000000..13249b49136 --- /dev/null +++ b/pylabrobot/hamilton/star/driver/features/iswap.py @@ -0,0 +1,2982 @@ +"""The iSWAP: a P(x)+P(y)+P(z)+R(elbow)+R(wrist)+EE(gripper) SCARA +with a mechanical gripper as end-effector that moves resources. +""" + +import dataclasses +import datetime +import enum +import logging +import math +from dataclasses import dataclass +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Sequence, Tuple, Union, cast + +from pylabrobot.hamilton.protocol.text.framing import parse_firmware_version_date +from pylabrobot.hamilton.star.driver.errors import NoElementError, STARFirmwareError +from pylabrobot.hamilton.star.resource_model import iSWAPHead +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.end_effector import MechanicalGripper +from pylabrobot.resources.manipulator import LinkBody +from pylabrobot.resources.rotation import Rotation + +if TYPE_CHECKING: + from pylabrobot.hamilton.star.driver.features.x_arm import XArm + from pylabrobot.hamilton.star.driver.master import STARDriver + +logger = logging.getLogger(__name__) + + +# A gripper direction: where the gripper looks - the way its fingers point - in the driver's own +# angles, 0 along +x, deck-right, turning counter-clockwise seen from above. Hamilton's `pd` codes +# name a position rather than a direction, the side of the resource the gripper stands on, so the +# two differ by half a turn. +GRIPPER_DECK_DIRECTIONS: Dict[str, float] = { + "right": 0.0, + "back": 90.0, + "left": 180.0, + "front": -90.0, +} + +RECORDED_FIRMWARE_PREFIX = "4." + +# The rotation drive's own dimensions, in mm. Module constants rather than field defaults alone, +# because the front limit below is worked out from them before any configuration exists. +ROTATION_DRIVE_DIAMETER = 30.5 +ROTATION_DRIVE_SAFETY_RADIUS = 90.0 + +# The arm this driver assumes it is on until it has read the one it is actually on. Twelve channels +# is more than most arms carry, and every extra channel in front of the drive holds the drive +# further back, so assuming twelve errs the safe way: it refuses poses a narrower arm could reach, +# rather than allowing ones a wider arm could not. `iSWAP.declare_front_limit` replaces all three +# with what the arm reports, at discovery. +ASSUMED_CHANNELS = 12 +ASSUMED_CHANNEL_WIDTH = 9.0 +ASSUMED_ARM_FRONT_LIMIT = 6.0 + + +def rotation_drive_front_limit( + arm_front_limit: float, channel_widths: Sequence[float], swept_radius: float +) -> float: + """How far forward the rotation drive can be brought, in mm. + + Not the arm's own front limit: the channels ride in front of the drive on the same rail, and the + drive stops behind the backmost of them however far forward they are packed. Packed is what this + measures - every channel against the front of the arm's travel, each taking its own width - so + it is the limit with the deck cleared, not wherever the channels happen to be standing. + + Args: + arm_front_limit: the front of the arm's own Y travel, in mm. + channel_widths: how wide each channel is, in mm, backmost first. + swept_radius: how far from the drive's centre anything it carries reaches, in mm. + + Returns: + The furthest forward the drive's own reference point can be sent, in mm. + """ + if not channel_widths: + return arm_front_limit + return arm_front_limit + sum(channel_widths[1:]) + channel_widths[0] / 2 + swept_radius + + +@dataclass +class CartesianPose: + """Location and rotation of the gripper. + + In the STAR's deck frame: mm from the deck's origin, and degrees counter-clockwise seen from + above with 0 along +x. Named as the other arms name theirs, since it is the same thing. + """ + + location: Coordinate + rotation: Rotation + + +class iSWAPAxis(enum.IntEnum): + """The iSWAP's addressable axes, as `request_joint_state` keys. + + Units are the axis's own: the prismatic axes and the gripper in mm, the two revolute drives in + degrees. `Z` is the rotation drive's bottom, which sits above the gripper finger plane by + `iSWAPConfiguration.rotation_drive_z_offset_above_finger`, so it is not the grip centre's Z. + """ + + X = 1 + Y = 2 + Z = 3 + ROTATION = 4 + WRIST = 5 + GRIPPER = 6 + + @property + def is_in_kinematic_chain(self) -> bool: + """Whether the axis moves the gripper frame. + + The gripper is driven, but opening it changes what is held rather than where the gripper is. + """ + return self is not iSWAPAxis.GRIPPER + + +JointState = Dict[iSWAPAxis, float] +"""Where every axis is, in its own units: the prismatic axes and the gripper in mm, the two +revolute drives in degrees. What the arm is, rather than anything worked out from it.""" + + +@dataclasses.dataclass(frozen=True) +class iSWAPPose: + """Where every joint of the arm is, and which way the gripper faces. + + One answer for the whole arm rather than for its end. The two links are what put the gripper + where it is, so a caller asking whether the arm clears something needs the joint between them as + much as the point at the end of it: a wrist folded back swings link 2 the opposite way to the + turn, and only the middle joint shows that. + + Every coordinate and rotation here is in the STAR's deck frame, as `CartesianPose` states it. + """ + + rotation_joint_location: Coordinate + """Where the rotation drive is: the joint link 1 turns about.""" + wrist_joint_location: Coordinate + """Link 1's far end, which is the joint link 2 turns about. Which way link 1 lies is not stated: + it is the direction from `rotation_joint_location` to here, and nothing has needed it.""" + gripper_center_location: Coordinate + """Link 2's far end, between the fingers: the point a grip is programmed against.""" + gripper_deck_orientation: Rotation + """Which way the gripper faces, in the deck frame - the yaw link 2 lies along, since the gripper + is bolted to it. Degrees counter-clockwise seen from above, 0 along +x.""" + joints: JointState + """What each drive reported, in its own units, as `request_joint_state` returns it.""" + + +@dataclass +class iSWAPConfiguration: + """Device parameters for the installed iSWAP. + + Ported from the legacy `iSWAPInformation`. Two kinds of value: per-device calibration read from + the device at setup - link lengths, calibrated stops, offsets - which is None until read; and + device facts of the 4th-generation iSWAP, the only generation supported, which are defaulted. + Neither changes at runtime. + """ + + module: str = "R0" + """What the iSWAP is on the bus.""" + + firmware_version: Optional[str] = None + firmware_date: Optional[datetime.date] = None + + link_1_length: Optional[float] = None + """rotation joint (joint 1) to the wrist joint (joint 2); default: 138.0 mm.""" + tool_length: Optional[float] = None + """wrist joint (joint 2) to the gripper finger centre, in mm. default: 138.0 mm.""" + + # -- X -- + rotation_drive_x_offset: Optional[float] = None + """Deck X distance from the X-arm carriage reference point to the rotation drive (mm). Stored in + master EEPROM. The Hamilton factory default is 34.0 mm.""" + + # -- Y -- + rotation_drive_y_slots: Tuple[str, ...] = ( + "home", + "lower_limit", + "upper_limit", + "parking", + "pre_parking", + "extra_1", + "extra_2", + "extra_3", + "extra_4", + "extra_5", + ) + """What the Y carriage's stored table holds, slot by slot. All position, and no arm length.""" + + rotation_drive_predefined_y_positions_increments: Optional[Dict[str, int]] = None + """Each Y stop the carriage is calibrated against, in increments, keyed as + `configuration.rotation_drive_y_slots` names them. The whole stored table, not just the parking + stop.""" + + # -- Z -- + rotation_drive_z_slots: Tuple[str, ...] = ( + "home", + "parking", + "extra_1", + "extra_2", + "extra_3", + "extra_4", + "extra_5", + "extra_6", + "extra_7", + "extra_8", + ) + """The same for the rotation drive's Z: ten stops, all position, no arm length.""" + + rotation_drive_predefined_z_positions_increments: Optional[Dict[str, int]] = None + """Each Z stop the rotation drive is calibrated against, in increments of the finger plane, + keyed as `configuration.rotation_drive_z_slots` names them. Read by discovery; the defaults are + factory values, not one unit's calibration.""" + + # -- rotation drive -- + rotation_drive_slots: Tuple[str, ...] = ( + "home", + "left", + "front", + "right", + "parking", + "extra_1", + "extra_2", + "extra_3", + "extra_4", + ) + """What the rotation drive's stored table holds, slot by slot. The tenth slot is the arm length, + read separately. The extra slots are addressable but have no documented meaning.""" + + rotation_drive_predefined_increments: Optional[Dict[str, int]] = None + + # -- wrist drive -- + wrist_drive_slots: Tuple[str, ...] = ( + "home", + "right", + "straight", + "left", + "reverse", + "parking", + "extra_1", + "extra_2", + "extra_3", + ) + """The same for the wrist twist drive.""" + + wrist_drive_predefined_increments: Optional[Dict[str, int]] = None + # -- gripper drive -- + gripper_drive_slots: Tuple[str, ...] = ( + "home", + "extra_1", + "closed", + "plate_type_1", + "plate_type_2", + "plate_type_3", + "plate_type_4", + "plate_type_5", + "plate_type_6", + "plate_type_7", + ) + """What the gripper drive's stored table holds, slot by slot: all jaw width, no arm length. One + slot stands for both home and parking, seven are the widths a plate type is gripped at, and the + second has no documented meaning - its default is the top of the drive's range.""" + + gripper_drive_predefined_increments: Optional[Dict[str, int]] = None + """Each jaw width the gripper is calibrated against, in increments, keyed as + `configuration.gripper_drive_slots` names them. Read by discovery; the defaults are factory + values, not one unit's calibration.""" + + # === Device facts of the 4th-generation iSWAP: per-drive area-of-operation ranges and encoder + # resolutions. The same across units of a generation, so they are defaulted - but only that + # generation's are held. On an arm of another generation every conversion below would be wrong, + # so discovery says so when the arm reports a firmware version these were not taken from. === + + # -- Y -- + y_range_increments: Tuple[int, int] = (0, 14_000) + y_mm_per_increment: float = 0.046302083 + y_speed_range_increments: Tuple[int, int] = (50, 8_000) # increments/sec + # Speeds run under the documented defaults - Y 68%, rotation 44%, wrist 41% - these swing a plate. + y_speed_default_increments: int = 4_751 + y_current_limit_default: int = 7 + y_acceleration_level_default: int = 2 + rotation_drive_diameter: float = ROTATION_DRIVE_DIAMETER + """How wide the rotation drive is, in mm.""" + + rotation_drive_safety_radius: float = ROTATION_DRIVE_SAFETY_RADIUS + """How far past the drive's own edge anything it carries reaches, in mm. A clearance that holds + at every rotation angle is the drive's radius plus this.""" + + rotation_drive_y_min: float = rotation_drive_front_limit( + ASSUMED_ARM_FRONT_LIMIT, + [ASSUMED_CHANNEL_WIDTH] * ASSUMED_CHANNELS, + ROTATION_DRIVE_DIAMETER / 2 + ROTATION_DRIVE_SAFETY_RADIUS, + ) + """How far forward the rotation drive can be brought, in mm: the front stop the channels leave it. + + The one bound here that is not the drive's own. Defaulted for the assumed twelve-channel arm, so + a driver that has not read a device still has a limit rather than none, and overwritten with the + arm's own channels by `iSWAP.declare_front_limit` at discovery.""" + + rotation_drive_size_z: float = 120.0 + """How tall to model the rotation drive, in mm. Not read from anywhere: how far the drive extends + is not something the device reports.""" + + # -- Z -- + z_range_increments: Tuple[int, int] = (-187, 26_661) + z_mm_per_increment: float = 0.01072765 + z_speed_range_increments: Tuple[int, int] = (50, 15_000) # increments/sec + z_acceleration_range_increments: Tuple[int, int] = (5, 999) # 1000 increments/sec^2 + z_speed_default_increments: int = 11_000 + z_acceleration_default_increments: int = 60 + z_current_limit_default: int = 6 + rotation_drive_z_offset_above_finger: float = 13.0 + """How far the rotation drive's lowest point sits above the finger plane, in mm. Z is calibrated + to the finger plane, so a position read or commanded is that plane's plus this.""" + + # -- rotation drive (joint 1) -- + rotation_range_increments: Tuple[int, int] = (-30_032, 30_032) + rotation_deg_per_increment: float = 0.00309619077 + rotation_speed_range_increments: Tuple[int, int] = (20, 75_000) # increments/sec + rotation_acceleration_range_increments: Tuple[int, int] = (5, 200) # 1000 increments/sec^2 + rotation_speed_default_increments: int = 24_223 + rotation_acceleration_default_increments: int = 161 + rotation_current_limit_default: int = 5 + + # -- wrist drive (joint 2) -- + wrist_range_increments: Tuple[int, int] = (-30_000, 30_000) + wrist_deg_per_increment: float = 0.00507968798 + wrist_speed_range_increments: Tuple[int, int] = (20, 65_000) # increments/sec + wrist_acceleration_range_increments: Tuple[int, int] = (5, 200) # 1000 increments/sec^2 + wrist_speed_default_increments: int = 19_686 + wrist_acceleration_default_increments: int = 143 + wrist_current_limit_default: int = 5 + + # -- gripper -- + gripper_range_increments: Tuple[int, int] = (12_780, 24_120) # jaw width + gripper_mm_per_increment: float = 0.00554337 + gripper_speed_range_increments: Tuple[int, int] = (20, 9_999) # increments/sec + gripper_acceleration_range_increments: Tuple[int, int] = (5, 150) # 1000 increments/sec^2 + gripper_current_limit_range: Tuple[int, int] = (0, 15) + gripper_speed_default_increments: int = 8_659 + gripper_acceleration_default_increments: int = 75 + gripper_current_limit_default: int = 15 + gripper_close_speed_default_increments: int = 5_000 + gripper_nudge_speed_default_increments: int = 2_000 # slow: used against a stuck drive + gripper_stop_trigger_default: int = 200 + gripper_low_pass_filter_default: bool = True + gripper_stop_band_range_increments: Tuple[int, int] = (80, 1_800) + """How wide a window the drive accepts around the width a close is aimed at, in its own steps.""" + gripper_counter_drift_increments: int = 50 + """How far the drive's two counters may sit apart before the gap means lost steps rather than + the ordinary lag between commanding a move and finishing it.""" + + # -- conversions: the wire counts in increments, the driver speaks mm and degrees ---------- + + @property + def rotation_drive_y_max(self) -> Optional[float]: + """How far back the carriage may be sent, in mm: the parking stop it is calibrated against. + + Returns: + The parking stop in mm, or None until the stored Y table has been read. + """ + predefined_y_positions = self.rotation_drive_predefined_y_positions_increments + if predefined_y_positions is None: + return None + return self.y_increments_to_mm(predefined_y_positions["parking"]) + + def y_increments_to_mm(self, increments: int) -> float: + """A Y-carriage position in mm, from the increments the drive counts in.""" + return round(increments * self.y_mm_per_increment, 2) + + def y_mm_to_increments(self, mm: float) -> int: + """A Y-carriage position in increments, from mm.""" + return round(mm / self.y_mm_per_increment) + + def z_increments_to_mm(self, increments: int) -> float: + """A Z position in mm, from increments.""" + return round(increments * self.z_mm_per_increment, 3) + + def z_mm_to_increments(self, mm: float) -> int: + """A Z position in increments, from mm.""" + return round(mm / self.z_mm_per_increment) + + @property + def rotation_drive_z_range(self) -> Tuple[float, float]: + """How far the rotation drive's bottom travels along Z, in mm, lowest first. + + Derived from the drive's documented area of operation, not probed: unlike a head, the iSWAP + has no command that finds its own limit. + """ + return ( + round( + self.z_increments_to_mm(self.z_range_increments[0]) + + self.rotation_drive_z_offset_above_finger, + 1, + ), + round( + self.z_increments_to_mm(self.z_range_increments[1]) + + self.rotation_drive_z_offset_above_finger, + 1, + ), + ) + + @property + def rotation_drive_swept_radius(self) -> float: + """How far from the rotation drive's centre anything it carries can reach, in mm. + + The drive and its arm are treated as one circle, so a clearance measured against it holds + whichever way the arm happens to be turned. + """ + return self.rotation_drive_diameter / 2 + self.rotation_drive_safety_radius + + def rotation_drive_increments_to_angle(self, increments: int) -> float: + """A rotation-drive angle in degrees, from increments, against the calibrated stops. + + Piecewise linear rather than one slope: `left` to `front` spans -90 to 0 degrees and `front` + to `right` spans 0 to +90, each against the stops this device reports, so they read back as + exactly -90, 0 and +90 however far calibration has drifted. + + Args: + increments: what the drive reports. + + Returns: + The angle in degrees, signed from the calibrated front stop. + + Raises: + RuntimeError: If the stored stops were not read. + """ + predefined_positions = self.rotation_drive_predefined_increments + if predefined_positions is None: + raise RuntimeError( + "the rotation drive's stops were not read; have you called `star.setup()`?" + ) + front = predefined_positions["front"] + if increments < front: + return -90.0 * (front - increments) / (front - predefined_positions["left"]) + return 90.0 * (increments - front) / (predefined_positions["right"] - front) + + def rotation_drive_angle_to_increments(self, angle: float) -> int: + """A rotation-drive angle in increments, from degrees, against the calibrated stops. + + The inverse of `rotation_drive_increments_to_angle`, piecewise on the same two segments, so + -90, 0 and +90 land exactly on the stops this device reports. + + Args: + angle: degrees, signed from the calibrated front stop. + + Returns: + What to send the drive. + + Raises: + RuntimeError: If the stored stops were not read. + """ + predefined_positions = self.rotation_drive_predefined_increments + if predefined_positions is None: + raise RuntimeError( + "the rotation drive's stops were not read; have you called `star.setup()`?" + ) + front = predefined_positions["front"] + if angle < 0: + return round(front - (angle / -90.0) * (front - predefined_positions["left"])) + return round(front + (angle / 90.0) * (predefined_positions["right"] - front)) + + # The wrist's four stops, in the drive's own degrees. The drive is zeroed between `straight` and + # `left`, which is what puts these at a quarter turn either side of +/-45 rather than at 0 and 90. + WRIST_STOP_ANGLES = (("right", -135.0), ("straight", -45.0), ("left", 45.0), ("reverse", 135.0)) + + def _wrist_calibrated_stops(self) -> List[Tuple[int, float]]: + """The wrist's stops as increment/degree pairs, in increasing order. + + Returns: + What this device reports for each stop, against the angle that stop stands at. + + Raises: + RuntimeError: If the stored stops were not read. + """ + predefined_positions = self.wrist_drive_predefined_increments + if predefined_positions is None: + raise RuntimeError("the wrist drive's stops were not read; have you called `star.setup()`?") + return [(predefined_positions[name], angle) for name, angle in self.WRIST_STOP_ANGLES] + + def wrist_increments_to_deg(self, increments: int) -> float: + """A wrist-drive angle in degrees, from increments, against the calibrated stops. + + Piecewise linear across the three segments the four stops divide the travel into, so the stops + read back exactly however far calibration has drifted, and an angle between two of them is + measured against the span this device actually reports rather than against a nominal + resolution. Past the outer stops the end segment's slope carries on. + + Args: + increments: what the drive reports. + + Returns: + The angle in degrees, signed from the drive's own zero. + + Raises: + RuntimeError: If the stored stops were not read. + """ + stops = self._wrist_calibrated_stops() + segment = next( + (i for i in range(len(stops) - 1) if increments < stops[i + 1][0]), len(stops) - 2 + ) + (low_increments, low_angle), (high_increments, high_angle) = stops[segment], stops[segment + 1] + span = (increments - low_increments) / (high_increments - low_increments) + return low_angle + span * (high_angle - low_angle) + + def wrist_deg_to_increments(self, deg: float) -> int: + """A wrist-drive angle in increments, from degrees, against the calibrated stops. + + The inverse of `wrist_increments_to_deg`, piecewise on the same three segments, so a stop's + angle lands exactly on the increments this device reports for it. + + Args: + deg: degrees, signed from the drive's own zero. + + Returns: + What to send the drive. + + Raises: + RuntimeError: If the stored stops were not read. + """ + stops = self._wrist_calibrated_stops() + segment = next((i for i in range(len(stops) - 1) if deg < stops[i + 1][1]), len(stops) - 2) + (low_increments, low_angle), (high_increments, high_angle) = stops[segment], stops[segment + 1] + span = (deg - low_angle) / (high_angle - low_angle) + return round(low_increments + span * (high_increments - low_increments)) + + # A rate is a plain division by the drive's resolution, unlike a position, which is piecewise + # against the stops. Acceleration is counted in thousands of increments. + + def rotation_deg_per_sec_to_increments(self, deg_per_sec: float) -> int: + """A rotation-drive speed in increments/s, from degrees/s.""" + return round(deg_per_sec / self.rotation_deg_per_increment) + + def rotation_increments_to_deg_per_sec(self, increments: int) -> float: + """A rotation-drive speed in degrees/s, from increments/s.""" + return round(increments * self.rotation_deg_per_increment, 2) + + def rotation_deg_per_sec2_to_increments(self, deg_per_sec2: float) -> int: + """A rotation-drive acceleration in thousands of increments/s2, from degrees/s2.""" + return round(deg_per_sec2 / self.rotation_deg_per_increment / 1000) + + def rotation_increments_to_deg_per_sec2(self, increments: int) -> float: + """A rotation-drive acceleration in degrees/s2, from thousands of increments/s2.""" + return round(increments * 1000 * self.rotation_deg_per_increment, 2) + + def wrist_deg_per_sec_to_increments(self, deg_per_sec: float) -> int: + """A wrist-drive speed in increments/s, from degrees/s.""" + return round(deg_per_sec / self.wrist_deg_per_increment) + + def wrist_increments_to_deg_per_sec(self, increments: int) -> float: + """A wrist-drive speed in degrees/s, from increments/s.""" + return round(increments * self.wrist_deg_per_increment, 2) + + def wrist_deg_per_sec2_to_increments(self, deg_per_sec2: float) -> int: + """A wrist-drive acceleration in thousands of increments/s2, from degrees/s2.""" + return round(deg_per_sec2 / self.wrist_deg_per_increment / 1000) + + def wrist_increments_to_deg_per_sec2(self, increments: int) -> float: + """A wrist-drive acceleration in degrees/s2, from thousands of increments/s2.""" + return round(increments * 1000 * self.wrist_deg_per_increment, 2) + + @property + def y_speed_default(self) -> float: + """Y speed a move uses when the caller names none (mm/s).""" + return self.y_increments_to_mm(self.y_speed_default_increments) + + @property + def z_speed_default(self) -> float: + """Z speed a move uses when the caller names none (mm/s).""" + return self.z_increments_to_mm(self.z_speed_default_increments) + + @property + def z_acceleration_default(self) -> float: + """Z acceleration a move uses when the caller names none (mm/s2).""" + return round(self.z_acceleration_default_increments * 1000 * self.z_mm_per_increment, 2) + + @property + def rotation_speed_default(self) -> float: + """Rotation-drive speed a move uses when the caller names none (deg/s).""" + return self.rotation_increments_to_deg_per_sec(self.rotation_speed_default_increments) + + @property + def rotation_acceleration_default(self) -> float: + """Rotation-drive acceleration a move uses when the caller names none (deg/s2).""" + return self.rotation_increments_to_deg_per_sec2(self.rotation_acceleration_default_increments) + + @property + def wrist_speed_default(self) -> float: + """Wrist-drive speed a move uses when the caller names none (deg/s).""" + return self.wrist_increments_to_deg_per_sec(self.wrist_speed_default_increments) + + @property + def wrist_acceleration_default(self) -> float: + """Wrist-drive acceleration a move uses when the caller names none (deg/s2).""" + return self.wrist_increments_to_deg_per_sec2(self.wrist_acceleration_default_increments) + + @property + def gripper_speed_default(self) -> float: + """Gripper speed a move uses when the caller names none (mm/s).""" + return self.gripper_increments_to_mm_per_sec(self.gripper_speed_default_increments) + + @property + def gripper_close_speed_default(self) -> float: + """Gripper speed a close uses when the caller names none (mm/s).""" + return self.gripper_increments_to_mm_per_sec(self.gripper_close_speed_default_increments) + + @property + def gripper_nudge_speed_default(self) -> float: + """Gripper speed a nudge uses when the caller names none (mm/s).""" + return self.gripper_increments_to_mm_per_sec(self.gripper_nudge_speed_default_increments) + + @property + def gripper_stop_band_max(self) -> float: + """The widest window a close may search either side of its target (mm).""" + return self.gripper_increments_to_mm(self.gripper_stop_band_range_increments[1]) + + @property + def gripper_acceleration_default(self) -> float: + """Gripper acceleration a move uses when the caller names none (mm/s2).""" + return self.gripper_increments_to_mm_per_sec2(self.gripper_acceleration_default_increments) + + def gripper_increments_to_mm(self, increments: int) -> float: + """A gripper jaw width in mm, from increments.""" + return round(increments * self.gripper_mm_per_increment, 3) + + def gripper_mm_to_increments(self, mm: float) -> int: + """A gripper jaw width in increments, from mm.""" + return round(mm / self.gripper_mm_per_increment) + + def gripper_mm_per_sec_to_increments(self, mm_per_sec: float) -> int: + """A gripper-drive speed in increments/s, from mm/s.""" + return round(mm_per_sec / self.gripper_mm_per_increment) + + def gripper_increments_to_mm_per_sec(self, increments: int) -> float: + """A gripper-drive speed in mm/s, from increments/s.""" + return round(increments * self.gripper_mm_per_increment, 2) + + def gripper_mm_per_sec2_to_increments(self, mm_per_sec2: float) -> int: + """A gripper-drive acceleration in thousands of increments/s2, from mm/s2.""" + return round(mm_per_sec2 / self.gripper_mm_per_increment / 1000) + + def gripper_increments_to_mm_per_sec2(self, increments: int) -> float: + """A gripper-drive acceleration in mm/s2, from thousands of increments/s2.""" + return round(increments * 1000 * self.gripper_mm_per_increment, 2) + + +class iSWAP: + """The internal Swivel Arm Plate (iSWAP) handler. + + Reached as `driver.iswap`, on a device that has one. It is addressed as `R0`, but the commands + that move it go to the master, so this feature speaks to both. + """ + + park_traverse_height_range: Tuple[float, float] = (145.0, 360.0) + """The band a park may be asked to lift to, in mm. + + The top is what the command itself accepts; the floor is this driver's, and parking is refused + below it.""" + + default_minimum_traverse_height: float = 284.0 + """How high the arm lifts to before it travels, in mm, when a caller names no height. + + What legacy sends. Parking drives the arm down to its nest, so this is what holds it clear of the + deck until it is home.""" + + def __init__(self, driver: "STARDriver", configuration: Optional[iSWAPConfiguration] = None): + """ + Args: + driver: the driver to send commands through. + configuration: the iSWAP's device facts. Defaults to `iSWAPConfiguration()`. + """ + self._driver = driver + self.configuration = configuration or iSWAPConfiguration() + self.resource: Optional[iSWAPHead] = None + self.gripped: Optional[bool] = None + """Whether the arm is holding something, as it last reported. + + None until anything has asked. Set by `request_plate_gripped`, which is the only thing that + knows: the arm reports it from the fingers themselves, so a plate is not something this driver + can infer from the commands it sent.""" + self.link_1: Optional[LinkBody] = None + self.gripper: Optional[MechanicalGripper] = None + + @property + def arm(self) -> "XArm": + """The arm carrying this iSWAP. + + It has no X drive of its own: it rides the arm, offset from the carriage reference point by + `configuration.rotation_drive_x_offset`. + + Returns: + The arm. + """ + return next(a for a in self._driver.arms if a.iswap is self) + + # -- session / discovery --------------------------------------------------- + + async def request_firmware_version(self) -> Tuple[str, datetime.date]: + """Request the iSWAP's firmware version and build date. + + Returns: + The version string as reported, and the date in it. + """ + resp: str = await self._driver.send_command(module="R0", command="RF") + return resp.split("rf")[-1], parse_firmware_version_date(resp) + + async def rotation_drive_request_x_offset(self) -> float: + """Request the X distance from the X-arm carriage centre to the rotation drive. + + Stored in the master's own memory, as the 96-head's offset is. + + Returns: + The offset in mm. + """ + resp = await self._driver.send_command(module="C0", command="RA", ra="kg", fmt="kg###") + return cast(int, resp["kg"]) / 10.0 + + async def rotation_drive_request_positions(self) -> Dict[str, int]: + """Request the rotation drive's stored position table. + + The device returns ten signed slots. Nine are positions and the tenth is link 1's length, so + both are recorded here rather than costing a second read of the same table. + + Returns: + Each named stop's motor increments. + """ + c = self.configuration + slots = await self._request_slots("pw") + c.rotation_drive_predefined_increments = dict(zip(c.rotation_drive_slots, slots)) + c.link_1_length = round(slots[9] / 10, 1) + return c.rotation_drive_predefined_increments + + async def wrist_drive_request_positions(self) -> Dict[str, int]: + """Request the wrist twist drive's stored position table. + + Its tenth slot carries link 2's length, recorded here alongside the stops. + + Returns: + Each named stop's motor increments. + """ + c = self.configuration + slots = await self._request_slots("pt") + c.wrist_drive_predefined_increments = dict(zip(c.wrist_drive_slots, slots)) + c.tool_length = round(slots[9] / 10, 1) + return c.wrist_drive_predefined_increments + + async def rotation_drive_request_y_stops(self) -> Dict[str, float]: + """Request the stored Y stops the carriage is calibrated against. + + The stored table, not where the carriage is now: `rotation_drive_request_y_position` is what + reads that. + + Returns: + Each named stop in mm. + """ + c = self.configuration + slots = await self._request_slots("py") + c.rotation_drive_predefined_y_positions_increments = dict(zip(c.rotation_drive_y_slots, slots)) + return {name: c.y_increments_to_mm(slot) for name, slot in zip(c.rotation_drive_y_slots, slots)} + + async def request_link_1_length(self) -> float: + """Request the distance from the rotation joint to the wrist joint. + + Returns: + Length in mm. + """ + return round((await self._request_slots("pw"))[9] / 10, 1) + + async def request_tool_center_point_xy_length(self) -> float: + """Request the distance from the wrist joint to the gripper finger centre in the x-y plane. + + Returns: + Length in mm. + """ + return round((await self._request_slots("pt"))[9] / 10, 1) + + async def rotation_drive_request_predefined_z_positions(self) -> Dict[str, float]: + """Read the Z stops the rotation drive is calibrated against, in mm on the deck. + + The stored table rather than where the drive is now. Its ten slots are all positions, unlike + the rotation and wrist tables whose tenth slot carries an arm length. The device holds them as + the finger plane, so each is offset to the drive's bottom the way + `rotation_drive_request_z_position` reports it, and the two are then in the same terms. + + Beyond home and parking the slots are extra ones, addressable through `R0 ZP` but with no + documented meaning. + + Returns: + Each stop in mm, keyed as `configuration.rotation_drive_z_slots` names them. + """ + c = self.configuration + slots = await self._request_slots("pz") + c.rotation_drive_predefined_z_positions_increments = dict(zip(c.rotation_drive_z_slots, slots)) + return { + name: round(c.z_increments_to_mm(increments) + c.rotation_drive_z_offset_above_finger, 1) + for name, increments in zip(c.rotation_drive_z_slots, slots) + } + + async def gripper_drive_request_widths(self) -> Dict[str, float]: + """Read the jaw widths the gripper drive is calibrated against, in mm. + + The stored table rather than how far the jaws stand now, which `gripper_request_width` reads. + Its ten slots are all widths: the one the jaws home and park at, one with no documented + meaning, the width the drive treats as closed, and seven a plate type is gripped at. + + Records what came back on the configuration, as the other stored tables are recorded, so an + arm read for it once carries the table from then on. + + Returns: + Each width in mm, keyed as `configuration.gripper_drive_slots` names them. + """ + c = self.configuration + slots = await self._request_slots("pg") + c.gripper_drive_predefined_increments = dict(zip(c.gripper_drive_slots, slots)) + return { + name: c.gripper_increments_to_mm(increments) + for name, increments in zip(c.gripper_drive_slots, slots) + } + + async def _request_slots(self, table: str) -> List[int]: + """One of the iSWAP's stored tables, as the ten signed slots the device returns.""" + resp = await self._driver.send_command( + module="R0", command="RA", ra=table, fmt=f"{table}##### (n)" + ) + return cast(List[int], resp[table]) + + async def discover(self): + """Read this iSWAP's calibration. Read-only: nothing moves.""" + c = self.configuration + c.firmware_version, c.firmware_date = await self.request_firmware_version() + if not c.firmware_version.startswith(RECORDED_FIRMWARE_PREFIX): + logger.warning( + "this iSWAP reports firmware %s; the ranges and resolutions here were recorded from an arm " + "reporting %sx, so every position, angle and width converted from them may be wrong. Set " + "them on iSWAPConfiguration to correct it.", + c.firmware_version, + RECORDED_FIRMWARE_PREFIX, + ) + c.rotation_drive_x_offset = await self.rotation_drive_request_x_offset() + # Every stored table, through the one reader each has: a table read two ways is a table whose + # two ways drift. Each records what it read, so a configuration saved after setup carries all + # of them - left out, they save as nothing, and a simulated arm built from that file cannot + # answer where its Z drive or its jaws are. + await self.rotation_drive_request_y_stops() + await self.rotation_drive_request_positions() + await self.wrist_drive_request_positions() + await self.rotation_drive_request_predefined_z_positions() + await self.gripper_drive_request_widths() + await self.declare_front_limit() + + async def declare_front_limit(self) -> None: + """Work out how far forward the rotation drive can be brought, and record it. + + The drive's back stop is its own and is read with the rest of its stored table. Its front stop + is not: the channels ride in front of it on the same rail, so how far forward it goes is a fact + about the arm it is on rather than about the drive. It is worked out here, once, from the arm's + own channel count and widths - `rotation_drive_front_limit` is the arithmetic - and it replaces + the assumed twelve-channel limit the configuration carries until this runs. + + The channels are discovered alongside this feature rather than before it, so a width that has + not arrived yet is asked for here rather than waited on. Nothing moves: these are reads, and + the same read the channels' own discovery makes. + + An arm with no channels keeps whatever the configuration holds: there is nothing in front of + the drive to work a limit out from, and an assumed limit is better than none. + """ + device = self._driver.configuration + pipettes = self.arm.pipettes + if device is None or pipettes is None or not pipettes.configuration.channels: + return + widths = [channel.width for channel in pipettes.configuration.channels] + if any(width is None for width in widths): + widths = [await pipettes.request_min_pipette_width(channel) for channel in range(len(widths))] + front = ( + device.left_arm_min_y_position if self.arm.side == "left" else device.right_arm_min_y_position + ) + self.configuration.rotation_drive_y_min = round( + rotation_drive_front_limit( + front, cast(List[float], widths), self.configuration.rotation_drive_swept_radius + ), + 2, + ) + + # -- initialization -------------------------------------------------------- + + async def initialize(self): + """Initialize the iSWAP. This moves it.""" + return await self._driver.send_command(module="C0", command="FI", subsystem="R0") + + # -- where it is ----------------------------------------------------------- + + def rotation_drive_update_angle(self, angle: float) -> None: + """Record which way the arm points on the resource that models it. + + The carriage is what the arm is mounted on, so the arm's angle is carried there and anything + hung off it - the links, and what they hold - turns with it. Stated as the deck angle link 1 + lies along, which is the rotation drive's own angle less ninety degrees, so a resource's + rotation reads in the frame every other resource is placed in. + + Does nothing until there is a resource to record it on. + + Args: + angle: the rotation drive's angle, in degrees, as it reports it. + """ + if self.resource is None: + return + self.resource.rotation_drive_angle = angle + if self.link_1 is not None: + # The carriage does not turn; the arm mounted on it does. Link 1 leaves the drive at the + # drive's own angle less ninety degrees, which is the deck angle it lies along. + self.link_1.rotate_to(z=angle - 90.0, pivot_coordinate=self.link_1.proximal_joint) + + def rotation_drive_get_reference_point_location(self) -> Optional[Coordinate]: + """Where the model has the rotation drive's reference point, in mm on the deck. + + The inverse of `update_location_by_reference_point`: it converts a reported position into a + location, and this converts a location back into the position that would be reported. X is + the arm's, so it is carried through unread. + + Returns: + Where the model has it, or None when there is nothing modelling it yet. + """ + deck = self._driver.deck + if self.resource is None or self.resource.location is None or deck is None: + return None + arm = self.resource.parent + if arm is None: + return None + return self.resource.location + arm.get_location_wrt(deck) + self.resource.reference_point + + def wrist_drive_get_angle(self) -> Optional[float]: + """Which way the model has the wrist turned, as its drive reports it. + + Read from what the drive last reported, as `rotation_drive_get_angle` is: link 2's own rotation is an + angle from link 1 about a different axis, so recovering a drive angle from it would be + inverting a rendering rather than reading a fact. + + Returns: + The angle in degrees, or None while nothing has read it yet. + """ + return None if self.resource is None else self.resource.wrist_drive_angle + + def rotation_drive_get_angle(self) -> Optional[float]: + """Which way the model has the arm pointing, as the rotation drive reports it. + + Read from what the drive last reported, not converted back out of the resource's `rotation`: + that is a deck angle about a different axis, so recovering a drive angle from it would be + inverting a rendering rather than reading a fact. + + Returns: + The angle in degrees, or None while nothing has read it yet. + """ + return None if self.resource is None else self.resource.rotation_drive_angle + + def wrist_drive_update_angle(self, angle: float) -> None: + """Record which way the wrist is turned on the resource that models it. + + Link 2 turns on the wrist, which link 1 carries, so its angle is measured from link 1 rather + than from the deck: a resource's rotation adds to its parent's, and link 1 is its parent. What + the wrist reports when it is straight is where link 2 continues link 1, so the angle here is + however far the wrist has turned from that. + + Does nothing until the links are modelled. + + Args: + angle: the wrist drive's angle, in degrees, as it reports it. + """ + c = self.configuration + if self.resource is not None: + self.resource.wrist_drive_angle = angle + if self.link_1 is None or self.gripper is None or c.wrist_drive_predefined_increments is None: + return + straight = c.wrist_increments_to_deg(c.wrist_drive_predefined_increments["straight"]) + # The gripper is bolted to link 1's far end, which is link 1's length along its own span. + self.gripper.rotate_to(z=angle - straight, pivot_coordinate=self.gripper.proximal_joint) + + def gripper_update_width(self, width: float) -> None: + """Record how far apart the jaws stand on the resource that models them. + + Does nothing until the gripper is modelled, and nothing when the width is outside what the + model says the fingers do - it says so instead, because the two disagreeing is a question + about the geometry rather than something to paper over. + + Args: + width: how far apart the jaws stand, in mm, as the drive reports it. + """ + gripper = self.gripper + if gripper is None: + return + low, high = gripper.jaw_range + if not low <= width <= high: + logger.warning( + "the gripper reports its jaws %.1f mm apart, outside the %.1f to %.1f mm the model says " + "they travel, so the model is left where it is", + width, + low, + high, + ) + return + gripper.jaw_width = width + + def update_location_by_reference_point( + self, y: Optional[float] = None, z: Optional[float] = None + ) -> None: + """Record where the rotation drive is on the resource that models it. + + Y and Z only: the drive rides the arm, so its resource is a child of the arm's and follows it + in X without anything having to record that. The drives report the point the resource states as + its `reference_point`, and a resource is located by its left front bottom corner, so that point + is taken out before either value is recorded. + + Both drives answer in the deck's frame, while a resource's location is measured from its + parent, which here is the arm. The arm's own position is taken out too. Does nothing when the + driver was given no deck, and so has nothing to model. + + Args: + y: where the drive is now, in mm on the deck. Left as it was when None. + z: where its bottom is now, in mm on the deck. Left as it was when None. + """ + deck = self._driver.deck + if self.resource is None or self.resource.location is None or deck is None: + return + arm = self.resource.parent + if arm is None: + return + here, on_the_arm = self.resource.location, arm.get_location_wrt(deck) + anchor = self.resource.reference_point + self.resource.location = Coordinate( + here.x, + here.y if y is None else y - on_the_arm.y - anchor.y, + here.z if z is None else z - on_the_arm.z - anchor.z, + ) + + def _check_reachable(self, axis: Literal["x", "y", "z"], value: float) -> None: + """Raise if the rotation drive cannot be sent where it is being asked to go. + + The one gate every position passes through. What the iSWAP is allowed to do is decided in one + place: travel limits now, and whatever else has to hold before it moves as it is added. + + The carriage the Y and Z drives position, which is what every move here commands. Where the + gripper ends up is `_check_pose_reachable`, which works it out from the joint angles: the arm + reaches past the carriage, and how far and in which direction is what the joints decide. + + Args: + axis: which axis - `x` along the rail, `y` across the deck, `z` up. + value: where the rotation drive would be sent, in mm. + + Raises: + ValueError: If the drive cannot reach it. + RuntimeError: If the limits were not read, so how far it reaches is unknown. + """ + c = self.configuration + device = self._driver.configuration + if device is None: + raise RuntimeError("no configuration read; have you called `star.setup()`?") + + if axis == "x": + x_range = self.arm.configuration.x_range + if x_range is None: + raise RuntimeError("the arm's X travel is not known; have you called `star.setup()`?") + if c.rotation_drive_x_offset is None: + raise RuntimeError("the drive's X offset was not read; have you called `star.setup()`?") + low = x_range[0] - c.rotation_drive_x_offset + high = x_range[1] - c.rotation_drive_x_offset + elif axis == "y": + if c.rotation_drive_y_max is None: + raise RuntimeError("the drive's Y limit was not read; have you called `star.setup()`?") + low = ( + device.left_arm_min_y_position + if self.arm.side == "left" + else device.right_arm_min_y_position + ) + high = c.rotation_drive_y_max + else: + low, high = c.rotation_drive_z_range + + if not low <= value <= high: + raise ValueError( + f"{axis} must be between {round(low, 1)} and {round(high, 1)} mm for the rotation drive, " + f"is {value}" + ) + + # ---------------------------------------- + # Linear Movement + # ---------------------------------------- + + # -- x position -------------------------------------------------------------------------------- + + async def rotation_drive_request_x_position(self) -> float: + """Read where the rotation drive is along X, in deck mm. + + Returns: + The rotation drive's X in mm. + + Raises: + RuntimeError: If the drive's X offset was not read. + """ + offset = self.configuration.rotation_drive_x_offset + if offset is None: + raise RuntimeError( + "the rotation drive's X offset was not read; have you called `star.setup()`?" + ) + return round(await self.arm.request_position() - offset, 2) + + # -- y position -------------------------------------------------------------------------------- + + async def rotation_drive_request_y_position(self) -> float: + """Read where the rotation drive is along Y, in deck mm. + + The Y carriage the rotation joint is mounted on, not the gripper finger's Y: where the finger + is depends on the rotation and wrist angles as well. `request_pose` is what resolves those. + + Returns: + The rotation drive's Y in mm. + """ + resp = await self._driver.send_command(module="R0", command="RY", fmt="ry##### (n)") + # Two counters come back, the firmware's and the hardware's. The hardware one is read. Rounded + # once, by the conversion: rounding again here left a position coarser than the limits it is + # compared against, and a carriage at its own back stop reading past it. + y = self.configuration.y_increments_to_mm(cast(List[int], resp["ry"])[1]) + self.update_location_by_reference_point(y=y) + return y + + async def _record_where_it_stopped(self, axis: Literal["y", "z", "gripper"]) -> None: + """Read where a drive came to rest, and record it. + + For a move's failure path. A move that stopped part way left the drive somewhere no target + describes. Its own failure is logged and swallowed: it must not replace the move's exception, + which is the one that says what went wrong. + + Args: + axis: which drive the move drove - `y` across the deck, `z` up and down, `gripper` the jaws. + """ + try: + if axis == "y": + await self.rotation_drive_request_y_position() + elif axis == "z": + await self.rotation_drive_request_z_position() + else: + await self.gripper_request_width() + except Exception: + logger.warning("could not read where the iSWAP stopped along %s; its model is stale", axis) + + async def _unchecked_fw_rotation_drive_move_to_y_position_increments( + self, + y_increments: int, + speed_increments: Optional[int] = None, + acceleration_level: Optional[int] = None, + current_limit: Optional[int] = None, + ): + """Drive the rotation drive to an absolute Y. Nothing is guarded and nothing is recorded. + + Args: + y_increments: where the drive is to go, in the increments it counts in. + speed_increments: max velocity, in increments/s. + acceleration_level: which acceleration curve to use, 1 or 2. + current_limit: the motor current limit, 0 to 7. + """ + c = self.configuration + if speed_increments is None: + speed_increments = c.y_speed_default_increments + if acceleration_level is None: + acceleration_level = c.y_acceleration_level_default + if current_limit is None: + current_limit = c.y_current_limit_default + return await self._driver.send_command( + module="R0", + command="YA", + ya=f"{y_increments:05}", + yv=f"{speed_increments:04}", + yr=f"{acceleration_level}", + yw=f"{current_limit}", + ) + + async def rotation_drive_move_to_y_position( + self, + y: float, + make_space: bool = False, + speed: Optional[float] = None, + acceleration_level: Optional[int] = None, + current_limit: Optional[int] = None, + ): + """Move the rotation drive along Y. This moves it. + + The backmost channel is what the drive can run into, so how far back it may go depends on + where that channel is. The drive and its arm are treated as one circle of + `configuration.rotation_drive_swept_radius`, which keeps the clearance true whichever way the + arm is turned. + + Args: + y: where to put the rotation drive, in mm. + make_space: whether the channels may be moved aside when the backmost is where the drive + needs to be. Off by default; making space raises them to Z safety first. + speed: how fast, in mm/s. + acceleration_level: how hard to accelerate, 1 or 2. + current_limit: the motor current limit, 0 to 7. + + Raises: + ValueError: If the drive cannot reach it, if the arm's current pose carried to it would put a + joint behind the X-arm or out of reach, if any of the drive parameters is outside what it + accepts, or if the channels are in the way and may not be moved. + RuntimeError: If the device's configuration or the drive's Y limit was not read, or the arm is + modelled but its angles have not been read. + """ + c = self.configuration + if speed is None: + speed = c.y_speed_default + if acceleration_level is None: + acceleration_level = c.y_acceleration_level_default + if current_limit is None: + current_limit = c.y_current_limit_default + device = self._driver.configuration + if device is None: + raise RuntimeError("no configuration read; have you called `star.setup()`?") + self._check_reachable("y", y) + + speed_increments = c.y_mm_to_increments(speed) + speed_low, speed_high = c.y_speed_range_increments + if not speed_low <= speed_increments <= speed_high: + raise ValueError( + f"speed must be between {c.y_increments_to_mm(speed_low)} and " + f"{c.y_increments_to_mm(speed_high)} mm/s, is {speed}" + ) + if not 1 <= acceleration_level <= 2: + raise ValueError(f"acceleration_level must be 1 or 2, is {acceleration_level}") + if not 0 <= current_limit <= 7: + raise ValueError(f"current_limit must be between 0 and 7, is {current_limit}") + + # The arm rides the carriage, so a Y move carries its pose along without turning a joint: a pose + # that stands clear here can reach behind the X-arm at the new Y. Checked at the angles the model + # has, as a rotation is checked at the Y the model has. With no arm modelled - a driver given no + # deck - there is nothing to check, and linear moves go ahead as they always have. + if self.rotation_drive_get_reference_point_location() is not None and self.gripper is not None: + rotation, wrist = self.rotation_drive_get_angle(), self.wrist_drive_get_angle() + if rotation is None or wrist is None: + raise RuntimeError("the arm's angles have not been read; have you called `star.setup()`?") + self._check_pose_reachable(rotation, wrist, y=y) + + # Every argument is checked before this: making space moves the channels, and a move refused + # afterwards would leave the deck rearranged for a command that never ran. + await self._make_space_for_y(y, make_space=make_space) + + try: + resp = await self._unchecked_fw_rotation_drive_move_to_y_position_increments( + y_increments=c.y_mm_to_increments(y), + speed_increments=speed_increments, + acceleration_level=acceleration_level, + current_limit=current_limit, + ) + # What was asked for, recorded as soon as the move answers, so the model holds it even if + # the read below cannot be taken. + self.update_location_by_reference_point(y=y) + return resp + finally: + # And then what the drive says, which is the last word either way. A move that stopped part + # way left the carriage somewhere no target describes, and a move that answered has still + # only answered. + await self._record_where_it_stopped("y") + + async def _unchecked_fw_position_components_for_free_y_range(self): + """Position all components so that there is maximum free Y range for the iSWAP. Nothing is + guarded and nothing is recorded. This moves the channels. + """ + return await self._driver.send_command(module="C0", command="FY") + + async def _unchecked_fw_release_brake(self): + """Release the arm's brake. Nothing is guarded and nothing is recorded. + + Dangerous: the brake is what holds the arm up, so releasing it drops whatever it is holding. + """ + return await self._driver.send_command(module="R0", command="BA") + + async def _unchecked_fw_reengage_brake(self): + """Re-engage the arm's brake. Nothing is guarded and nothing is recorded.""" + return await self._driver.send_command(module="R0", command="BO") + + async def make_space(self) -> None: + """Clear the deck volume for the iSWAP. This moves the channels and any head. + + The channels are raised to Z safety and then moved aside; a head is only raised. Nothing may + travel in Y while one of them is low. The raises are commanded here, not left to + `_unchecked_fw_position_components_for_free_y_range` to arrange on the way. + + What each carries is read before it is moved. A tip hangs below the drive that holds it, so + the volume cleared here is not the one a bare channel or head occupies, and the reads are what + put that on the record. The channels sense theirs; a head only reports what its firmware holds. + + The Y positioning is the master's own, which places every component for the widest free Y + range there is, rather than this driver working out where each channel should stand. It does + not say where it left them, so they are read back afterwards either way. + """ + arm = self.arm + if arm.pipettes is not None: + tips = await arm.pipettes.sense_tip_presence() + mounted = [channel for channel, tip in enumerate(tips) if tip] + if mounted: + logger.info("the deck volume is being cleared with tips on channels %s", mounted) + await arm.pipettes.move_to_safe_z() + for head in (arm.head96, arm.head384): + if head is None: + continue + # TODO: warn when the head is clearing the deck volume with tips on it. The head has no + # sleeve sensor, so this has to come from the model rather than from a read, and nothing + # models what a head carries until tip handling is integrated. + # if await head.request_tip_presence(): + # logger.info( + # "the deck volume is being cleared with the head's firmware holding that it carries tips" + # ) + await head.move_to_safe_z() + try: + await self._unchecked_fw_position_components_for_free_y_range() + finally: + if arm.pipettes is not None: + await arm.pipettes.request_y_positions() + await self.rotation_drive_request_y_position() + + async def _make_space_for_y(self, y: float, make_space: bool) -> None: + """Make sure the backmost channel is out of the way before the drive travels to `y`. + + Args: + y: where the rotation drive is going, in mm. + make_space: whether the channels may be moved to make that space. + + Raises: + ValueError: If the channel is in the way and either may not be moved, or cannot move far + enough to clear it. + """ + pipettes = self.arm.pipettes + if pipettes is None: + return + + device = self._driver.configuration + if device is None: + raise RuntimeError("no configuration read; have you called `star.setup()`?") + + widths = [channel.width for channel in pipettes.configuration.channels] + if any(width is None for width in widths): + raise RuntimeError("the channels have no width read yet; have you called `star.setup()`?") + + # Where the backmost channel would have to be for the drive to reach `y`, and the furthest + # back it can get: every channel behind it packed against the front of their travel. + backmost_y = await pipettes.request_y_position(0) + target_y = y - cast(float, widths[0]) / 2 - self.configuration.rotation_drive_swept_radius + furthest_back = device.left_arm_min_y_position + sum(cast(List[float], widths[1:])) + + if backmost_y <= target_y: + return + if target_y < furthest_back: + raise ValueError( + f"y={y} mm is out of reach: it needs the backmost channel at {round(target_y, 1)} mm, and " + f"the channels do not fit behind {round(furthest_back, 1)} mm" + ) + if not make_space: + raise ValueError( + f"y={y} mm needs the backmost channel at {round(target_y, 1)} mm or further front, and it " + f"is at {backmost_y} mm. Pass make_space=True to move the channels out of the way" + ) + + # Channel 0 goes exactly as far forward as this y needs, and the channels behind it follow by + # their own minimum spacing. Everything is raised first: the channels are about to travel in + # Y, and the iSWAP is about to travel past whatever a head would leave in its path. + await pipettes.move_to_safe_z() + for head in (self.arm.head96, self.arm.head384): + if head is not None: + await head.move_to_safe_z() + await pipettes.move_to_y_positions({0: target_y}, make_space=True) + + async def rotation_drive_request_z_position(self) -> float: + """Read where the rotation drive's lowest point is along Z. + + The drive reports two counters, the firmware's and the hardware's. The hardware counter is + the one read, as legacy reads it. + + Returns: + The rotation drive's bottom Z in mm. + """ + resp = await self._driver.send_command(module="R0", command="RZ", fmt="rz##### (n)") + finger_plane = self.configuration.z_increments_to_mm(cast(List[int], resp["rz"])[1]) + z = round(finger_plane + self.configuration.rotation_drive_z_offset_above_finger, 3) + self.update_location_by_reference_point(z=z) + return z + + async def _unchecked_fw_rotation_drive_move_to_z_position_increments( + self, + z_increments: int, + speed_increments: Optional[int] = None, + acceleration_increments: Optional[int] = None, + current_limit: Optional[int] = None, + ): + """Drive the rotation drive to an absolute Z. Nothing is guarded and nothing is recorded. + + The drive is calibrated to the gripper finger plane, so what it counts is that plane's height + rather than the drive's own: `rotation_drive_move_to_z_position` is what takes the offset out. + + Args: + z_increments: where the finger plane is to go, in the increments the drive counts in. + speed_increments: max velocity, in increments/s. + acceleration_increments: in thousands of increments/s2. + current_limit: the motor current limit, 0 to 7. + """ + c = self.configuration + if speed_increments is None: + speed_increments = c.z_speed_default_increments + if acceleration_increments is None: + acceleration_increments = c.z_acceleration_default_increments + if current_limit is None: + current_limit = c.z_current_limit_default + return await self._driver.send_command( + module="R0", + command="ZA", + za=f"{z_increments:+06}", + zv=f"{speed_increments:05}", + zr=f"{acceleration_increments:03}", + zw=f"{current_limit}", + ) + + async def rotation_drive_move_to_z_position( + self, + z: float, + speed: Optional[float] = None, + acceleration: Optional[float] = None, + current_limit: Optional[int] = None, + ): + """Move the rotation drive's lowest point to a Z position. This moves it. + + Args: + z: where to put the rotation drive's bottom, in mm. + speed: how fast, in mm/s. + acceleration: how hard, in mm/s2. + current_limit: the motor current limit, 0 to 7. + + Raises: + ValueError: If any of them is outside what the drive accepts. + """ + c = self.configuration + if speed is None: + speed = c.z_speed_default + if acceleration is None: + acceleration = c.z_acceleration_default + if current_limit is None: + current_limit = c.z_current_limit_default + self._check_reachable("z", z) + + speed_increments = c.z_mm_to_increments(speed) + speed_low, speed_high = c.z_speed_range_increments + if not speed_low <= speed_increments <= speed_high: + raise ValueError( + f"speed must be between {c.z_increments_to_mm(speed_low)} and " + f"{c.z_increments_to_mm(speed_high)} mm/s, is {speed}" + ) + + # The drive counts acceleration in thousands of increments per second squared. + acceleration_increments = c.z_mm_to_increments(acceleration / 1000) + acceleration_low, acceleration_high = c.z_acceleration_range_increments + if not acceleration_low <= acceleration_increments <= acceleration_high: + raise ValueError( + f"acceleration must be between {c.z_increments_to_mm(acceleration_low * 1000)} and " + f"{c.z_increments_to_mm(acceleration_high * 1000)} mm/s2, is {acceleration}" + ) + + if not 0 <= current_limit <= 7: + raise ValueError(f"current_limit must be between 0 and 7, is {current_limit}") + + finger_plane = z - c.rotation_drive_z_offset_above_finger + try: + resp = await self._unchecked_fw_rotation_drive_move_to_z_position_increments( + z_increments=c.z_mm_to_increments(finger_plane), + speed_increments=speed_increments, + acceleration_increments=acceleration_increments, + current_limit=current_limit, + ) + # What was asked for, recorded as soon as the move answers, so the model holds it even if + # the read below cannot be taken. + self.update_location_by_reference_point(z=z) + return resp + finally: + # And then what the drive says, which is the last word either way. + await self._record_where_it_stopped("z") + + async def rotation_drive_move_to_safe_z_height( + self, + speed: Optional[float] = None, + acceleration: Optional[float] = None, + current_limit: Optional[int] = None, + ) -> float: + """Move the iSWAP up to the top of its Z travel, and read where that put it. This moves it. + + The precondition for any lateral move, as it is for the channels and the heads. The iSWAP has + no Z-safety command of its own, so this is an ordinary Z move to the top of `configuration.rotation_drive_z_range`. + + Args: + speed: how fast, in mm/s. + acceleration: how hard, in mm/s2. + current_limit: the motor current limit, 0 to 7. + + Returns: + The rotation drive's bottom Z once there, in mm. + """ + c = self.configuration + if speed is None: + speed = c.z_speed_default + if acceleration is None: + acceleration = c.z_acceleration_default + if current_limit is None: + current_limit = c.z_current_limit_default + await self.rotation_drive_move_to_z_position( + self.configuration.rotation_drive_z_range[1], + speed=speed, + acceleration=acceleration, + current_limit=current_limit, + ) + # The move read the drive back and recorded it on the way out, so the model holds where it + # stopped. Asking again would be a second `RZ` for the same answer, on the one method every + # lateral move goes through. Without a deck there is no model to hold it, and then it is read. + here = self.rotation_drive_get_reference_point_location() + return here.z if here is not None else await self.rotation_drive_request_z_position() + + # ---------------------------------------- + # Rotational Movement + # ---------------------------------------- + + # -- rotation, wrist and gripper -------------------------------------------- + # -- both joints, which the drive command carries together ----------------------- + + async def _unchecked_fw_rotation_drive_rotate_increments( + self, + rotation_increments: int, + wrist_increments: int, + rotation_speed_increments: Optional[int] = None, + wrist_speed_increments: Optional[int] = None, + rotation_acceleration_increments: Optional[int] = None, + wrist_acceleration_increments: Optional[int] = None, + rotation_current_limit: Optional[int] = None, + wrist_current_limit: Optional[int] = None, + ): + """Drive both joints to absolute increments. Nothing is guarded and nothing is recorded. + + The lowest command there is here: it takes what the drives count in and sends it. Both joints + go in one command because they move together - the wrist rides the rotation drive, so sending + them separately turns the arm and then corrects the wrist, sweeping a path neither target + describes. A caller that means to move one holds the other at where it already is. + + Args: + rotation_increments: where the rotation drive is to go, signed. + wrist_increments: where the wrist drive is to go, signed. + rotation_speed_increments: max velocity of the rotation drive, in increments/s. + wrist_speed_increments: max velocity of the wrist drive, in increments/s. + rotation_acceleration_increments: for the rotation drive, in thousands of increments/s2. + wrist_acceleration_increments: for the wrist drive, in thousands of increments/s2. + rotation_current_limit: the rotation motor's current limit. + wrist_current_limit: the wrist motor's current limit. + """ + c = self.configuration + if rotation_speed_increments is None: + rotation_speed_increments = c.rotation_speed_default_increments + if wrist_speed_increments is None: + wrist_speed_increments = c.wrist_speed_default_increments + if rotation_acceleration_increments is None: + rotation_acceleration_increments = c.rotation_acceleration_default_increments + if wrist_acceleration_increments is None: + wrist_acceleration_increments = c.wrist_acceleration_default_increments + if rotation_current_limit is None: + rotation_current_limit = c.rotation_current_limit_default + if wrist_current_limit is None: + wrist_current_limit = c.wrist_current_limit_default + return await self._driver.send_command( + module="R0", + command="PA", + wa=f"{rotation_increments:+06}", + wv=f"{rotation_speed_increments:05}", + wr=f"{rotation_acceleration_increments:03}", + ww=f"{rotation_current_limit}", + ta=f"{wrist_increments:+06}", + tv=f"{wrist_speed_increments:05}", + tr=f"{wrist_acceleration_increments:03}", + tw=f"{wrist_current_limit}", + ) + + def _resolve_rotation_increments(self, angle: Union[str, float]) -> int: + """A rotation stop's name or an angle, as the increments the drive counts in. + + Args: + angle: a stop in `configuration.rotation_drive_slots`, or degrees from the calibrated front stop. + + Returns: + Where the drive is to go, in increments. + + Raises: + ValueError: If the name is not a stop, or the angle is outside the drive's travel. + RuntimeError: If the stored stops have not been read. + """ + c = self.configuration + if isinstance(angle, str): + predefined_positions = c.rotation_drive_predefined_increments + if predefined_positions is None: + raise RuntimeError("the rotation drive's stops were not read; have you called `setup()`?") + if angle not in predefined_positions: + raise ValueError(f"{angle!r} is not one of the stops {tuple(predefined_positions)}") + increments = predefined_positions[angle] + else: + increments = c.rotation_drive_angle_to_increments(angle) + low, high = c.rotation_range_increments + if not low <= increments <= high: + raise ValueError( + f"{angle} is {increments} increments, outside the {low} to {high} the drive travels" + ) + return increments + + def _resolve_rotation_absolute_increments(self, angle: Union[str, float]) -> int: + """Where link 1 is to point on the deck, as the increments the rotation drive counts in. + + The drive turns link 1 and nothing else, so the deck angle and the drive's own differ by the + quarter turn between the drive's front stop and the deck's +x, and a stop's name means the + same in either frame. + + Args: + angle: a stop in `configuration.rotation_drive_slots`, or degrees on the deck. + + Returns: + Where the drive is to go, in increments. + + Raises: + ValueError: If the name is not a stop, or the deck angle is outside the drive's travel. + RuntimeError: If the stored stops have not been read. + """ + if isinstance(angle, str): + return self._resolve_rotation_increments(angle) + c = self.configuration + drive_angle = (angle + 90.0 + 180.0) % 360.0 - 180.0 + increments = c.rotation_drive_angle_to_increments(drive_angle) + low, high = c.rotation_range_increments + if not low <= increments <= high: + raise ValueError( + f"pointing link 1 at {angle} deg on the deck needs the drive at {drive_angle} deg, which " + f"is {increments} increments, outside the {low} to {high} it travels" + ) + return increments + + def _resolve_gripper_direction_increments( + self, angle: Union[str, float], rotation_increments: int + ) -> int: + """A gripper direction, as the increments the wrist drive counts in. + + The wrist carries link 2 on link 1, so where the gripper ends up pointing is both joints + together. Given where link 1 will be, this is the wrist that points it where it is asked to. + + Args: + angle: a direction in `GRIPPER_DECK_DIRECTIONS`, or degrees on the deck. + rotation_increments: where the rotation drive will be, in its own increments. + + Returns: + Where the wrist drive is to go, in increments. + + Raises: + ValueError: If the name is not a direction, or the fold it asks of the wrist is outside its + travel. + RuntimeError: If the wrist's stored stops were not read. + """ + c = self.configuration + if isinstance(angle, str): + if angle not in GRIPPER_DECK_DIRECTIONS: + raise ValueError(f"{angle!r} is not one of {tuple(GRIPPER_DECK_DIRECTIONS)}") + deck_angle = GRIPPER_DECK_DIRECTIONS[angle] + else: + deck_angle = angle + if c.wrist_drive_predefined_increments is None: + raise RuntimeError("the wrist's stored stops were not read; have you called `setup()`?") + + link_1_deck_angle = c.rotation_drive_increments_to_angle(rotation_increments) - 90.0 + straight = c.wrist_increments_to_deg(c.wrist_drive_predefined_increments["straight"]) + # A direction is the same direction a turn either way round, so the fold is taken to the + # half-turn nearest zero before it is asked of the drive: +225 and -135 point the same way, + # and only one of them is inside the travel. + wrist_deg = (deck_angle - link_1_deck_angle + straight + 180.0) % 360.0 - 180.0 + increments = c.wrist_deg_to_increments(wrist_deg) + # A stop's own angle converts back to the increment this arm stores for it, so a named + # direction off a named rotation lands there without being pushed. What is left is rounding: + # an angle a hair off a stop takes the stop, which is the tolerance legacy used. + for name, _ in c.WRIST_STOP_ANGLES: + stored = c.wrist_drive_predefined_increments[name] + if abs(wrist_deg - c.wrist_increments_to_deg(stored)) <= c.wrist_deg_per_increment: + increments = stored + break + low, high = c.wrist_range_increments + if not low <= increments <= high: + raise ValueError( + f"pointing the gripper at {angle} deg with link 1 at {link_1_deck_angle} deg needs the " + f"wrist at {wrist_deg} deg, outside its travel of " + f"{c.wrist_increments_to_deg(low)} to {c.wrist_increments_to_deg(high)} deg" + ) + return increments + + def _resolve_wrist_increments(self, angle: Union[str, float]) -> int: + """A wrist stop's name or an angle, as the increments the drive counts in. + + Args: + angle: a stop in `configuration.wrist_drive_slots`, or degrees from the drive's own zero. + + Returns: + Where the drive is to go, in increments. + + Raises: + ValueError: If the name is not a stop, or the angle is outside the drive's travel. + RuntimeError: If the stored stops have not been read. + """ + c = self.configuration + if isinstance(angle, str): + stops = c.wrist_drive_predefined_increments + if stops is None: + raise RuntimeError("the wrist drive's stops were not read; have you called `setup()`?") + if angle not in stops: + raise ValueError(f"{angle!r} is not one of the stops {tuple(stops)}") + increments = stops[angle] + else: + increments = c.wrist_deg_to_increments(angle) + low, high = c.wrist_range_increments + if not low <= increments <= high: + raise ValueError( + f"{angle} is {increments} increments, outside the {low} to {high} the wrist travels" + ) + return increments + + async def rotate_to_angles( + self, + rotation_relative_angle: Optional[Union[str, float]] = None, + rotation_absolute_angle: Optional[Union[str, float]] = None, + gripper_relative_angle: Optional[Union[str, float]] = None, + gripper_absolute_angle: Optional[Union[str, float]] = None, + raise_features: bool = True, + make_space: bool = False, + rotation_speed: Optional[float] = None, + wrist_speed: Optional[float] = None, + rotation_acceleration: Optional[float] = None, + wrist_acceleration: Optional[float] = None, + rotation_current_limit: Optional[int] = None, + wrist_current_limit: Optional[int] = None, + ): + """Rotate one or both iSWAP joints to absolute angles in a single motion. This moves the arm. + + Each joint takes either angle, and a stop's name means the same in both: `relative` is the + drive's own frame, `absolute` is the deck. They differ for a float - the rotation drive reads + zero at its front stop, a quarter turn from the deck's +x, and the wrist reads from its own + zero and turns with link 1 under it. A joint given neither angle holds where it is, read from + the drive rather than assumed. + + Both joints arrive together under a single motion plan, so the gripper sweeps a straight + joint-space path and IK-driven trajectories can be executed. + + Collision risk: the whole arm sweeps, and the path is neither joint's alone. + + Args: + rotation_relative_angle: where the rotation drive is to sit - `left`, `front` or `right` - or + degrees signed from its front stop. Mutually exclusive with `rotation_absolute_angle`. + rotation_absolute_angle: where link 1 is to point on the deck - `left`, `front` or `right` - + or degrees on the deck. Mutually exclusive with `rotation_relative_angle`. + gripper_relative_angle: where the wrist drive is to sit - `right`, `straight`, `left` or + `reverse` - or degrees from its own zero. Mutually exclusive with `gripper_absolute_angle`. + gripper_absolute_angle: where the gripper is to point on the deck - `right`, `front`, `left` + or `back` - or degrees on the deck. Mutually exclusive with `gripper_relative_angle`. + raise_features: whether to raise the channels and any head to safe Z before rotating. On by + default, since the arm sweeps over whatever they are standing in. + make_space: whether to clear the deck volume of the pose being commanded. Not built yet, so + passing True refuses rather than rotating without the clearance it promises. `make_space` + is the blanket clearance in the meantime. + rotation_speed [deg/sec]: max angular velocity, within what + `configuration.rotation_speed_range_increments` accepts. + wrist_speed [deg/sec]: max angular velocity, within what + `configuration.wrist_speed_range_increments` accepts. + rotation_acceleration [deg/sec^2]: max angular acceleration, within what + `configuration.rotation_acceleration_range_increments` accepts. + wrist_acceleration [deg/sec^2]: max angular acceleration, within what + `configuration.wrist_acceleration_range_increments` accepts. + rotation_current_limit: motor current protection limiter, 0..7. + wrist_current_limit: motor current protection limiter, 0..7. + + Raises: + RuntimeError: if `setup()` has not populated the predefined-stop tables, or an absolute angle + is given to a driver with no deck. + ValueError: if no angle is provided, if a joint is given both of its angles, or if either + resolved target increment is outside the hardware range. + NotImplementedError: if `make_space` is True, which is not built for a rotation yet. + """ + c = self.configuration + if rotation_speed is None: + rotation_speed = c.rotation_speed_default + if wrist_speed is None: + wrist_speed = c.wrist_speed_default + if rotation_acceleration is None: + rotation_acceleration = c.rotation_acceleration_default + if wrist_acceleration is None: + wrist_acceleration = c.wrist_acceleration_default + if rotation_current_limit is None: + rotation_current_limit = c.rotation_current_limit_default + if wrist_current_limit is None: + wrist_current_limit = c.wrist_current_limit_default + for relative, absolute, joint, own_frame in ( + (rotation_relative_angle, rotation_absolute_angle, "rotation", "rotation drive"), + (gripper_relative_angle, gripper_absolute_angle, "gripper", "wrist drive"), + ): + if relative is not None and absolute is not None: + raise ValueError( + f"pass {joint}_relative_angle or {joint}_absolute_angle, not both: they name the same " + f"joint, one in the {own_frame}'s own frame and one on the deck" + ) + if all( + angle is None + for angle in ( + rotation_relative_angle, + rotation_absolute_angle, + gripper_relative_angle, + gripper_absolute_angle, + ) + ): + raise ValueError("pass at least one angle; all four are None") + # An absolute angle is on the deck: with no deck, there is nothing to measure it against. + if ( + rotation_absolute_angle is not None or gripper_absolute_angle is not None + ) and self.rotation_drive_get_reference_point_location() is None: + raise RuntimeError( + "absolute angles are on the deck, and the iSWAP's arm is not modelled: the driver was " + "given no deck. Pass relative angles instead" + ) + # Held in the drive's own increments rather than through its angle, so a joint that is holding + # is sent exactly where it already is. + if rotation_absolute_angle is not None: + rotation = self._resolve_rotation_absolute_increments(rotation_absolute_angle) + elif rotation_relative_angle is not None: + rotation = self._resolve_rotation_increments(rotation_relative_angle) + else: + rotation = await self._rotation_drive_request_increments() + if gripper_absolute_angle is not None: + wrist = self._resolve_gripper_direction_increments(gripper_absolute_angle, rotation) + elif gripper_relative_angle is not None: + wrist = self._resolve_wrist_increments(gripper_relative_angle) + else: + wrist = await self._wrist_drive_request_increments() + rotation_speed_increments = c.rotation_deg_per_sec_to_increments(rotation_speed) + wrist_speed_increments = c.wrist_deg_per_sec_to_increments(wrist_speed) + rotation_acceleration_increments = c.rotation_deg_per_sec2_to_increments(rotation_acceleration) + wrist_acceleration_increments = c.wrist_deg_per_sec2_to_increments(wrist_acceleration) + for name, asked, increments, (low, high), in_degrees in ( + ( + "rotation_speed", + rotation_speed, + rotation_speed_increments, + c.rotation_speed_range_increments, + c.rotation_increments_to_deg_per_sec, + ), + ( + "wrist_speed", + wrist_speed, + wrist_speed_increments, + c.wrist_speed_range_increments, + c.wrist_increments_to_deg_per_sec, + ), + ( + "rotation_acceleration", + rotation_acceleration, + rotation_acceleration_increments, + c.rotation_acceleration_range_increments, + c.rotation_increments_to_deg_per_sec2, + ), + ( + "wrist_acceleration", + wrist_acceleration, + wrist_acceleration_increments, + c.wrist_acceleration_range_increments, + c.wrist_increments_to_deg_per_sec2, + ), + ): + if not low <= increments <= high: + raise ValueError( + f"{name} must be between {in_degrees(low)} and {in_degrees(high)}, is {asked}" + ) + for name, value in ( + ("rotation_current_limit", rotation_current_limit), + ("wrist_current_limit", wrist_current_limit), + ): + if not 0 <= value <= 7: + raise ValueError(f"{name} must be between 0 and 7, is {value}") + + rotation_target = c.rotation_drive_increments_to_angle(rotation) + wrist_target = c.wrist_increments_to_deg(wrist) + + # Check 1 - drive compliance: is the pose itself reachable? Says nothing about what else + # stands on the deck. + self._check_pose_reachable(rotation_target, wrist_target) + + if raise_features: + arm = self.arm + if arm.pipettes is not None: + await arm.pipettes.move_to_safe_z() + for head in (arm.head96, arm.head384): + if head is not None: + await head.move_to_safe_z() + + # Check 2 - collision detection: + + # channels + + # head + + # if collision_detection is not None and make_space: + + # TODO: clear the deck volume for the pose being commanded, in the shape `_make_space_for_y` + # uses - the frontmost point the arm would reach, and channel 0 moved in front of it. + if make_space: + raise NotImplementedError( + "make_space is not built for a rotation yet. Rotating anyway would sweep the arm while the " + "caller believes the deck was cleared, so this refuses instead. Clear the deck volume " + "first with `make_space()`, or pass make_space=False to accept the pose unchecked" + ) + + try: + resp = await self._unchecked_fw_rotation_drive_rotate_increments( + rotation_increments=rotation, + wrist_increments=wrist, + rotation_speed_increments=rotation_speed_increments, + wrist_speed_increments=wrist_speed_increments, + rotation_acceleration_increments=rotation_acceleration_increments, + wrist_acceleration_increments=wrist_acceleration_increments, + rotation_current_limit=rotation_current_limit, + wrist_current_limit=wrist_current_limit, + ) + # What was asked for, recorded before anything is read: a move that answered has arrived, + # and the model says so even if the reads below cannot be taken. + self.rotation_drive_update_angle(c.rotation_drive_increments_to_angle(rotation)) + self.wrist_drive_update_angle(c.wrist_increments_to_deg(wrist)) + return resp + finally: + # And then what the drives say, which is the last word either way. A move that stopped part + # way left the arm somewhere no target describes, and this is the only thing that finds it. + await self._record_where_the_joints_stopped() + + def _compute_pose_at_angles( + self, rotation_angle: float, gripper_relative_angle: float, y: Optional[float] = None + ) -> iSWAPPose: + """Where the arm would be with its joints at these angles. Nothing is read or moved. + + Worked from where the model has the drive, so it costs no commands, and it is what both of the + checks below a move ask. None when the arm is not modelled or the numbers the kinematics need + have not been read. + + Args: + rotation_angle: the rotation drive's angle, in degrees. + gripper_relative_angle: the wrist drive's angle, in degrees. + y: where the drive would be, in mm. Where the model has it when None. + + Returns: + The pose. + + Raises: + RuntimeError: If the arm is not modelled, its gripper is not, or the kinematics' numbers + have not been read. + """ + c = self.configuration + predefined_wrist_positions = c.wrist_drive_predefined_increments + drive = self.rotation_drive_get_reference_point_location() + gripper = self.gripper + if drive is None: + raise RuntimeError("the iSWAP's arm is not modelled; the driver was given no deck") + if gripper is None: + raise RuntimeError("the iSWAP's arm is modelled but its gripper is not") + if c.link_1_length is None or predefined_wrist_positions is None: + raise RuntimeError("the arm's link length or the wrist's stops were not read") + return self._forward_kinematics( + joints={ + iSWAPAxis.X: drive.x, + iSWAPAxis.Y: drive.y if y is None else y, + iSWAPAxis.Z: drive.z, + iSWAPAxis.ROTATION: rotation_angle, + iSWAPAxis.WRIST: gripper_relative_angle, + }, + link_1_length=c.link_1_length, + # Asked of the tool, not taken off the arm: the gripper knows how far its grip centre sits + # from the wrist, and a different end-effector would answer differently. + tool_center_point_distance=gripper.tool_center_point.x, + wrist_straight_angle=c.wrist_increments_to_deg(predefined_wrist_positions["straight"]), + rotation_drive_z_offset_above_finger=c.rotation_drive_z_offset_above_finger, + ) + + def _check_pose_reachable( + self, rotation_angle: float, gripper_relative_angle: float, y: Optional[float] = None + ) -> None: + """Raise if the arm cannot put its gripper where these angles would. + + Not what `_check_reachable` answers: that bounds one value on one axis. + + The X-arm is a rail across the back of the deck, behind the drive's own Y travel, so nothing + the arm carries may stand further back than the drive itself reaches. A pose is worked out + before it is commanded rather than discovered on the way into the rail. + + The crudest check there is: two points, at the end of the move, against one bound. It says + nothing about what the arm sweeps through on the way, nor about anything else standing on the + deck. Skipped entirely when the arm is not modelled or the numbers it needs have not been + read - a check that cannot be made must not look like one that passed. + + Args: + rotation_angle: where the rotation drive is being sent, in degrees. + gripper_relative_angle: where the wrist is being sent, in degrees. + y: where the drive is being sent, in mm. Where the model has it when None, which is what a + rotation leaves it at; a Y move carries the pose to a new Y without turning either joint. + + Raises: + ValueError: If either joint would land behind the drive's own back stop, or further forward + than the arm can carry it. + """ + y_max = self.configuration.rotation_drive_y_max + if y_max is None or self.rotation_drive_get_reference_point_location() is None: + return + pose = self._compute_pose_at_angles(rotation_angle, gripper_relative_angle, y=y) + at = "" if y is None else f" and the drive at y {y:.1f} mm" + # Known, or `_compute_pose_at_angles` would have refused to work the pose out at all. + link_1 = cast(float, self.configuration.link_1_length) + tool = cast(MechanicalGripper, self.gripper).tool_center_point.x + # Each joint against the window it can be carried through: back, the drive's own stop, which + # both joints reach past by their own length; front, the stop the channels leave the drive, + # less that same length. Both moving joints, not only the far one - link 1 alone is long enough + # to put the wrist behind the rail while the grip centre is still clear of it. + y_min = self.configuration.rotation_drive_y_min + for what, point, front in ( + ("wrist joint", pose.wrist_joint_location, y_min - link_1), + ("grip centre", pose.gripper_center_location, y_min - link_1 - tool), + ): + if point.y > y_max: + raise ValueError( + f"rotation {rotation_angle:.2f} deg with the wrist at {gripper_relative_angle:.2f}{at} would put the " + f"{what} at y {point.y:.1f} mm, behind the {y_max:.1f} mm the rotation drive itself " + f"reaches - the X-arm runs across the back of the deck there. Turn the arm the other " + f"way, or move the drive forward first" + ) + if point.y < front: + raise ValueError( + f"rotation {rotation_angle:.2f} deg with the wrist at {gripper_relative_angle:.2f}{at} would put the " + f"{what} at y {point.y:.1f} mm, in front of the {front:.1f} mm the arm reaches with the " + f"drive at its own front stop of {y_min:.1f} mm - the channels ride in front of it and " + f"it stops behind them. Turn the arm the other way, or move the drive back first" + ) + + async def _record_where_the_joints_stopped(self) -> None: + """Read both joints and record them on the model. + + Its own failure is logged and swallowed: it runs on a move's failure path as well as its + success, and it must not replace the exception that says what went wrong. + """ + try: + await self.rotation_drive_request_angle() + await self.wrist_drive_request_angle() + except Exception: + logger.warning("could not read where the iSWAP's joints stopped; its model is stale") + + # -- rotation drive -------------------------------------------------------------- + + async def rotation_drive_request_angle(self) -> float: + """Read the rotation drive's angle, signed from the calibrated front stop. + + Returns: + The angle in degrees. + """ + angle = self.configuration.rotation_drive_increments_to_angle( + await self._rotation_drive_request_increments() + ) + self.rotation_drive_update_angle(angle) + return angle + + async def _rotation_drive_request_increments(self) -> int: + """Reads the rotation drive's position in the increments the drive counts in. + + Returns: + int: The drive's position, in increments. + """ + resp = await self._driver.send_command(module="R0", command="RW", fmt="rw######") + return cast(int, resp["rw"]) + + async def rotation_drive_rotate_to_angle( + self, + angle: Union[str, float], + speed: Optional[float] = None, + acceleration: Optional[float] = None, + current_limit: Optional[int] = None, + ): + """Turn the rotation drive to an angle, holding the wrist where it is. This moves the arm. + + A caller for `rotate_to_angles`, which is where the move and the model update live. The wrist + is left to hold where it is, so one command carries both joints. + + Args: + angle: a stop named in `configuration.rotation_drive_slots`, or degrees signed from the + calibrated front stop. + speed [deg/sec]: max angular velocity. + acceleration [deg/sec^2]: max angular acceleration. + current_limit: motor current protection limiter, 0..7. + + Raises: + ValueError: If the angle lands outside the drive's travel, or an argument is out of range. + """ + c = self.configuration + if speed is None: + speed = c.rotation_speed_default + if acceleration is None: + acceleration = c.rotation_acceleration_default + if current_limit is None: + current_limit = c.rotation_current_limit_default + return await self.rotate_to_angles( + rotation_relative_angle=angle, + rotation_speed=speed, + rotation_acceleration=acceleration, + rotation_current_limit=current_limit, + ) + + # -- wrist drive ----------------------------------------------------------------- + + async def wrist_drive_request_angle(self) -> float: + """Read the wrist drive's angle, signed from the motor's own zero. + + That zero sits between the straight and left stops, which keeps the reachable range symmetric + about it rather than anchoring it on a stop. + + Returns: + The angle in degrees. + """ + angle = self.configuration.wrist_increments_to_deg(await self._wrist_drive_request_increments()) + self.wrist_drive_update_angle(angle) + return angle + + async def _wrist_drive_request_increments(self) -> int: + """Reads the wrist drive's position in the increments the drive counts in. + + Returns: + int: The drive's position, in increments. + """ + resp = await self._driver.send_command(module="R0", command="RT", fmt="rt######") + return cast(int, resp["rt"]) + + async def wrist_drive_rotate_to_angle( + self, + angle: Union[str, float], + speed: Optional[float] = None, + acceleration: Optional[float] = None, + current_limit: Optional[int] = None, + ): + """Turn the wrist to an angle, holding the rotation drive where it is. This moves the arm. + + The mirror of `rotation_drive_rotate_to_angle`, and the same one move underneath. + + Args: + angle: one of the stops in `configuration.wrist_drive_slots` - `straight`, `left`, `right`, `reverse`, + `parking` - which goes to the increment this arm stores for it, or degrees signed from the + drive's own zero. + speed [deg/sec]: max angular velocity. + acceleration [deg/sec^2]: max angular acceleration. + current_limit: motor current protection limiter, 0..7. + + Raises: + ValueError: If the angle lands outside the drive's travel, or an argument is out of range. + """ + c = self.configuration + if speed is None: + speed = c.wrist_speed_default + if acceleration is None: + acceleration = c.wrist_acceleration_default + if current_limit is None: + current_limit = c.wrist_current_limit_default + # This one is named for its drive, so it stays in the drive's terms: the wrist angle is turned + # into the deck direction it points the gripper, which is what `rotate_to_angles` takes. + wrist = self._resolve_wrist_increments(angle) + rotation = await self._rotation_drive_request_increments() + link_1_deck_angle = c.rotation_drive_increments_to_angle(rotation) - 90.0 + if c.wrist_drive_predefined_increments is None: + raise RuntimeError("the wrist's stored stops were not read; have you called `setup()`?") + straight = c.wrist_increments_to_deg(c.wrist_drive_predefined_increments["straight"]) + return await self.rotate_to_angles( + gripper_absolute_angle=link_1_deck_angle + (c.wrist_increments_to_deg(wrist) - straight), + wrist_speed=speed, + wrist_acceleration=acceleration, + wrist_current_limit=current_limit, + ) + + # -- gripper drive --------------------------------------------------------------- + + async def gripper_request_counters(self) -> Tuple[int, int]: + """Read both counters the gripper drive keeps, in its own increments. + + The drive answers what the firmware believes it commanded and what the encoder reads back. + They part when the drive has lost steps - jammed against something, or driven into its own + stop - and the gap is the only sign of it: a single width read cannot show it, and a drive + whose counters have parted will refuse to initialize until it has been freed. + + Returns: + The counter the firmware keeps and the one the encoder reads, in that order. + """ + resp = await self._driver.send_command(module="R0", command="RG", fmt="rg##### (n)") + firmware, hardware = cast(List[int], resp["rg"]) + if abs(firmware - hardware) > self.configuration.gripper_counter_drift_increments: + logger.warning( + "the gripper drive's counters are %d increments apart (firmware %d, hardware %d), which is " + "a drive that has lost steps rather than one that has moved", + abs(firmware - hardware), + firmware, + hardware, + ) + return firmware, hardware + + async def gripper_request_latest_force_applied(self) -> Dict[str, int]: + """Read what the gripper's force sensor and motor current did during the last movement. + + All of it is the arm's own raw measurement, and the last entry is the only one in engineering + units - the arm converts it with a divisor it keeps in its own memory. + + Returns: + The peak drive current and peak force during the last movement, the sensor's idle offset, + its last reading, all in the sensor's own counts, and that last reading in millinewtons. + """ + resp = await self._driver.send_command(module="R0", command="RH", fmt="rh#### (n)") + current, peak, idle, last, millinewtons = cast(List[int], resp["rh"]) + return { + "peak_current": current, + "peak_force": peak, + "idle_offset": idle, + "last_force": last, + "last_force_millinewtons": millinewtons, + } + + async def gripper_request_width(self) -> float: + """Read how far the gripper jaws are open. + + Where the fingers are, which after a grip is not how wide the thing between them is: a close + stops by pressing into what it meets, so it leaves them nearer together than the object stands. + + Returns: + The jaw width in mm. + """ + # Through the counters rather than off the wire again: it is the same command, and reading it + # there is what says whether the drive has lost steps. A width taken on its own cannot show + # that, and a caller who only ever asks how wide the jaws are would never be told. + _, hardware = await self.gripper_request_counters() + width = self.configuration.gripper_increments_to_mm(hardware) + self.gripper_update_width(width) + return width + + async def request_plate_gripped(self) -> bool: + """Read whether the arm is holding something between its fingers. + + The arm's own answer, not the model's: the gripper reports it, so a plate taken or dropped by + anything other than this driver still shows up. `gripped` is what the model says, and the two + disagreeing means the model has lost track of what the arm is carrying. + + Returns: + True while it holds something. + """ + resp = await self._driver.send_command(module="C0", command="QP", subsystem="R0", fmt="ph#") + gripped = cast(int, resp["ph"]) == 1 + self.gripped = gripped + return gripped + + async def _unchecked_fw_gripper_move_to_jaw_position_increments( + self, + increments: int, + speed_increments: Optional[int] = None, + acceleration_increments: Optional[int] = None, + current_limit: Optional[int] = None, + ): + """Drive the jaws to an absolute width. Nothing is guarded and nothing is recorded. + + The lowest command there is here: it takes what the drive counts in and sends it. It feels + nothing on the way - the drive pushes to where it is told with whatever the current limit + allows, and says so only once it has locked. What checks, chooses and records is + `gripper_move_to_jaw_position`; the drive's own knobs are here for a caller that needs them. + + Args: + increments: where the jaws are to go, in the drive's own steps. + speed_increments: max velocity, in increments/s. The drive's own default when None. + acceleration_increments: in thousands of increments/s2. The drive's own default when None. + current_limit: the motor current limit. The drive's own default when None. + """ + c = self.configuration + if speed_increments is None: + speed_increments = c.gripper_speed_default_increments + if acceleration_increments is None: + acceleration_increments = c.gripper_acceleration_default_increments + if current_limit is None: + current_limit = c.gripper_current_limit_default + return await self._driver.send_command( + module="R0", + command="GA", + ga=f"{increments:05}", + gv=f"{speed_increments:04}", + gr=f"{acceleration_increments:03}", + gw=f"{current_limit:02}", + ) + + async def gripper_move_to_jaw_position( + self, + width: float, + speed: Optional[float] = None, + acceleration: Optional[float] = None, + current_limit: Optional[int] = None, + ): + """Open the jaws to a width. This moves them. + + A position, driven: the jaws go where they are told whether or not something is in the way, and + the drive says nothing until it has locked. It feels nothing on the way, so closing onto a + thing is `gripper_close_with_force_sensed_width_window`, which watches the force sensor and + reports what it met. + + The jaws are read first, so which way this move goes is known rather than assumed. A close the + caller named no speed for is driven at half the configured default, since whatever the jaws + meet is met at whatever they were driven at. + + Args: + width: how far apart to stand the jaws, in mm. + speed: how fast to drive them, in mm/s. `configuration.gripper_speed_default_increments` + when None, halved for a close. + acceleration: how hard to accelerate, in mm/s2. + `configuration.gripper_acceleration_default_increments` when None. + current_limit: the motor current limit, 0 the weakest and 15 the strongest. + `configuration.gripper_current_limit_default` when None. + + Raises: + ValueError: If the width is outside the drive's travel, or the speed, acceleration or + current limit is outside what the drive accepts. + """ + c = self.configuration + # Compared in mm rather than in increments: a width read off the drive and sent straight back + # loses a fraction of an increment on the way, and the ends of the travel are exactly the + # widths a caller asks for when it wants the jaws shut or wide open. + low = c.gripper_increments_to_mm(c.gripper_range_increments[0]) + high = c.gripper_increments_to_mm(c.gripper_range_increments[1]) + if not low <= width <= high: + raise ValueError(f"width must be between {low} and {high} mm, is {width}") + width_increments = min( + max(c.gripper_mm_to_increments(width), c.gripper_range_increments[0]), + c.gripper_range_increments[1], + ) + + # Read the jaws before anything is chosen: which way this move goes decides what it is driven + # at, and a width that happens to equal the default is not the same as a caller naming none. + closing = width < await self.gripper_request_width() + + if current_limit is None: + current_limit = c.gripper_current_limit_default + limit_low, limit_high = c.gripper_current_limit_range + if not limit_low <= current_limit <= limit_high: + raise ValueError( + f"current_limit must be between {limit_low} and {limit_high}, is {current_limit}" + ) + + if speed is not None: + speed_increments = c.gripper_mm_per_sec_to_increments(speed) + else: + # Half speed into a close the caller did not set a speed for: this command feels nothing, so + # whatever the jaws meet is met at whatever they were driven at. + speed_increments = c.gripper_speed_default_increments + if closing: + speed_increments //= 2 + acceleration_increments = ( + c.gripper_acceleration_default_increments + if acceleration is None + else c.gripper_mm_per_sec2_to_increments(acceleration) + ) + for name, asked, increments, (limit_low, limit_high), in_mm in ( + ( + "speed", + speed, + speed_increments, + c.gripper_speed_range_increments, + c.gripper_increments_to_mm_per_sec, + ), + ( + "acceleration", + acceleration, + acceleration_increments, + c.gripper_acceleration_range_increments, + c.gripper_increments_to_mm_per_sec2, + ), + ): + if not limit_low <= increments <= limit_high: + raise ValueError( + f"{name} must be between {in_mm(limit_low)} and {in_mm(limit_high)}, is {asked}" + ) + + try: + resp = await self._unchecked_fw_gripper_move_to_jaw_position_increments( + increments=width_increments, + speed_increments=speed_increments, + acceleration_increments=acceleration_increments, + current_limit=current_limit, + ) + # What was asked for, recorded as soon as the move answers, so the model holds it even if + # the read below cannot be taken. + self.gripper_update_width(width) + return resp + finally: + # And then what the drive says, which is the last word. This one stalls: sent the full sweep + # from its open end it has locked part way and answered an error, leaving the jaws nowhere + # the target described - which a model taking only the target would have denied. + await self._record_where_it_stopped("gripper") + + async def gripper_open(self): + """Open the jaws all the way. This moves them. + + Opening cannot close on anything, so it is a plain move to the far end of the drive's travel. + What clears the jaws of whatever they are about to take hold of. + """ + c = self.configuration + return await self.gripper_move_to_jaw_position( + c.gripper_increments_to_mm(c.gripper_range_increments[1]) + ) + + async def gripper_close(self): + """Close the jaws all the way. This moves them. + + Shut, which is below the width the master's own close will be aimed at, so it is a plain move + too. Closing onto something and holding it is what `gripper_move_to_jaw_position` does at any + width above that floor. + """ + c = self.configuration + return await self.gripper_move_to_jaw_position( + c.gripper_increments_to_mm(c.gripper_range_increments[0]) + ) + + async def _unchecked_fw_gripper_close_with_force_sensed_width_window_increments( + self, + grip_strength: int, + width_increments: int, + width_tolerance_increments: int, + ): + """Close the jaws onto an object, feeling for it. Nothing is guarded and nothing is recorded. + + The lower of the two commands the jaws take and the one that senses: the master closes until + the force sensor says it has met something, and answers an error when it meets nothing. What + guards and records is `gripper_close_with_force_sensed_width_window`. + + Both widths are in the tenths of a millimetre the master counts in, which is not what the + drive counts in: a grip is stated to the master, and a position to the drive. + + Args: + grip_strength: how hard to hold, 0 the weakest and 9 the strongest. + width_increments: how wide the thing between the jaws is said to be, in tenths of a + millimetre. + width_tolerance_increments: how far off that width the thing may be, in tenths of a + millimetre. The jaws must start further apart than the width and this together. + """ + return await self._driver.send_command( + module="C0", + command="GC", + subsystem="R0", + gw=f"{grip_strength}", + gb=f"{width_increments:04}", + gt=f"{width_tolerance_increments:02}", + ) + + async def gripper_close_with_force_sensed_width_window( + self, + width: float, + grip_strength: int = 5, + width_tolerance: float = 2.0, + ): + """Close the jaws onto whatever is between them and hold it. This moves them. + + They stop on what they meet, where `gripper_move_to_jaw_position` drives to a width whatever is + in the way. So the width is where to look, not where the jaws end up: they end inside the thing, + and a plate stated at 85.5 mm was held at 80.3 mm. The jaws have to start clear of it. + + Args: + width: how wide the thing between the jaws is said to be, in mm. + grip_strength: how hard to hold, 0 the weakest and 9 the strongest. + width_tolerance: how far off that width the thing may be, in mm. Something met inside that + window is gripped; a close that runs past it reports finding nothing. + + Raises: + ValueError: If any of them is outside what the command accepts. + """ + c = self.configuration + if not 0 <= grip_strength <= 9: + raise ValueError(f"grip_strength must be between 0 and 9, is {grip_strength}") + # The master's own floor: below it the closing ramp would run past the drive's minimum. + high = c.gripper_increments_to_mm(c.gripper_range_increments[1]) + if not 76.0 < width <= high: + raise ValueError(f"width must be between 76.0 and {high} mm, is {width}") + if not 0.5 <= width_tolerance <= 9.9: + raise ValueError(f"width_tolerance must be between 0.5 and 9.9 mm, is {width_tolerance}") + # TODO: compute what is actually between the fingers before closing, and refuse a width that + # does not describe it. Doing that needs a `Resource.contains(point)` that respects rotation + # - the arm turns, and a corner plus a bounding-box extent is not a rotated box - and a deck + # query that answers it without sweeping every well and tip. Neither exists yet, and the + # version removed here was wrong on rotated resources, silently skipped unless the fingers + # lay within a few degrees of a deck axis, and cost 72 ms per grip on a loaded deck. + + try: + resp = await self._unchecked_fw_gripper_close_with_force_sensed_width_window_increments( + grip_strength=grip_strength, + width_increments=round(width * 10), + width_tolerance_increments=round(width_tolerance * 10), + ) + return resp + finally: + # Where the jaws stopped is the only word on it: a close has no target / is a probing action, + # so nothing here knows the width until the drive is read. + await self._record_where_it_stopped("gripper") + + async def _unchecked_fw_gripper_close_to_object_increments( + self, + destination_increments: int, + stop_band_increments: int, + stop_trigger: Optional[int] = None, + speed_increments: Optional[int] = None, + current_limit: Optional[int] = None, + low_pass_filter: Optional[bool] = None, + ): + """Close the jaws toward a width, stopping on whatever they meet, without checking or + recording. + + The drive's own version of the master's close, with the two things the master hides under a + dial: the band around the destination in which meeting something counts, and the force at + which meeting is declared. The arm still feels, and still answers an error when it reaches the + end of the band having met nothing. + + It has a destination, which is what makes it safe to point at an unknown object: it stops + there whatever happens, rather than closing until something stops it. + + Args: + destination_increments: where the jaws are expected to meet the object, in the drive's steps. + stop_band_increments: how far either side of that still counts, in the drive's steps. + stop_trigger: how hard a push counts as meeting something, in the sensor's own counts. + speed_increments: max gripping velocity, in increments/s. + current_limit: the motor current limit, 0 to 15. + low_pass_filter: whether to filter the current signal the trigger is read from. + """ + c = self.configuration + if stop_trigger is None: + stop_trigger = c.gripper_stop_trigger_default + if speed_increments is None: + speed_increments = c.gripper_close_speed_default_increments + if current_limit is None: + current_limit = c.gripper_current_limit_default + if low_pass_filter is None: + low_pass_filter = c.gripper_low_pass_filter_default + return await self._driver.send_command( + module="R0", + command="GB", + gb=f"{destination_increments:05}", + gu=f"{speed_increments:04}", + gd=f"{stop_band_increments:04}", + gw=f"{current_limit:02}", + gi=f"{stop_trigger:03}", + fi=f"{int(low_pass_filter)}", + ) + + async def gripper_probe_for_object( + self, + expected_width: float, + band: Optional[float] = None, + stop_trigger: Optional[int] = None, + current_limit: Optional[int] = None, + ) -> Optional[float]: + """Close the jaws toward a width and report what they met on the way. This moves them. + + What the master's close cannot do, because its band is fixed at a couple of millimetres: this + one opens the window as wide as the drive allows, so something several millimetres off the + width expected is still found rather than reported missing. + + It stops at the width given whether or not it meets anything, which is what keeps it away from + the drive's own stop - a close with no destination runs the jaws into it and latches the drive. + + The jaws are left where they stopped. What was found is being held, and letting go is the + caller's decision. + + Args: + expected_width: roughly how wide the thing between the jaws is, in mm. + band: how far either side of that to accept, in mm. + stop_trigger: how hard a push counts as meeting something, in the sensor's own counts. + current_limit: the motor current limit, 0 to 15. + + Returns: + How far apart the jaws stopped, in mm, or None when they reached the width given without + meeting anything within the band. + + A width is where the fingers stopped pushing, not how wide what they met is: this stops on a + lighter push than a grip does and so stops nearer the object, but it still stops inside it. + A plate 85.5 mm across was found at 82.1 mm by this and held at 80.3 mm by a grip. + + None is not a promise that the jaws are empty: something far enough outside the band is met + without being reported, and the arm has answered "plate not found" with its force sensor + reading twenty times its idle value. + + Raises: + ValueError: If any argument is outside what the drive accepts. + """ + c = self.configuration + if band is None: + band = c.gripper_stop_band_max + if stop_trigger is None: + stop_trigger = c.gripper_stop_trigger_default + if current_limit is None: + current_limit = c.gripper_current_limit_default + low = c.gripper_increments_to_mm(c.gripper_range_increments[0]) + high = c.gripper_increments_to_mm(c.gripper_range_increments[1]) + if not low <= expected_width <= high: + raise ValueError(f"expected_width must be between {low} and {high} mm, is {expected_width}") + band_increments = c.gripper_mm_to_increments(band) + band_low, band_high = c.gripper_stop_band_range_increments + if not band_low <= band_increments <= band_high: + raise ValueError( + f"band must be between {c.gripper_increments_to_mm(band_low)} and " + f"{c.gripper_increments_to_mm(band_high)} mm, is {band}" + ) + if not 0 <= stop_trigger <= 999: + raise ValueError(f"stop_trigger must be between 0 and 999, is {stop_trigger}") + if not 0 <= current_limit <= 15: + raise ValueError(f"current_limit must be between 0 and 15, is {current_limit}") + + destination = min( + max(c.gripper_mm_to_increments(expected_width), c.gripper_range_increments[0]), + c.gripper_range_increments[1], + ) + found = True + try: + await self._unchecked_fw_gripper_close_to_object_increments( + destination_increments=destination, + stop_band_increments=band_increments, + stop_trigger=stop_trigger, + current_limit=current_limit, + ) + except STARFirmwareError as error: + # An error is one per module, so the arm's own part is what says it met nothing: the master + # reports a failure alongside it, and the dict is keyed by display name rather than by id. + met_nothing = any( + part.raw_module == "R0" and isinstance(part, NoElementError) + for part in error.errors.values() + ) + if not met_nothing: + raise + found = False + finally: + # Where the jaws stopped is the answer, and it can only be read: a probe has no target. + await self._record_where_it_stopped("gripper") + + return await self.gripper_request_width() if found else None + + async def initialize_gripper_drive(self, current_limit: Optional[int] = None): + """Bring the gripper drive back to its own reference. This moves the jaws. + + The arm's own initialize brings every drive up and swings the whole arm to do it. This is the + one drive, which is what a gripper that has lost its reference needs - and what it refuses + while it is jammed, since it cannot travel to find its sensor edge. `_unchecked_fw_gripper_move_relative_increments` + is what frees it first. + + Args: + current_limit: the motor current limit, 0 to 15. + + Raises: + ValueError: If the current limit is outside what the drive accepts. + """ + c = self.configuration + if current_limit is None: + current_limit = c.gripper_current_limit_default + if not 0 <= current_limit <= 15: + raise ValueError(f"current_limit must be between 0 and 15, is {current_limit}") + + try: + return await self._driver.send_command(module="R0", command="GI", gw=f"{current_limit:02}") + finally: + # Finding the sensor edge is a travel, so where the jaws end up is only known by reading. + await self._record_where_it_stopped("gripper") + + async def _unchecked_fw_gripper_move_relative_increments( + self, + distance_increments: int, + opening: bool, + speed_increments: Optional[int] = None, + acceleration_increments: Optional[int] = None, + current_limit: Optional[int] = None, + ): + """Move the jaws a distance, unsupervised, without checking or recording. + + The one movement the drive accepts while it is uninitialized, and the only way out of a jam: + everything else is refused until the drive has a reference, and the initialize cannot give it + one while it cannot move. Unsupervised means the drive reports nothing about whether it + arrived, so the counters have to be read after each attempt - and a nudge that changes nothing + is a drive still stuck rather than one that had nowhere to go. + + Args: + distance_increments: how far to travel, in the drive's own steps. + opening: whether to travel the way that opens the jaws. + speed_increments: max velocity, in increments/s. Slower than a normal move by default, + since this is used against something that is stuck. + acceleration_increments: in thousands of increments/s2. + current_limit: the motor current limit, 0 to 15. + """ + c = self.configuration + if speed_increments is None: + speed_increments = c.gripper_nudge_speed_default_increments + if acceleration_increments is None: + acceleration_increments = c.gripper_acceleration_default_increments + if current_limit is None: + current_limit = c.gripper_current_limit_default + return await self._driver.send_command( + module="R0", + command="GS", + gs=f"{distance_increments:04}", + gt=f"{0 if opening else 1}", + gv=f"{speed_increments:04}", + gr=f"{acceleration_increments:03}", + gw=f"{current_limit:02}", + ) + + async def _switch_gripper_drive_off(self): + """Cut the current to the gripper drive, so the jaws can be moved by hand. + + What the arm does to itself on any gripper error, and what a jam is freed by when the drive + cannot free itself. Whatever is held is released, and the drive keeps no reference through it - + `initialize_gripper_drive` is what gives it one back. + """ + try: + return await self._driver.send_command(module="R0", command="GO") + finally: + # Letting go moves the jaws under whatever load is on them. + await self._record_where_it_stopped("gripper") + + async def recover_gripper_drive( + self, + attempts: int = 4, + nudge: float = 1.1, + current_limit: Optional[int] = None, + ) -> bool: + """Get a stuck gripper drive moving again, and back onto its own reference. This moves it. + + A drive that has run into something it cannot pass stops reporting where it is: its two + counters part, every ordinary move answers that it is locked, and the initialize that would + fix the reference cannot run, because it has to travel to find its sensor edge and it cannot + travel. That is a state the arm cannot leave on its own. + + So this works outwards. It reads the counters, tries the initialize, and when that is refused + it nudges the jaws open by the one movement an uninitialized drive accepts, checking after each + nudge whether anything actually moved - unsupervised means the drive answers whether the + command was taken, not whether it went anywhere. A nudge that moves nothing is met by cutting + the drive's current and letting it go slack before trying again, which is what frees a drive + holding itself against its own stop. + + Args: + attempts: how many times to nudge and retry the initialize. + nudge: how far to open the jaws on each nudge, in mm. + current_limit: the motor current limit, 0 to 15. + + Returns: + True when the drive initialized, False when it is still stuck and needs freeing by hand. + + Raises: + ValueError: If any argument is outside what the drive accepts. + """ + c = self.configuration + if current_limit is None: + current_limit = c.gripper_current_limit_default + if attempts < 1: + raise ValueError(f"attempts must be at least 1, is {attempts}") + nudge_increments = c.gripper_mm_to_increments(nudge) + if not 0 < nudge_increments <= 9_999: + raise ValueError( + f"nudge must be between {c.gripper_increments_to_mm(1)} and " + f"{c.gripper_increments_to_mm(9_999)} mm, is {nudge}" + ) + if not 0 <= current_limit <= 15: + raise ValueError(f"current_limit must be between 0 and 15, is {current_limit}") + + for attempt in range(attempts): + firmware, hardware = await self.gripper_request_counters() + try: + await self.initialize_gripper_drive(current_limit=current_limit) + except STARFirmwareError: + logger.info( + "the gripper drive will not initialize (counters %d and %d); freeing it, attempt %d of %d", + firmware, + hardware, + attempt + 1, + attempts, + ) + else: + _, hardware = await self.gripper_request_counters() + logger.info("the gripper drive is back on its reference, reading %d", hardware) + return True + + before = hardware + try: + await self._unchecked_fw_gripper_move_relative_increments( + distance_increments=nudge_increments, opening=True, current_limit=current_limit + ) + except STARFirmwareError: + # Even unsupervised, a drive that cannot turn at all says so. That is not a reason to + # stop: what comes next is cutting its current, which is the thing that frees it. + logger.debug("the nudge was refused as well") + _, after = await self.gripper_request_counters() + + if after == before: + # It did not move, so it is holding itself somewhere. Letting go is the only thing left + # to try, and the drive keeps no reference through it - which the initialize above will + # give back on the next turn of this loop. + logger.info("the nudge moved nothing, so the drive is being switched off to let it go") + await self._switch_gripper_drive_off() + + firmware, hardware = await self.gripper_request_counters() + # Nudges move the jaws without reporting where to, so the last one leaves the model stale. + await self._record_where_it_stopped("gripper") + logger.warning( + "the gripper drive is still stuck after %d attempts, reading %d and %d. Its jaws have to be " + "freed by hand, and `initialize_gripper_drive` run afterwards", + attempts, + firmware, + hardware, + ) + return False + + # -- pose ------------------------------------------------------------------ + + async def request_joint_state(self) -> JointState: + """Read every axis, one after another, as the joint state the kinematics run on. + + Each read records what it answered, so this is also what brings the whole model back in step + with the arm - which is what a move touching more than one drive reads in its `finally`. + + Returns: + Each axis's position, in that axis's own units. + """ + return { + iSWAPAxis.X: await self.rotation_drive_request_x_position(), + iSWAPAxis.Y: await self.rotation_drive_request_y_position(), + iSWAPAxis.Z: await self.rotation_drive_request_z_position(), + iSWAPAxis.ROTATION: await self.rotation_drive_request_angle(), + iSWAPAxis.WRIST: await self.wrist_drive_request_angle(), + iSWAPAxis.GRIPPER: await self.gripper_request_width(), + } + + @staticmethod + def _forward_kinematics( + joints: JointState, + link_1_length: float, + tool_center_point_distance: float, + wrist_straight_angle: float, + rotation_drive_z_offset_above_finger: float, + ) -> iSWAPPose: + """Where a joint state puts the gripper. Pure arithmetic: nothing is read. + + One link off the rotation drive, and whatever is bolted to its far end. Link 1 leaves the drive + at the rotation angle; the tool leaves the wrist at that plus however far the wrist is turned + from straight. Angles are signed + counter-clockwise seen from above, and a yaw of 0 points along +x, deck-right. + + Args: + joints: the joint state, as `request_joint_state` returns it. + link_1_length: rotation joint to wrist joint, in mm - the arm's own. + tool_center_point_distance: wrist joint to the point the end-effector is programmed against, + in mm - the tool's own, which the gripper reports as its `tool_center_point`. + wrist_straight_angle: what the wrist reports when it is straight, in degrees. + rotation_drive_z_offset_above_finger: how far the drive's bottom sits above the fingers. + + Returns: + Every joint of the arm, and the deck angle the gripper faces along. + """ + link_1_deck_angle = joints[iSWAPAxis.ROTATION] - 90.0 + gripper_deck_angle = link_1_deck_angle + (joints[iSWAPAxis.WRIST] - wrist_straight_angle) + + alpha_1 = math.radians(link_1_deck_angle) + alpha_2 = math.radians(gripper_deck_angle) + + # Both joints sit at the drive's own height; the fingers hang below its bottom. + base = Coordinate(x=joints[iSWAPAxis.X], y=joints[iSWAPAxis.Y], z=joints[iSWAPAxis.Z]) + wrist = Coordinate( + x=base.x + link_1_length * math.cos(alpha_1), + y=base.y + link_1_length * math.sin(alpha_1), + z=base.z, + ) + return iSWAPPose( + rotation_joint_location=base, + wrist_joint_location=wrist, + gripper_center_location=Coordinate( + x=wrist.x + tool_center_point_distance * math.cos(alpha_2), + y=wrist.y + tool_center_point_distance * math.sin(alpha_2), + z=base.z - rotation_drive_z_offset_above_finger, + ), + gripper_deck_orientation=Rotation(z=gripper_deck_angle), + joints=joints, + ) + + async def _unchecked_fw_request_gripper_tcp(self) -> Coordinate: + """Ask the master where the gripper's tool centre point is. Nothing is guarded. + + The master answers from what it tracks rather than from the drives, and it has only been + measured right with both joints at predefined stops. Away from them it answers the rotation + drive's own position, which is wrong by however far the arm reaches - 275 mm with the links + extended. `request_pose` reads the drives and runs the kinematics, and is what anything + relying on the answer should call. + + Returns: + The tool centre point, in mm on the deck, as the master has it. + """ + resp = await self._driver.send_command( + module="C0", command="QG", fmt="xs#####xd#yj####yd#zj####zd#" + ) + return Coordinate( + x=cast(int, resp["xs"]) / 10 * (1 if resp["xd"] == 0 else -1), + y=cast(int, resp["yj"]) / 10 * (1 if resp["yd"] == 0 else -1), + z=cast(int, resp["zj"]) / 10 * (1 if resp["zd"] == 0 else -1), + ) + + async def request_pose(self) -> iSWAPPose: + """Where the gripper is, worked out from the joint state. + + Read and computed rather than asked for: the master answers a gripper position of its own, but + only correctly after certain commands have run. This reads each drive and runs the kinematics, + so it holds whenever it is called. + + Returns: + Every joint of the arm and where its tool ends up, in one answer: what a caller needs to say + whether the arm clears something is where its middle joint is as much as where its end is. + + Raises: + RuntimeError: If the arm's link length or the wrist's stops were not read, or the gripper is + not modelled. + """ + c = self.configuration + gripper = self.gripper + if c.link_1_length is None: + raise RuntimeError("the arm's link length was not read; have you called `star.setup()`?") + if gripper is None: + raise RuntimeError("the gripper is not modelled, so how far it reaches is unknown") + if c.wrist_drive_predefined_increments is None: + raise RuntimeError("the wrist drive's stops were not read; have you called `star.setup()`?") + + return self._forward_kinematics( + joints=await self.request_joint_state(), + link_1_length=c.link_1_length, + tool_center_point_distance=gripper.tool_center_point.x, + wrist_straight_angle=c.wrist_increments_to_deg( + c.wrist_drive_predefined_increments["straight"] + ), + rotation_drive_z_offset_above_finger=c.rotation_drive_z_offset_above_finger, + ) + + # -- parking --------------------------------------------------------------- + + async def _unchecked_fw_park(self, traverse_height: Optional[float] = None): + """Close the gripper and park the arm. Nothing is guarded and nothing is recorded. + + Args: + traverse_height: how high to lift to before travelling, in mm. + `default_minimum_traverse_height` when None. + + Raises: + ValueError: If the traverse height is outside what the command takes. + """ + if traverse_height is None: + traverse_height = self.default_minimum_traverse_height + low, high = self.park_traverse_height_range + if not low <= traverse_height <= high: + raise ValueError(f"the arm parks from {low} to {high} mm, not {traverse_height}") + return await self._driver.send_command( + module="C0", + command="PG", + subsystem="R0", + th=f"{round(traverse_height * 10):04}", + ) + + async def park(self, traverse_height: Optional[float] = None): + """Close the gripper and park the arm. This moves it. + + Parking retracts the arm, which is lateral motion, so the arm lifts to `traverse_height` before + it starts. Sending no height leaves that to the master, which does not raise the arm: an arm + left out over the deck is driven down into whatever is under it. + + Every axis moves, so every axis is read back afterwards, whether or not the park answered. + + Args: + traverse_height: how high to lift to before travelling, in mm. + `default_minimum_traverse_height` when None. + """ + try: + return await self._unchecked_fw_park(traverse_height) + finally: + # Parking drives every axis, so every axis is read back: each read records what it answered, + # and one command answering does not say where the others stopped. Its own failure is logged + # and swallowed, so it cannot replace the exception that says what went wrong. + try: + await self.request_joint_state() + except Exception: + logger.warning("could not read where the iSWAP parked; its model is stale") diff --git a/pylabrobot/hamilton/star/driver/features/iswap_tests.py b/pylabrobot/hamilton/star/driver/features/iswap_tests.py new file mode 100644 index 00000000000..11bfcba8c22 --- /dev/null +++ b/pylabrobot/hamilton/star/driver/features/iswap_tests.py @@ -0,0 +1,329 @@ +import math +import unittest +from typing import Any, List, Optional, Tuple, cast + +from pylabrobot.hamilton.protocol.text.framing import assemble_command +from pylabrobot.hamilton.star.device import RECORDING_STAR +from pylabrobot.hamilton.star.driver.features.iswap import iSWAP +from pylabrobot.hamilton.star.driver.simulator import STARSimulationDriver +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.end_effector import MechanicalGripper +from pylabrobot.resources.hamilton import STARDeck +from pylabrobot.resources.resource import Resource +from pylabrobot.utils.linalg import matrix_vector_multiply_3x3 + + +async def gripper() -> Tuple[iSWAP, List[str]]: + """The iSWAP of a simulated device, and the list its commands are recorded in. + + Returns: + The feature, and every command it sends from here on. + """ + driver = STARSimulationDriver(deck=STARDeck(), declared_configuration_json=RECORDING_STAR) + await driver.setup() + iswap = driver.iswap + assert iswap is not None + + sent: List[str] = [] + answer = driver.send_command + + async def recorded( + module: str, + command: str, + fmt: Optional[Any] = None, + subsystem: Optional[str] = None, + **kwargs: Any, + ): + # `fmt` and `subsystem` are the driver's own rather than firmware parameters, so they are taken + # as `send_command` takes them and never reach the assembler. + sent.append(assemble_command(module=module, command=command, id_=None, **kwargs)) + return await answer(module=module, command=command, fmt=fmt, subsystem=subsystem, **kwargs) + + driver.send_command = recorded # type: ignore[assignment] + return iswap, sent + + +def jaw_moves(sent: List[str]) -> List[str]: + """Every jaw move in what was sent.""" + return [command for command in sent if command.startswith("R0GA")] + + +def moves(sent: List[str]) -> List[str]: + """Every command in what was sent that puts something somewhere.""" + return [ + command + for command in sent + if command[:4] in ("R0YA", "R0ZA", "R0PA", "R0GA") or command[:4] in ("C0JY", "C0JZ") + ] + + +class TestJawMoves(unittest.IsolatedAsyncioTestCase): + """What a jaw move puts on the wire.""" + + async def test_a_jaw_move_carries_the_width_it_was_asked_for(self): + """The width reaches the drive as the position it is driven to. Asserted on the payload rather + than on the model, because the two are set from different values: the model is told the width + the caller asked for whatever the drive was sent, so a target lost between them shows up here + and nowhere else. Both ends of the travel, so a move carrying a constant would fail one.""" + iswap, sent = await gripper() + c = iswap.configuration + + await iswap.gripper_open() + await iswap.gripper_close() + + opening, closing = jaw_moves(sent) + self.assertIn(f"ga{c.gripper_range_increments[1]:05}", opening) + self.assertIn(f"ga{c.gripper_range_increments[0]:05}", closing) + + async def test_a_close_the_caller_did_not_time_runs_at_half_speed(self): + """A close carries half the configured default when the caller names no speed, since this + command feels nothing and meets whatever is there at whatever it was driven at. An opening move + carries the whole default, which is what separates the two.""" + iswap, sent = await gripper() + c = iswap.configuration + + await iswap.gripper_open() + await iswap.gripper_close() + + opening, closing = jaw_moves(sent) + self.assertIn(f"gv{c.gripper_speed_default_increments:04}", opening) + self.assertIn(f"gv{c.gripper_speed_default_increments // 2:04}", closing) + + +class TestYMoves(unittest.IsolatedAsyncioTestCase): + """What a Y move does before it is sure it can run.""" + + async def test_a_refused_y_move_leaves_the_deck_alone(self): + """Making space moves the channels, so every argument is checked before it runs: a speed the + drive will not take has to be refused with nothing moved, rather than with the deck rearranged + for a command that never went out. Driven with a target the channels are in the way of, since + one they already clear makes space without moving anything and would pass either way.""" + iswap, sent = await gripper() + pipettes = iswap.arm.pipettes + assert pipettes is not None + before = (await pipettes.request_y_positions())[0] + + with self.assertRaises(ValueError): + await iswap.rotation_drive_move_to_y_position(460.0, make_space=True, speed=500.0) + + self.assertEqual((await pipettes.request_y_positions())[0], before) + self.assertEqual(moves(sent), []) + + async def test_a_y_move_that_carries_the_arm_behind_the_rail_is_refused(self): + """The arm rides the carriage, so moving it back carries the pose with it. Turned to rotation + -90 deg with the wrist at -140 deg, the grip centre stands clear with the drive at 450 mm, and + 137 mm behind the rotation drive's back stop once the drive is there.""" + iswap, sent = await gripper() + c = iswap.configuration + assert c.rotation_drive_y_max is not None + iswap.update_location_by_reference_point(y=450.0) + iswap.rotation_drive_update_angle(-90.0) + iswap.wrist_drive_update_angle(-140.0) + iswap._check_pose_reachable(-90.0, -140.0) + + with self.assertRaises(ValueError): + await iswap.rotation_drive_move_to_y_position(c.rotation_drive_y_max) + self.assertEqual(moves(sent), []) + + async def test_a_y_move_that_keeps_the_arm_clear_goes_ahead(self): + """Pointing to the front, nothing the arm carries reaches behind the drive at any Y.""" + iswap, sent = await gripper() + c = iswap.configuration + assert c.rotation_drive_y_max is not None + iswap.rotation_drive_update_angle(0.0) + iswap.wrist_drive_update_angle(-45.0) + + await iswap.rotation_drive_move_to_y_position(c.rotation_drive_y_max) + self.assertEqual(len([m for m in moves(sent) if m.startswith("R0YA")]), 1) + + async def test_only_park_puts_the_arm_in_its_parking_position(self): + """The parking pose leaves the wrist joint behind the rotation drive's back stop, which only the + park command may do. A parked arm moves forward along Y, and is refused a move to the back stop + that would leave it in that pose.""" + iswap, sent = await gripper() + c = iswap.configuration + assert c.rotation_drive_y_max is not None + self.assertEqual(await iswap.rotation_drive_request_y_position(), c.rotation_drive_y_max) + + with self.assertRaises(ValueError): + await iswap.rotation_drive_move_to_y_position(c.rotation_drive_y_max) + self.assertEqual(moves(sent), []) + + await iswap.rotation_drive_move_to_y_position(c.rotation_drive_y_max - 5.0) + self.assertEqual(len([m for m in moves(sent) if m.startswith("R0YA")]), 1) + + +class TestRotationWithoutADeck(unittest.IsolatedAsyncioTestCase): + """A driver given no deck models no arm: a relative angle needs none, an absolute one is on it.""" + + async def test_a_relative_rotation_goes_ahead(self): + iswap, sent = await gripper() + iswap._driver.deck = None + + await iswap.rotate_to_angles(rotation_relative_angle="front", raise_features=False) + + self.assertEqual(len([move for move in moves(sent) if move.startswith("R0PA")]), 1) + + async def test_an_absolute_rotation_is_refused_before_anything_moves(self): + iswap, sent = await gripper() + iswap._driver.deck = None + + with self.assertRaises(RuntimeError): + await iswap.rotate_to_angles(rotation_absolute_angle="front", raise_features=False) + + self.assertEqual(moves(sent), []) + + +class TestTheModelIsSavedWhole(unittest.IsolatedAsyncioTestCase): + """A deck carrying the iSWAP is read back with the arm as it was: its parts and the angles its + drives last reported from `serialize`, and the gripper's jaw width from the state.""" + + async def test_a_deck_carrying_the_iswap_deserializes(self): + iswap, _ = await gripper() + deck = iswap._driver.deck + head = iswap.resource + assert deck is not None and head is not None + + loaded = Resource.deserialize(deck.serialize()) + loaded.load_all_state(deck.serialize_all_state()) + + self.assertEqual(loaded.get_resource(head.name).serialize(), head.serialize()) + + +class TestPosesAgainstTheRail(unittest.IsolatedAsyncioTestCase): + """What the X-arm at the back of the deck lets the arm reach.""" + + async def test_a_carriage_on_its_own_back_stop_may_still_turn_sideways(self): + """Parked at the back, the arm lying along the deck reaches nothing behind the carriage, so the + pose stands. It used to be refused: the limit came from a conversion rounded to two places and + the position from one rounded again to a single place, leaving the carriage a hundredth of a + millimetre past its own maximum.""" + iswap, _ = await gripper() + c = iswap.configuration + assert c.rotation_drive_predefined_increments is not None + assert c.wrist_drive_predefined_increments is not None + + self.assertEqual(await iswap.rotation_drive_request_y_position(), c.rotation_drive_y_max) + for stop in ("left", "right"): + angle = c.rotation_drive_increments_to_angle(c.rotation_drive_predefined_increments[stop]) + straight = c.wrist_increments_to_deg(c.wrist_drive_predefined_increments["straight"]) + iswap._check_pose_reachable(angle, straight) + + async def test_a_pose_that_reaches_behind_the_rail_is_still_refused(self): + """The slack is half an increment, not a licence: link 2 folded square backwards puts the grip + centre 137.7 mm behind the carriage, where the X-arm is.""" + iswap, _ = await gripper() + c = iswap.configuration + assert c.rotation_drive_predefined_increments is not None + assert c.wrist_drive_predefined_increments is not None + + angle = c.rotation_drive_increments_to_angle(c.rotation_drive_predefined_increments["right"]) + wrist = c.wrist_increments_to_deg(c.wrist_drive_predefined_increments["left"]) + with self.assertRaises(ValueError): + iswap._check_pose_reachable(angle, wrist) + + async def test_a_grip_centre_just_in_front_of_the_rail_is_allowed(self): + """Rotation -3.5 deg with the wrist at 121 deg puts the grip centre 6.2 mm in front of the + carriage's back stop. Measuring the tool from the gripper's corner rather than its wrist put it + 6.2 mm behind, and refused a pose the arm can reach.""" + iswap, _ = await gripper() + c = iswap.configuration + + self.assertEqual(await iswap.rotation_drive_request_y_position(), c.rotation_drive_y_max) + iswap._check_pose_reachable(-3.5, 121.0) + + +class TestToolCentrePoint(unittest.IsolatedAsyncioTestCase): + """Where the kinematics put the grip centre, against the arm the model builds.""" + + async def test_the_grip_centre_is_the_tool_length_from_the_wrist(self): + """The firmware reports the tool length from the wrist joint, so a pose worked out at any angles + carries the grip centre exactly that far past it.""" + iswap, _ = await gripper() + c = iswap.configuration + assert c.tool_length is not None + + for rotation in (-90.0, 0.0, 45.0, 90.0): + for wrist in (-135.0, -45.0, 45.0, 121.0): + with self.subTest(rotation=rotation, wrist=wrist): + pose = iswap._compute_pose_at_angles(rotation, wrist) + w, t = pose.wrist_joint_location, pose.gripper_center_location + self.assertAlmostEqual(math.hypot(t.x - w.x, t.y - w.y), c.tool_length, places=2) + + async def test_the_pose_puts_the_grip_centre_where_the_gripper_has_it(self): + """`request_pose` and the gripper resource describe the same arm from the same joints, so they + agree on where it grips. They parted by 13 mm when the tool was measured from the gripper's + corner instead of the joint it hangs on at link 1's far end.""" + iswap, _ = await gripper() + g = cast(MechanicalGripper, iswap.gripper) + + pose = await iswap.request_pose() + turned = g.get_absolute_rotation().get_rotation_matrix() + # The grip centre is stated from the joint the gripper hangs on, so that joint carries it across. + model = g.get_absolute_location() + Coordinate( + *matrix_vector_multiply_3x3(turned, (g.proximal_joint + g.tool_center_point).vector()) + ) + self.assertAlmostEqual(pose.gripper_center_location.x, model.x, places=2) + self.assertAlmostEqual(pose.gripper_center_location.y, model.y, places=2) + + +class TestLostSteps(unittest.IsolatedAsyncioTestCase): + """What a drive whose counters have parted tells whoever asks.""" + + async def test_a_width_read_says_when_the_drive_has_lost_steps(self): + """The two counters part when the drive has been driven into something, and that is the only + sign of it. A width read goes through them rather than off the wire on its own, so a caller who + only ever asks how wide the jaws are is still told.""" + iswap, _ = await gripper() + parted = iswap.configuration.gripper_counter_drift_increments + 100 + + async def counters_apart(**kwargs): + return {"rg": [13100, 13100 - parted]} + + iswap._driver.send_command = counters_apart # type: ignore[assignment] + with self.assertLogs("pylabrobot.hamilton.star.driver.features.iswap", "WARNING") as logged: + await iswap.gripper_request_width() + self.assertIn("lost steps", "".join(logged.output)) + + +class TestGripperDirections(unittest.IsolatedAsyncioTestCase): + """Where a named gripper direction sends the wrist.""" + + async def test_every_named_pose_lands_on_a_stored_stop(self): + """Three rotation stops against four directions, each resolving to one of the four increments + this arm stores for its wrist. Nothing is pushed there: the conversion interpolates against the + same stops, so a stop's own angle converts back to its own increment. A conversion anchored on + the motor's zero instead would miss two of the four by around a degree, which is more than a + rounding tolerance would carry.""" + iswap, _ = await gripper() + c = iswap.configuration + assert c.rotation_drive_predefined_increments is not None + assert c.wrist_drive_predefined_increments is not None + stored = {c.wrist_drive_predefined_increments[name] for name, _ in c.WRIST_STOP_ANGLES} + + for rotation in ("left", "front", "right"): + for direction in ("right", "back", "left", "front"): + increments = iswap._resolve_gripper_direction_increments( + direction, c.rotation_drive_predefined_increments[rotation] + ) + self.assertIn(increments, stored, f"{rotation}/{direction}") + + +class TestSafeZ(unittest.IsolatedAsyncioTestCase): + """What the move every lateral move waits on costs.""" + + async def test_going_to_safe_z_reads_the_drive_once(self): + """The Z move reads the drive back and records it, so the height comes off the model rather + than from a second `RZ` for the same answer. Asserted on the count because that is the whole + of it, and on the value because a model read that had drifted would be worse than the read it + saves.""" + iswap, sent = await gripper() + + height = await iswap.rotation_drive_move_to_safe_z_height() + + self.assertEqual(len([command for command in sent if command.startswith("R0RZ")]), 1) + self.assertEqual(height, await iswap.rotation_drive_request_z_position()) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/hamilton/star/driver/features/pipettes.py b/pylabrobot/hamilton/star/driver/features/pipettes.py new file mode 100644 index 00000000000..c45fb6a8464 --- /dev/null +++ b/pylabrobot/hamilton/star/driver/features/pipettes.py @@ -0,0 +1,1315 @@ +"""The pipetting channels: the row of independently driven pipettes on an arm.""" + +import asyncio +import datetime +import logging +import math +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Dict, Iterable, List, Literal, Optional, Tuple, cast + +from pylabrobot.hamilton.protocol.text.framing import parse_firmware_version_date +from pylabrobot.hamilton.star.driver.lock import _FirmwareLock +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.n_channel_pipettes import NChannelPipette, TipMountingShaft +from pylabrobot.resources.resource import Resource + +if TYPE_CHECKING: + from pylabrobot.hamilton.star.driver.features.x_arm import XArm + from pylabrobot.hamilton.star.driver.master import STARDriver + +logger = logging.getLogger(__name__) + +ChannelType = Literal["ML_STAR", "ML_STAR_RPC"] +HeadType = Literal["ML_STAR", "ML_STAR_PLE", "ML_STAR_RPC"] +StopDiscType = Literal["core_i", "core_ii"] +PressureADC = Literal["Renesas_X9268", "Analog_Devices_AD5263"] + + +# The letters a channel's module is addressed by, in order from the back. `channel_id` spells an +# address with them and `channel_from_module` reads one back. +CHANNEL_MODULE_LETTERS = "123456789ABCDEFG" + + +@dataclass +class PipetteConfiguration: + """The hardware fitted to a single pipetting channel. + + Read off the channel itself. Every field is None until it has been read. + """ + + channel_type: Optional[ChannelType] = None + head_type: Optional[HeadType] = None + stop_disc_type: Optional[StopDiscType] = None + pressure_adc: Optional[PressureADC] = None + firmware_version: Optional[str] = None + width: Optional[float] = None + """How wide the pipette is, in mm. Two channels cannot sit closer than this in Y.""" + + +@dataclass +class PipettesConfiguration: + """Configuration for the pipetting channels, and for each channel in turn. + + The encoder resolutions convert between the units a command carries on the wire (increments) + and the units the driver speaks (mm, uL). They are properties of the channel drives and are + identical across a device's channels, so they are held once, not per channel. + + `channels` holds what each individual channel carries. It is empty until setup has counted the + channels; only the device reports how many there are. + """ + + hardware_query_first_year: int = 2017 + """Firmware from 2016 or older does not carry the hardware query.""" + + initialize_y_range: Tuple[float, float] = (217.5, 405.0) + """The Y band the channels spread across during the initialization procedure, in mm.""" + + initialize_begin_of_tip_deposit: float = 245.0 + """Where the procedure begins depositing whatever is mounted, in mm.""" + + initialize_end_of_tip_deposit: float = 122.0 + """Where it ends, in mm.""" + + initialize_z_position_at_end: float = 360.0 + """Where the channels are left along Z when it finishes, in mm.""" + + initialize_tip_type: int = 4 + initialize_discarding_method: int = 0 + + initialize_read_timeout: int = 120 + """How long to wait for the procedure, in seconds. The channels travel to the waste and eject + there, so the reply is a long time coming.""" + + x_reference_anchor: str = "c" + """Along X every channel sits at the arm's own reference point: the master and the X-drive board + report the same position, so a channel's X is the arm's.""" + y_reference_anchor: str = "c" + z_reference_anchor: str = "b" + """Along Z the drive reports the bottom of the tip mounting shaft, which is what Hamilton's + firmware calls the stop disc. A channel carrying a shaft is anchored on the shaft's end rather + than on this, which then applies only to one that carries none.""" + + y_drive_mm_per_increment: float = 0.046302083 + z_drive_mm_per_increment: float = 0.01072765 + + z_range_increments: Tuple[int, int] = (9_320, 31_200) + """The Z travel the drive counts in, in increments, lowest first. The floor is the deck + surface, which is as low as a stop disc goes.""" + + # -- what a channel's own Z drive accepts, for the moves addressed to the channel itself -- + z_drive_speed_range_increments: Tuple[int, int] = (20, 15_000) + z_drive_speed_default: float = 125.0 + """How fast a channel's Z drive moves when the caller names nothing, in mm/s.""" + z_drive_acceleration_range_increments: Tuple[int, int] = (5, 150) + z_drive_acceleration_default: float = 800.0 + """How hard it accelerates when the caller names nothing, in mm/s2. Counted in thousands of + increments per second squared, unlike the positions and speeds beside it.""" + z_drive_current_limit_range: Tuple[int, int] = (0, 7) + z_drive_current_limit_default: int = 3 + + z_range: Tuple[float, float] = (99.98, 334.7) + """The Z window the channels reach, in mm, lowest first. + + What the drive counts, until setup replaces the ceiling with what `probe_z_max` read off this + device's channels. The floor is the deck surface either way.""" + dispensing_drive_mm_per_increment: float = 0.002734375 + dispensing_drive_uL_per_increment: float = 0.046876 + + channel_size_z: float = 140.0 + """How tall to model a channel, in mm. Not read from anywhere: how far a channel extends is not + something the device reports.""" + + channels: List[PipetteConfiguration] = field(default_factory=list) + """One entry per channel, in channel order.""" + + # -- conversions: the wire counts in increments, the driver speaks mm and uL --------------- + + def y_drive_increments_to_mm(self, increments: int) -> float: + """A Y-drive position in mm, from the increments the drive counts in.""" + return round(increments * self.y_drive_mm_per_increment, 2) + + def y_drive_mm_to_increments(self, mm: float) -> int: + """A Y-drive position in increments, from mm.""" + return round(mm / self.y_drive_mm_per_increment) + + def z_drive_increments_to_mm(self, increments: int) -> float: + """A Z-drive position in mm, from increments.""" + return round(increments * self.z_drive_mm_per_increment, 2) + + def z_drive_acceleration_increments_to_mm(self, increments: int) -> float: + """A Z-drive acceleration in mm/s2, from the thousands of increments it is counted in.""" + return round(increments * self.z_drive_mm_per_increment * 1000, 1) + + def z_drive_acceleration_mm_to_increments(self, mm: float) -> int: + """A Z-drive acceleration in increments, from mm/s2.""" + return round(mm / (self.z_drive_mm_per_increment * 1000)) + + @property + def z_speed_range(self) -> Tuple[float, float]: + """Z-drive speed window (mm/s).""" + low, high = self.z_drive_speed_range_increments + return (self.z_drive_increments_to_mm(low), self.z_drive_increments_to_mm(high)) + + @property + def z_acceleration_range(self) -> Tuple[float, float]: + """Z-drive acceleration window (mm/s2).""" + low, high = self.z_drive_acceleration_range_increments + return ( + self.z_drive_acceleration_increments_to_mm(low), + self.z_drive_acceleration_increments_to_mm(high), + ) + + def z_drive_mm_to_increments(self, mm: float) -> int: + """A Z-drive position in increments, from mm.""" + return round(mm / self.z_drive_mm_per_increment) + + def dispensing_drive_increments_to_uL(self, increments: int) -> float: + """A dispensing-drive position as the volume it holds, from increments.""" + return round(increments * self.dispensing_drive_uL_per_increment, 1) + + def dispensing_drive_uL_to_increments(self, uL: float) -> int: + """A dispensing-drive position in increments, from the volume to hold.""" + return round(uL / self.dispensing_drive_uL_per_increment) + + def dispensing_drive_increments_to_mm(self, increments: int) -> float: + """A dispensing-drive position as how far the piston has travelled, from increments.""" + return round(increments * self.dispensing_drive_mm_per_increment, 3) + + def dispensing_drive_mm_to_increments(self, mm: float) -> int: + """A dispensing-drive position in increments, from how far the piston should travel.""" + return round(mm / self.dispensing_drive_mm_per_increment) + + def check_channels_agree(self) -> None: + """Warn if the channels are not all running the same firmware. + + The resolutions above are held once for every channel, so they are one board's. Channels are + replaced individually, and a channel on different firmware may not convert the same way. A + device repaired piecemeal is the case this catches. + """ + by_version: Dict[str, List[int]] = {} + for channel, entry in enumerate(self.channels): + if entry.firmware_version is not None: + by_version.setdefault(entry.firmware_version, []).append(channel) + if len(by_version) <= 1: + return + reported = "; ".join( + f"{version} on channel{'s' if len(channels) > 1 else ''} " + f"{', '.join(str(c) for c in channels)}" + for version, channels in by_version.items() + ) + logger.warning( + "the pipetting channels are not all on the same firmware (%s). The conversion factors here " + "are held once for every channel, so a channel on different firmware may convert " + "differently, and the version recorded for the feature is channel %d's.", + reported, + next(iter(by_version.values()))[0], + ) + + def resolve_channels(self, num_channels: int) -> None: + """Size `channels` against the device, once it has said how many channels it has. + + A list supplied up front is left as it is. A caller can configure channels before the device + is known, and it is then checked, not overwritten. + + Args: + num_channels: how many channels the device reported. + + Raises: + ValueError: If a supplied list does not have one entry per channel. + """ + if not self.channels: + self.channels.extend(PipetteConfiguration() for _ in range(num_channels)) + elif len(self.channels) != num_channels: + raise ValueError(f"configuration has {len(self.channels)} channels, expected {num_channels}") + + +class Pipettes: + """The pipetting channels. + + Reached as `driver.pipettes`. Individual channels are addressed as `P1`..`PG`. The commands + that act on all of them at once go to the master, so this feature speaks to both. + + `configuration` holds what every channel shares, and one entry per channel in + `configuration.channels`. + """ + + def __init__(self, driver: "STARDriver", configuration: Optional[PipettesConfiguration] = None): + """ + Args: + driver: the driver to send commands through. + configuration: the channels' device facts. Defaults to `PipettesConfiguration()`. + """ + self._driver = driver + # One resource per channel, in channel order, when the driver was given a deck. Setup puts them + # on the arm; the reads keep them in step. Without a deck the list stays empty. + self.resources: List[Resource] = [] + self.configuration = configuration or PipettesConfiguration() + # The height the channels travel at when a command names none, in mm. Legacy STARBackend's + # channel traversal height. + self.default_minimum_traverse_height: float = 245.0 + + # -- addressing ------------------------------------------------------------ + + @staticmethod + def channel_id(channel: int) -> str: + """The module a channel is addressed by. Channel 0 is the one at the back.""" + return "P" + CHANNEL_MODULE_LETTERS[channel] + + @staticmethod + def channel_from_module(module: str) -> Optional[int]: + """Which channel a module address names, the other way round from `channel_id`. + + Args: + module: the two-character module, e.g. `P1`. + + Returns: + The channel, 0-indexed from the back, or None when the address names something else. + """ + if len(module) != 2 or module[0] != "P" or module[1] not in CHANNEL_MODULE_LETTERS: + return None + return CHANNEL_MODULE_LETTERS.index(module[1]) + + @property + def num_channels(self) -> int: + """How many channels are fitted, as counted at setup.""" + return self._driver.num_channels + + # -- session / discovery --------------------------------------------------- + + def _require_channel(self, channel: int) -> None: + """Raise unless this device has that channel. + + Args: + channel: which channel, 0-indexed from the back. + + Raises: + ValueError: If it is not a whole number, or the device has no such channel. + """ + if not isinstance(channel, int) or not (0 <= channel <= self.num_channels - 1): + raise ValueError(f"channel must be in [0, {self.num_channels - 1}], is {channel}") + + async def _record_where_they_stopped( + self, axis: Literal["y", "z"], channels: Optional[Iterable[int]] = None + ) -> None: + """Read where channels came to rest along one axis, and record it. + + A move that stopped part way left them somewhere no target describes. Its own failure is + logged and swallowed: it must not replace the move's exception, which is the one that says + what went wrong. + + Args: + axis: which axis the move drove - `y` across the deck, `z` up and down. + channels: which channels, 0-indexed from the back. All of them when None, read in one + command rather than one each. + """ + try: + if channels is None: + await (self.request_y_positions() if axis == "y" else self.request_stop_disc_z_positions()) + return + for channel in channels: + if axis == "y": + await self.request_y_position(channel) + else: + await self.request_stop_disc_z_position(channel) + except Exception: + logger.warning( + "could not read where the channels stopped along %s; their model is stale", axis + ) + + async def _require_tips(self, channels: Iterable[int], instead: str) -> None: + """Raise unless every named channel carries a tip. + + Args: + channels: which channels, 0-indexed from the back. + instead: the method to reach for when they do not, named in the refusal. + + Raises: + ValueError: If any of them carries no tip, naming which. + """ + presence = await self.sense_tip_presence() + bare = [channel for channel in channels if not presence[channel]] + if bare: + raise ValueError( + f"channels {bare} carry no tips, so they have no tool bottom; " + f"`{instead}` is the one that answers whatever is mounted" + ) + + async def request_firmware_version(self, channel: int) -> Tuple[str, datetime.date]: + """Request one channel's firmware version and build date. + + Args: + channel: which channel to ask, 0-indexed from the back. + + Returns: + The version string and its build date, e.g. `("4.0S j 2022-03-16", date(2022, 3, 16))`. + """ + self._require_channel(channel) + resp = await self._driver.send_command(module=self.channel_id(channel), command="RF") + return resp.split("rf")[-1], parse_firmware_version_date(resp) + + async def request_min_pipette_width(self, channel: int) -> float: + """Request how wide a pipette is. + + This is what bounds how close two channels can sit in Y: they cannot overlap. + + Args: + channel: which channel to ask, 0-indexed from the back. + + Returns: + The width in mm. + """ + self._require_channel(channel) + resp = await self._driver.send_command( + module=self.channel_id(channel), command="VY", fmt="yc### (n)" + ) + increments = cast(List[int], resp["yc"])[1] + return self.configuration.y_drive_increments_to_mm(increments) + + async def request_pipette_configuration(self, channel: int) -> PipetteConfiguration: + """Request what hardware is fitted to a pipette. + + Firmware from 2016 or older does not carry it. + + Args: + channel: which channel to ask, 0-indexed from the back. + + Returns: + What the channel reports about itself. The fields it does not report - its firmware version + and its width - are left None, since they are separate queries. + + Raises: + ValueError: If the reply carries no hardware fields at all. + """ + self._require_channel(channel) + resp = await self._driver.send_command(module=self.channel_id(channel), command="VW") + fields = resp.split("vw")[-1].strip().split() + if not fields: + raise ValueError(f"no hardware fields in the reply from channel {channel}: {resp!r}") + + def field_at(index: int) -> Optional[str]: + # The reply carries between two and four fields depending on firmware. A field that is not + # there falls back to its baseline value instead of failing: these are descriptive, and no + # pipetting decision reads them. + return fields[index] if index < len(fields) else None + + return PipetteConfiguration( + channel_type="ML_STAR_RPC" if field_at(0) == "1" else "ML_STAR", + head_type=( + "ML_STAR_PLE" if field_at(1) == "1" else "ML_STAR_RPC" if field_at(1) == "2" else "ML_STAR" + ), + stop_disc_type="core_i" if field_at(2) in ("0", None) else "core_ii", + pressure_adc="Analog_Devices_AD5263" if field_at(3) == "1" else "Renesas_X9268", + ) + + async def discover(self): + """Read what each channel is and what it can do. + + Read-only, and asks every channel at once. Fills in `configuration.channels`. + """ + self.configuration.resolve_channels(self.num_channels) + await asyncio.gather(*(self._discover_channel(ch) for ch in range(self.num_channels))) + self.configuration.check_channels_agree() + + async def _discover_channel(self, channel: int): + version, build_date = await self.request_firmware_version(channel) + # On older firmware the hardware fields simply stay unread, rather than the query failing. + pipette = ( + await self.request_pipette_configuration(channel) + if build_date.year >= self.configuration.hardware_query_first_year + else PipetteConfiguration() + ) + pipette.firmware_version = version + pipette.width = await self.request_min_pipette_width(channel) + self.configuration.channels[channel] = pipette + + # -- where the channels are ------------------------------------------------ + + def _reference_anchor(self, resource: Resource) -> Coordinate: + """Where on a channel's resource the drives report, from its left front bottom corner. + + The drives report the centre-centre-bottom of the tip mounting shaft, on all three axes, and the + shaft hangs below the body it is mounted on, so the point the drive names is the shaft's end rather + than the body's own bottom. A channel carrying a shaft states that point as its `reference_point`, + so a shaft of another length needs nothing changed here; one that carries none falls back to its + anchors. + + Args: + resource: the resource modelling the channel. + + Returns: + The offset from the resource's corner to the point the drives report. + """ + if isinstance(resource, NChannelPipette): + return resource.reference_point + stated = getattr(resource, "reference_point", None) + if isinstance(stated, Coordinate): + return stated + anchor = resource.get_anchor( + x=self.configuration.x_reference_anchor, + y=self.configuration.y_reference_anchor, + z=self.configuration.z_reference_anchor, + ) + shaft = next( + (child for child in resource.children if isinstance(child, TipMountingShaft)), None + ) + if shaft is None or shaft.location is None: + return anchor + return Coordinate(anchor.x, anchor.y, shaft.location.z) + + def get_reference_point_location(self, channel: int) -> Optional[Coordinate]: + """Where the model has a channel's reference point, in mm on the deck. + + The inverse of `update_location_by_reference_point`: it converts a reported position into a + location, and this converts a location back into the position that would be reported. X is + the arm's, so it is carried through unread. + + Args: + channel: which channel, 0-indexed from the back. + + Returns: + Where the model has it, or None when there is nothing modelling it yet. + """ + deck = self._driver.deck + if channel >= len(self.resources) or deck is None: + return None + resource = self.resources[channel] + if resource.location is None or resource.parent is None: + return None + return ( + resource.location + resource.parent.get_location_wrt(deck) + self._reference_anchor(resource) + ) + + def update_location_by_reference_point( + self, channel: int, y: Optional[float] = None, z: Optional[float] = None + ) -> None: + """Record where a channel is on the resource that models it. + + Y and Z only. A channel rides the arm, so its resource is a child of the arm's and follows it + in X with nothing recording that. A resource is located by its left front bottom corner, and + each axis differs from the reported position by the channel's reference point. + + The channel states that point, because it is not a corner of the box: the drives report the + stop disc, the shaft a tip mounts on, which hangs below the body. A channel stating nothing + falls back to its anchors, the same point when it carries no shaft. + + Both drives answer in the deck's frame, while a resource's location is measured from its + parent, the arm. The arm's position is taken out before either is recorded. Does nothing when + the driver was given no deck to model into. + + Args: + channel: which channel, 0-indexed from the back. + y: where it is now, in mm on the deck. Left as it was when None. + z: where its stop disc is now, in mm on the deck. Left as it was when None. + """ + deck = self._driver.deck + if channel >= len(self.resources) or deck is None: + return + resource = self.resources[channel] + if resource.location is None or resource.parent is None: + return + here, on_the_arm = resource.location, resource.parent.get_location_wrt(deck) + anchor = self._reference_anchor(resource) + resource.location = Coordinate( + here.x, + here.y if y is None else y - on_the_arm.y - anchor.y, + here.z if z is None else z - on_the_arm.z - anchor.z, + ) + + @staticmethod + def add_tip_mounting_shaft(channel: Resource) -> None: + """Hang a tip mounting shaft off the lower end of a channel, and measure the channel from it. + + The shaft hangs its own length below the channel's bottom, not inside it, so it reaches + lowest and the channel body starts clear of it. This is the arrangement a 96-head has, where + the shafts define the bottom of the assembly. It is centred on the channel, the axis a tip is + collected on. A shaft already there is left alone, and repeated setups do not duplicate it. + + The shaft is the stop disc the Z drive reports, and so is also where the channel is measured + from. That is the reference point this states and `update_location_by_reference_point` reads + back. + + Args: + channel: the channel resource to hang it from. + """ + name = f"{channel.name}_tip_mounting_shaft" + if any(child.name == name for child in channel.children): + return + shaft = TipMountingShaft(name=name, tip_pickup_mode="core") + channel.assign_child_resource( + shaft, + location=Coordinate( + (channel.get_absolute_size_x() - shaft.get_absolute_size_x()) / 2, + (channel.get_absolute_size_y() - shaft.get_absolute_size_y()) / 2, + -shaft.get_absolute_size_z(), + ), + ) + # Stated on a plain `Resource`, which does not declare the field: a channel is not yet the + # `NChannelPipette` that would, and that carries its own reference point as a `Coordinate`. + channel.reference_point = Coordinate( # type: ignore[attr-defined] + channel.get_absolute_size_x() / 2, + channel.get_absolute_size_y() / 2, + -shaft.get_absolute_size_z(), + ) + + # -- channel initialization ------------------------------------------------ + + def default_initialize_y_positions(self) -> List[float]: + """Where each channel sits in Y during initialization, in mm, back to front. + + The channels spread evenly across the band the procedure uses, clear of one another whatever + the channel count. + + Returns: + One position per channel, in mm, back to front. + """ + front, back = self.configuration.initialize_y_range + spacing = round((back - front) * 10) // (self.num_channels - 1) + return [(round(back * 10) - channel * spacing) / 10 for channel in range(self.num_channels)] + + async def sense_tip_presence(self) -> List[int]: + """Sense tip presence on every channel, from their sleeve sensors. + + Answered as the channels answer it, 1 where a tip is and 0 where none is, rather than narrowed + to True and False: the two carry the same meaning, and a value that is neither would be lost by + the narrowing rather than read back as it stands. + + Returns: + One value per channel, 1 where a tip is mounted, 0-indexed from the back. + """ + resp = await self._driver.send_command(module="C0", command="RT", fmt="rt# (n)") + return cast(List[int], resp.get("rt")) + + async def initialize( + self, + x_position: Optional[float] = None, + y_positions: Optional[List[float]] = None, + begin_of_tip_deposit_process: Optional[float] = None, + end_of_tip_deposit_process: Optional[float] = None, + z_position_at_end_of_a_command: Optional[float] = None, + tip_pattern: Optional[List[bool]] = None, + tip_type: Optional[int] = None, + discarding_method: Optional[int] = None, + ): + """Initialize the channels, discarding whatever is mounted on them. + + This moves the channels: they spread out across the Y band, travel to the tip waste, and + eject. Anything on a channel, including a gripper, ends up in the waste. + + Args: + x_position: X to eject at, in mm. Defaults to the device's tip waste position. + y_positions: where to put each channel in Y, in mm, back to front. Defaults to spreading + them evenly across the Y band the procedure uses. + begin_of_tip_deposit_process: Z to start the eject from, in mm. + end_of_tip_deposit_process: Z the eject ends at, in mm. + z_position_at_end_of_a_command: Z to leave the channels at, in mm. + tip_pattern: which channels take part. Defaults to all of them. + tip_type: tip type table index. + discarding_method: how tips are discarded. + """ + c = self.configuration + if x_position is None: + if self._driver.configuration is None: + raise RuntimeError("no configuration read; have you called `star.setup()`?") + x_position = self._driver.configuration.tip_waste_x_position + if y_positions is None: + y_positions = self.default_initialize_y_positions() + if tip_pattern is None: + tip_pattern = [True] * self.num_channels + if begin_of_tip_deposit_process is None: + begin_of_tip_deposit_process = c.initialize_begin_of_tip_deposit + if end_of_tip_deposit_process is None: + end_of_tip_deposit_process = c.initialize_end_of_tip_deposit + if z_position_at_end_of_a_command is None: + z_position_at_end_of_a_command = c.initialize_z_position_at_end + if tip_type is None: + tip_type = c.initialize_tip_type + if discarding_method is None: + discarding_method = c.initialize_discarding_method + + return await self._driver.send_command( + module="C0", + command="DI", + subsystem=_FirmwareLock.CHANNELS, + read_timeout=c.initialize_read_timeout, + xp=[f"{round(x_position * 10):05}"], + yp=[f"{round(y * 10):04}" for y in y_positions], + tp=f"{round(begin_of_tip_deposit_process * 10):04}", + tz=f"{round(end_of_tip_deposit_process * 10):04}", + te=f"{round(z_position_at_end_of_a_command * 10):04}", + tm=[f"{tm:01}" for tm in tip_pattern], + tt=f"{tip_type:02}", + ti=discarding_method, + ) + + def _min_spacing_between(self, i: int, j: int) -> float: + """The smallest allowed Y gap two channels may sit at, in mm. + + Adjacent channels take the wider of the two, rounded up to 0.1 mm, since neither may overlap + the other. Channels further apart take the sum of the pairs between them. + + Args: + i: one channel, 0-indexed from the back. + j: the other. + + Returns: + The gap in mm. + + Raises: + RuntimeError: If a channel's width has not been read yet. + """ + lo, hi = min(i, j), max(i, j) + if hi - lo > 1: + return sum(self._min_spacing_between(k, k + 1) for k in range(lo, hi)) + widths = [self.configuration.channels[channel].width for channel in (lo, hi)] + if any(width is None for width in widths): + raise RuntimeError(f"channels {lo} and {hi} have no width read yet; run discovery first") + return math.ceil(max(cast(List[float], widths)) * 10) / 10 + + # ---------------------------------------- + # Movement + # ---------------------------------------- + + @property + def arm(self) -> "XArm": + """The arm carrying these channels. + + The firmware keeps the two X-drives' feature bits disjoint: channels are on one arm or the + other, never both. Discovery builds this feature on that arm; this property finds it back + by identity. + + Returns: + The arm carrying independent single-channel pipettes. + """ + return next(a for a in self._driver.arms if a.pipettes is self) + + def _check_reachable(self, axis: Literal["x", "y", "z"], value: float) -> None: + """Raise if the channels cannot be sent where they are being asked to go. + + The one gate every position passes through. What the channels are allowed to do is decided in + one place: travel limits now, and whatever else has to hold before they move as it is added. + + X is the arm's travel as the arm reports it. A channel sits at the arm's reference point, with + no offset to apply. Y is the band the device states its channels reach, which differs by the + side the arm is on. + + Args: + axis: which axis - `x` along the rail, `y` across the arm. + value: where it would be sent, in mm. + + Raises: + ValueError: If the channels cannot reach it. + RuntimeError: If the limits were not read, so how far they reach is unknown. + """ + device = self._driver.configuration + if device is None: + raise RuntimeError("no configuration read; have you called `star.setup()`?") + if axis == "x": + x_range = self.arm.configuration.x_range + if x_range is None: + raise RuntimeError("the arm's X travel is not known; have you called `star.setup()`?") + low, high = x_range + elif axis == "z": + low, high = self.configuration.z_range + else: + low = ( + device.left_arm_min_y_position + if self.arm.side == "left" + else device.right_arm_min_y_position + ) + high = device.pip_maximal_y_position + if not low <= value <= high: + raise ValueError(f"{axis} must be between {low} and {high} mm, is {value}") + + # -- x position -------------------------------------------------------------------------------- + + async def request_x_position(self) -> float: + """Request where along X the channels are, in deck mm. + + The channels have no X drive. They ride the arm and sit at its reference point, and this asks + the arm. Nothing is recorded: each channel's resource is a child of the arm's and follows it + in X. + + Returns: + The position in mm. + """ + return await self.arm.request_position() + + async def move_to_x_position( + self, + x: float, + acceleration_level: int = 3, + current_limit: int = 7, + settle_reads: int = 20, + ): + """Move the channels along X. The whole arm travels, with everything else it carries. + + Args: + x: where to go, in mm. + acceleration_level: how hard to accelerate, 1 to 4. + current_limit: the motor current limit, 1 to 7. + settle_reads: how many reads to take before calling the arm stopped. + + Raises: + ValueError: If the channels cannot reach it. + """ + self._check_reachable("x", x) + return await self.arm.move_x( + x, + acceleration_level=acceleration_level, + current_limit=current_limit, + settle_reads=settle_reads, + ) + + # -- y position -------------------------------------------------------------------------------- + + async def request_y_positions(self) -> List[float]: + """Request where every channel is along Y, in one command. + + The master answers for all of them at once: one exchange, not one per channel. Each answer is + recorded on the resource modelling that channel. + + Returns: + The position of each channel in mm, back to front. + """ + resp = await self._driver.send_command(module="C0", command="RY", fmt="ry#### (n)") + positions = [increments / 10 for increments in cast(List[int], resp["ry"])] + for channel, y in enumerate(positions): + self.update_location_by_reference_point(channel, y=y) + return positions + + async def request_y_position(self, channel: int) -> float: + """Request where a specific channel is along Y. + + Args: + channel: the channel to request the position of. + + Returns: + The position of the requested channel in mm. + """ + self._require_channel(channel) + positions = await self.request_y_positions() + return positions[channel] + + async def move_to_y_positions(self, ys: Dict[int, float], make_space: bool = False): + """Move channels along Y. + + The channels not named stay where they are. + + TODO: park the iSWAP first when one is installed. Legacy does, skipping the move when its + flag says it is already parked; v1 tracks no such state and has no query for it. + + Args: + ys: where to put each named channel, in mm, keyed by channel, 0-indexed from the back. + make_space: whether the channels not named may be moved, so that every pair meets its + minimum Y spacing and the channels stay in order back to front. Off by default: nothing + moves that the caller did not ask to move, and a request that will not fit raises instead. + It can raise either way, since the requested positions may leave no room. + """ + + if self._driver.configuration is None: + raise RuntimeError("no configuration read; have you called `star.setup()`?") + min_y = self._driver.configuration.left_arm_min_y_position + + # The frontmost channel parks a fraction ahead of the documented minimum. Tolerate 0.2 mm of + # that and snap it up; refuse beyond, rather than guessing what the reading means. + positions = await self.request_y_positions() + if positions[-1] < min_y - 0.2: + raise RuntimeError( + f"the frontmost channel reports {positions[-1]}mm, more than 0.2mm in front of the " + f"{min_y}mm the channels reach. Reported: {positions}" + ) + positions[-1] = max(positions[-1], min_y) + + # Floating point error sometimes puts a reported pair a fraction below its minimum spacing, + # which the check further down would then refuse. Walk front to back and conform each pair + # against what it reported, as legacy does. + for channel in range(len(positions) - 2, -1, -1): + spacing = self._min_spacing_between(channel, channel + 1) + if positions[channel] - positions[channel + 1] < spacing: + positions[channel] = positions[channel + 1] + spacing + + # check that the locations of channels after the move will respect pairwise minimum + # spacing and be in descending order + channel_locations = dict(enumerate(positions)) + + for channel_idx, y in ys.items(): + channel_locations[channel_idx] = y + + if make_space: + # For the channels to the back of `back_channel`, make sure the space between them + # meets the per-pair minimum. We start with the channel closest to `back_channel`, and + # make sure the channel behind it is spaced correctly, updating if needed. + use_channels = list(ys.keys()) + back_channel = min(use_channels) + for channel_idx in range(back_channel, 0, -1): + pair_spacing = self._min_spacing_between(channel_idx - 1, channel_idx) + if (channel_locations[channel_idx - 1] - channel_locations[channel_idx]) < pair_spacing: + channel_locations[channel_idx - 1] = channel_locations[channel_idx] + pair_spacing + + # Position intermediate channels between back_channel and front_channel. + front_channel = max(use_channels) + for intermediate_ch in range(back_channel + 1, front_channel): + if intermediate_ch not in ys: + pair_spacing = self._min_spacing_between(intermediate_ch - 1, intermediate_ch) + channel_locations[intermediate_ch] = channel_locations[intermediate_ch - 1] - pair_spacing + + # Similarly for the channels to the front of `front_channel`, make sure they are all + # spaced by the per-pair minimum. This time, we iterate from back (closest to + # `front_channel`) to the frontmost channel. + for channel_idx in range(front_channel, self.num_channels - 1): + pair_spacing = self._min_spacing_between(channel_idx, channel_idx + 1) + if (channel_locations[channel_idx] - channel_locations[channel_idx + 1]) < pair_spacing: + channel_locations[channel_idx + 1] = channel_locations[channel_idx] - pair_spacing + + # Quick checks before movement. The channels stay in order, so the two ends bound the rest. + for channel in (0, self.num_channels - 1): + self._check_reachable("y", channel_locations[channel]) + + for i in range(len(channel_locations) - 1): + required = self._min_spacing_between(i, i + 1) + actual = channel_locations[i] - channel_locations[i + 1] + if round(actual * 1000) < round(required * 1000): # compare in um to avoid float issues + raise ValueError( + f"Channels {i} and {i + 1} must be at least {required}mm apart, " + f"but are {actual:.2f}mm apart." + ) + + yp = " ".join([f"{round(y * 10):04}" for y in channel_locations.values()]) + try: + resp = await self._driver.send_command( + module="C0", command="JY", subsystem=_FirmwareLock.CHANNELS, yp=yp + ) + except Exception: + # Only on the way out: a move that arrives is recorded from its target below, so a `finally` + # here would ask the device where the channels are on every successful move. + await self._record_where_they_stopped("y") + raise + + for channel, y in channel_locations.items(): + self.update_location_by_reference_point(channel, y=y) + return resp + + async def move_to_y_position(self, channel: int, y: float, make_space: bool = False): + """Move one channel along Y. + + The other channels stay where they are, unless `make_space` says they may move to let this + one through. + + Args: + channel: which channel to move, 0-indexed from the back. + y: where to put it, in mm. + make_space: whether the other channels may be moved to make room. Off by default. See + `move_to_y_positions`. + + Raises: + ValueError: If the channel cannot reach it, or the others cannot make room. + """ + self._require_channel(channel) + return await self.move_to_y_positions({channel: y}, make_space=make_space) + + async def make_max_space_for_channel(self, channel: int): + """Spread the channels to leave one of them as much free Y as the arm allows. + + What a caller reaches for before working a channel by hand, and the device decides where the + others go. + + TODO: park the iSWAP first when one is installed. Legacy does, skipping the move when its + flag says it is already parked; v1 tracks no such state and has no query for it. + + Args: + channel: which channel to free, 0-indexed from the back. + + Raises: + ValueError: If the channel is not one this device has. + """ + if not 0 <= channel < self.num_channels: + raise ValueError(f"channel must be between 0 and {self.num_channels - 1}, is {channel}") + try: + resp = await self._driver.send_command( + module="C0", + command="JP", + subsystem=_FirmwareLock.CHANNELS, + pn=f"{channel + 1:02}", # the firmware counts channels from 1 + ) + finally: + # The device decides where the channels go, so unlike a commanded move there is nothing to + # write the model from: where they ended up has to be read - and a move that stopped part + # way has to be read for the same reason. + await self._record_where_they_stopped("y") + return resp + + # -- z position -------------------------------------------------------------------------------- + async def _unchecked_fw_request_lowest_z_positions(self) -> Dict[int, float]: + """Read where every channel is along Z, without recording it. + + The reading alone. `request_tool_bottom_z_positions` is the one that also records it on the resources. + + Returns: + The position of each channel in mm, keyed by channel, 0-indexed from the back. + """ + resp = await self._driver.send_command(module="C0", command="RZ", fmt="rz#### (n)") + return { + channel: increments / 10 for channel, increments in enumerate(cast(List[int], resp["rz"])) + } + + async def request_tool_bottom_z_positions(self) -> Dict[int, float]: + """Read where the bottom of the tip on every channel is. + + Every channel has to carry one. Records each channel's stop disc on the resource modelling it. + + Returns: + The bottom of each channel's tip in mm, keyed by channel, 0-indexed from the back. + + Raises: + ValueError: If any channel carries no tip. + """ + await self._require_tips(range(self.num_channels), "request_stop_disc_z_positions") + positions = await self._unchecked_fw_request_lowest_z_positions() + # What comes back is each tip's bottom, but the model references stop discs, so we + # read them for correct model update. + await self.request_stop_disc_z_positions() + return positions + + async def request_tool_bottom_z_position(self, channel: int) -> float: + """Read where the bottom of the tip on one channel is. + + A channel with no tip has no tool bottom, so this refuses rather than quietly answering with + its stop disc, which is what the master would do. `request_stop_disc_z_position` is the read + that answers whatever is mounted. + + Args: + channel: which channel, 0-indexed from the back. + + Returns: + Where the bottom of its tip is, in mm on the deck. + + Raises: + ValueError: If the channel carries no tip. + """ + self._require_channel(channel) + await self._require_tips([channel], "request_stop_disc_z_position") + tip_bottom = (await self._unchecked_fw_request_lowest_z_positions())[channel] + # As above: the model holds this channel's stop disc, not the bottom of what is on it. + await self.request_stop_disc_z_position(channel) + return tip_bottom + + async def request_stop_disc_z_positions(self) -> Dict[int, float]: + """Read where every channel's stop disc is. + + Returns: + Each channel's stop disc in mm, keyed by channel, 0-indexed from the back. + """ + return { + channel: await self.request_stop_disc_z_position(channel) + for channel in range(self.num_channels) + } + + async def request_stop_disc_z_position(self, channel: int) -> float: + """Read where one channel's stop disc is, regardless of whether a tool (e.g. tip, + core_gripper, suction_gripper, ...) is mounted. + + Records the answer on the resource modelling that channel. + + Args: + channel: which channel, 0-indexed from the back. + + Returns: + Where its stop disc is, in mm on the deck. + """ + self._require_channel(channel) + resp = await self._driver.send_command( + module=self.channel_id(channel), command="RZ", fmt="rz######" + ) + z = self.configuration.z_drive_increments_to_mm(cast(int, resp["rz"])) + self.update_location_by_reference_point(channel, z=z) + return z + + async def request_tip_overhang(self, channel: int) -> float: + """Measure how far the tip on one channel stands below its stop disc. + + Both readings are of the same channel at the same moment, so the difference is the overhang + without anything having to move: the channel reports its own stop disc, the master reports the + bottom of what is mounted. This is what a Z target has to be offset by for the tip end, rather + than the stop disc, to land where it is wanted. + + Args: + channel: which channel, 0-indexed from the back. + + Returns: + The overhang in mm. + + Raises: + RuntimeError: If the channel carries no tip, so there is nothing to measure. + """ + self._require_channel(channel) + if not (await self.sense_tip_presence())[channel]: + raise RuntimeError(f"channel {channel} reports no tip, so there is no overhang to measure") + stop_disc = await self.request_stop_disc_z_position(channel) + tip_bottom = (await self._unchecked_fw_request_lowest_z_positions())[channel] + return round(stop_disc - tip_bottom, 2) + + async def _unchecked_fw_move_lowest_point_to_z_positions(self, zs: Dict[int, float]): + """Move each channel's lowest point along Z, without checking or recording it. + + The command alone, as `_unchecked_fw_request_lowest_z_positions` is the read alone. What it + positions is what the master takes Z to be: the bottom of the tip on a channel that carries + one, the stop disc on a channel that does not. Which of the two depends on what is mounted, so + this stays private and the moves that name their reference are what callers reach for. + + The command carries a position for every channel, so the ones not named are read first and sent + back unchanged. + + Args: + zs: where to put each named channel, in mm, keyed by channel, 0-indexed from the back. + + Returns: + What the command answered. + """ + positions = await self._unchecked_fw_request_lowest_z_positions() + positions.update(zs) + return await self._driver.send_command( + module="C0", + command="JZ", + subsystem=_FirmwareLock.CHANNELS, + zp=[f"{round(z * 10):04}" for z in positions.values()], + ) + + async def move_tool_bottom_to_z_positions(self, zs: Dict[int, float]): + """Move the bottom of the tip on each named channel along Z, in one command. + + The master positions the bottom of what a channel carries, so this is that move with its + reference made true: every named channel has to carry a tip, or the master would be placing a + stop disc instead and calling it the same thing. The channels not named stay where they are. + + Args: + zs: where to put each named channel's tip bottom, in mm, keyed by channel, 0-indexed from + the back. + + Returns: + What the command answered. + + Raises: + ValueError: If a named channel is not one this device has, carries no tip, or is being sent + outside the window the channels reach. + """ + for channel in zs: + self._require_channel(channel) + await self._require_tips(zs, "move_stop_disc_to_z_positions") + for z in zs.values(): + self._check_reachable("z", z) + + try: + resp = await self._unchecked_fw_move_lowest_point_to_z_positions(zs) + finally: + # Whether the move arrived or stopped part way, where the channels are has to be read: this + # is both how a successful move is recorded and how a failed one is. + await self._record_where_they_stopped("z") + return resp + + async def move_tool_bottom_to_z_position( + self, + channel: int, + z: float, + speed: Optional[float] = None, + acceleration: Optional[float] = None, + current_limit: Optional[int] = None, + ): + """Move the bottom of the tip on one channel along Z. + + The channel has to carry one. Needs the Z window, so run `star.setup()` first. + + Args: + channel: which channel to move, 0-indexed from the back. + z: where to put the bottom of its tip, in mm on the deck. + speed: how fast, in mm/s. Defaults to `configuration.z_drive_speed_default`. + acceleration: how hard, in mm/s2. Defaults to `configuration.z_drive_acceleration_default`. + current_limit: the motor current limit. Defaults to + `configuration.z_drive_current_limit_default`. + + Raises: + ValueError: If the channel carries no tip, or it cannot put the tip bottom at `z`. + """ + self._require_channel(channel) + c = self.configuration + await self._require_tips([channel], "move_stop_disc_to_z_position") + overhang = await self.request_tip_overhang(channel) + + # The drive works in stop-disc terms over `z_range`, so what the tip bottom reaches is that + # window shifted down by the overhang, and no lower than a stop disc itself may go. + low = round(max(c.z_range[0] - overhang, c.z_range[0]), 2) + high = round(c.z_range[1] - overhang, 2) + if not low <= z <= high: + raise ValueError( + f"the tool bottom reaches {low} to {high} mm with a {overhang} mm overhang, not {z}" + ) + + return await self.move_stop_disc_to_z_position( + channel, + z + overhang, + speed=speed, + acceleration=acceleration, + current_limit=current_limit, + ) + + async def move_stop_disc_to_z_positions( + self, + zs: Dict[int, float], + speed: Optional[float] = None, + acceleration: Optional[float] = None, + current_limit: Optional[int] = None, + ): + """Move each named channel's stop disc along Z. + + One command per channel, as `request_stop_disc_z_positions` is one read per channel: a channel + module answers for its own channel and no other. They go one after another, so a channel that + refuses stops the rest. The channels not named stay where they are. + + Args: + zs: where to put each named channel's stop disc, in mm, keyed by channel, 0-indexed from the + back. + speed: how fast, in mm/s. Defaults to `configuration.z_drive_speed_default`. + acceleration: how hard, in mm/s2. Defaults to `configuration.z_drive_acceleration_default`. + current_limit: the motor current limit. Defaults to + `configuration.z_drive_current_limit_default`. + + Raises: + ValueError: If a named channel is not one this device has, or an argument is outside what the + drive accepts. + """ + for channel, z in zs.items(): + await self.move_stop_disc_to_z_position( + channel, + z, + speed=speed, + acceleration=acceleration, + current_limit=current_limit, + ) + + async def move_stop_disc_to_z_position( + self, + channel: int, + z: float, + speed: Optional[float] = None, + acceleration: Optional[float] = None, + current_limit: Optional[int] = None, + ): + """Move one channel's stop disc along Z. The other channels stay where they are. + + Addressed to the channel rather than the master, so what it positions is the stop disc whether + or not a tip is mounted. `move_tool_bottom_to_z_position` is the one that places a tip end. + + Args: + channel: which channel to move, 0-indexed from the back. + z: where to put its stop disc, in mm on the deck. + speed: how fast, in mm/s. Defaults to `configuration.z_drive_speed_default`. + acceleration: how hard, in mm/s2. Defaults to `configuration.z_drive_acceleration_default`. + current_limit: the motor current limit. Defaults to + `configuration.z_drive_current_limit_default`. + + Raises: + ValueError: If an argument is outside what the drive accepts. + """ + self._require_channel(channel) + c = self.configuration + speed = c.z_drive_speed_default if speed is None else speed + acceleration = c.z_drive_acceleration_default if acceleration is None else acceleration + current_limit = c.z_drive_current_limit_default if current_limit is None else current_limit + + self._check_reachable("z", z) + for checked, (low, high), name in ( + (speed, c.z_speed_range, "speed"), + (acceleration, c.z_acceleration_range, "acceleration"), + (current_limit, c.z_drive_current_limit_range, "current_limit"), + ): + if not low <= checked <= high: + raise ValueError(f"{name} must be between {low} and {high}, is {checked}") + + try: + return await self._driver.send_command( + module=self.channel_id(channel), + command="ZA", + za=f"{c.z_drive_mm_to_increments(z):05}", + zv=f"{c.z_drive_mm_to_increments(speed):05}", + zr=f"{c.z_drive_acceleration_mm_to_increments(acceleration):03}", + zw=f"{current_limit:01}", + ) + finally: + # Whether the move succeeded or not: one that failed part way left the channel somewhere + # neither position describes, and this read is also how a successful move is recorded. + await self._record_where_they_stopped("z", [channel]) + + async def probe_z_max(self) -> Dict[int, float]: + """Raises single-channel pipettes to Z safety and reads their stop discs z-positions. + + Informs the max of `configuration.z_range` during setup. + + Returns: + List[float]: The z-positions of each channel's stop disc, in mm, keyed by channel. + """ + await self._driver.send_command(module="C0", command="ZA", subsystem=_FirmwareLock.CHANNELS) + + positions = await self.request_stop_disc_z_positions() + reached = list(positions.values()) + if max(reached) - min(reached) > self.configuration.z_drive_increments_to_mm(1): + logger.warning("the channels came to rest at different heights: %s", positions) + + return positions + + async def move_to_safe_z(self) -> List[float]: + """Move every channel up to its safe Z: the top of the window setup probed. + + Nothing may move in X or Y while a channel is low, so this is the precondition for any lateral + move and it runs often. An ordinary Z move to a known height, not a command of its own, so it + is bounded and keeps the model current like any other move. With no window probed yet there is + no height to aim at, and the firmware's own safety move establishes one instead. + + Returns: + Where each channel's stop disc came to rest, in mm, back to front. + """ + z_range = self.configuration.z_range + + await self._unchecked_fw_move_lowest_point_to_z_positions( + {channel: z_range[1] for channel in range(self.num_channels)} + ) + + return list((await self._unchecked_fw_request_lowest_z_positions()).values()) + + # -- spreading ----------------------------------------------------------------------------------- + + async def spread_channels(self): + """Spread the channels evenly across the Y band. This moves them. + + One command with nothing to say where they go: the device spreads them itself, over the same + band the initialization procedure uses, so a caller that wants particular positions reaches + for `move_to_y_positions` instead. + + Collision risk: every channel travels in Y, so anything between them moves with them. + + Returns: + What the device answered. + """ + try: + return await self._driver.send_command( + module="C0", command="JE", subsystem=_FirmwareLock.CHANNELS + ) + finally: + # The device decides where they land, so unlike a commanded move there is nothing to write + # the model from: where they ended up has to be read, and a spread that stopped part way + # has to be read for the same reason. + await self._record_where_they_stopped("y") + + # ---------------------------------------- + # Probing + # ---------------------------------------- + + # -- x probing (capacitive only) -------------------------------------------------------------- + + # TODO: _unchecked_fw_ vs tip-presence-guarded versions + + # -- y probing (capacitive only) -------------------------------------------------------------- + + # TODO: _unchecked_fw_ vs tip-presence-guarded versions + + # -- z probing (capacitive, pressure, force) -------------------------------------------------- + + # TODO: _unchecked_fw_ vs tip-presence-guarded versions diff --git a/pylabrobot/hamilton/star/driver/features/pipettes_tests.py b/pylabrobot/hamilton/star/driver/features/pipettes_tests.py new file mode 100644 index 00000000000..35f5ad7d62d --- /dev/null +++ b/pylabrobot/hamilton/star/driver/features/pipettes_tests.py @@ -0,0 +1,165 @@ +import unittest +from typing import Any, List, Optional, Tuple + +from pylabrobot.hamilton.protocol.text.framing import assemble_command +from pylabrobot.hamilton.star.device import RECORDING_STAR +from pylabrobot.hamilton.star.driver.features.pipettes import Pipettes, PipettesConfiguration +from pylabrobot.hamilton.star.driver.simulator import STARSimulationDriver +from pylabrobot.resources.hamilton import STARDeck + + +async def channels(width: float, positions: List[float]) -> Tuple[Pipettes, List[str]]: + """The channels of a simulated device, of one width and at known Y positions. + + Both are what the tests vary: the width decides the minimum spacing a pair must keep, and the + positions are what the device answers `C0 RY` with. Everything else is the driver's own. + + Args: + width: what every channel reports its width to be, in mm. + positions: where each channel is along Y, in mm, back to front. + + Returns: + The feature, and the list its commands are recorded in. + """ + driver = STARSimulationDriver(deck=STARDeck(), declared_configuration_json=RECORDING_STAR) + await driver.setup() + pipettes = driver.pipettes + assert pipettes is not None + + for channel in pipettes.configuration.channels: + channel.width = width + + sent: List[str] = [] + answer = driver.send_command + + async def recorded( + module: str, + command: str, + fmt: Optional[Any] = None, + subsystem: Optional[str] = None, + **kwargs: Any, + ): + # `fmt` and `subsystem` are the driver's own, not firmware parameters: taken exactly as + # `send_command` takes them, so they never reach the assembler. + sent.append(assemble_command(module=module, command=command, id_=None, **kwargs)) + return await answer(module=module, command=command, fmt=fmt, subsystem=subsystem, **kwargs) + + async def reported_positions() -> List[float]: + return list(positions) + + driver.send_command = recorded # type: ignore[assignment] + # The simulated channels answer this from their own model rather than from the wire, so the + # starting positions are set here, as legacy sets them on its backend. + pipettes.request_y_positions = reported_positions # type: ignore[assignment] + return pipettes, sent + + +def jy(yp: str) -> str: + """The Y positioning command carrying `yp`.""" + return assemble_command(module="C0", command="JY", id_=None, yp=yp) + + +# The two extremes of 160 distinct `C0 JY` payloads recorded across three years of runs: the +# tightest adjacent gap ever commanded, and the frontmost a channel has ever been sent. Both sit +# exactly on a limit the driver enforces, and nothing recorded goes past either. +TIGHTEST_GAP = [529.8, 520.8, 511.8, 502.8, 493.8, 484.8, 459.0, 338.0] +FRONTMOST = [130.0, 100.0, 91.0, 82.0, 73.0, 64.0, 15.0, 6.0] + +# What a channel reports its width to be, in mm: `PxVY` answers 194 increments. Not a round +# number, so the rounding up to 0.1 mm is exercised rather than assumed. +REPORTED_WIDTH = 8.9826 + + +class TestPositionInYDirection(unittest.IsolatedAsyncioTestCase): + """What the channels' minimum spacing does to a Y positioning command.""" + + async def test_the_limits_accept_what_the_device_has_been_commanded(self): + """The driver's minimum spacing and front limit against the extremes of real runs. + + A minimum wider than 9.0 mm fails on the first, a front limit behind 6.0 mm on the second. + """ + for payload in (TIGHTEST_GAP, FRONTMOST): + pipettes, sent = await channels(width=REPORTED_WIDTH, positions=payload) + await pipettes.move_to_y_positions(dict(enumerate(payload)), make_space=False) + self.assertEqual(sent[-1], jy(" ".join(f"{round(y * 10):04}" for y in payload))) + + async def test_a_gap_that_is_wide_enough_at_9mm_is_refused_at_18mm(self): + spread = [100.0, 91.0, 82.0, 73.0, 64.0, 55.0, 46.0, 37.0] + + at_9, sent_9 = await channels(width=9.0, positions=spread) + await at_9.move_to_y_positions(dict(enumerate(spread)), make_space=False) + self.assertEqual(sent_9[-1], jy("1000 0910 0820 0730 0640 0550 0460 0370")) + + at_18, _ = await channels(width=18.0, positions=spread) + with self.assertRaises(ValueError): + await at_18.move_to_y_positions(dict(enumerate(spread)), make_space=False) + + async def test_make_space_moves_the_channel_in_front_by_the_minimum(self): + # Already 18mm apart, so the reading needs no conforming and only make_space moves anything. + current = [400.0, 300.0, 200.0, 160.0, 142.0, 124.0, 106.0, 88.0] + + at_9, sent_9 = await channels(width=9.0, positions=current) + await at_9.move_to_y_positions({3: 150.0}, make_space=True) + self.assertEqual(sent_9[-1], jy("4000 3000 2000 1500 1410 1240 1060 0880")) + + at_18, sent_18 = await channels(width=18.0, positions=current) + await at_18.move_to_y_positions({3: 150.0}, make_space=True) + self.assertEqual(sent_18[-1], jy("4000 3000 2000 1500 1320 1140 0960 0780")) + + +async def simulated_channels() -> Pipettes: + """The channels of a simulated device, as setup leaves them. + + Returns: + The feature. + + Raises: + RuntimeError: If the simulated device reports no channels. + """ + driver = STARSimulationDriver(deck=STARDeck(), declared_configuration_json=RECORDING_STAR) + await driver.setup() + if driver.pipettes is None: + raise RuntimeError("the simulated device reports no pipetting channels") + return driver.pipettes + + +class TestPositionInZDirection(unittest.IsolatedAsyncioTestCase): + """What the channels' Z window does to a Z positioning command. + + The window is taken from the configuration rather than written out here: what the drive counts + differs by arm, so a test that named the millimetres would be testing this generation only. + """ + + async def test_a_z_outside_the_window_is_refused_and_one_inside_is_not(self): + """The floor is the deck surface, so a Z below it would drive a stop disc into the deck.""" + pipettes = await simulated_channels() + c = pipettes.configuration + low, high = c.z_range or c.z_range + + for z in (low - 0.1, high + 0.1): + with self.assertRaises(ValueError): + await pipettes.move_stop_disc_to_z_position(0, z) + + await pipettes.move_stop_disc_to_z_position(0, round((low + high) / 2, 1)) + + async def test_setup_takes_the_ceiling_from_what_the_channels_reached(self): + """The probe says how high these channels reach, and setup makes that the ceiling. The floor + is left as it stands: nothing measures how low they go.""" + pipettes = await simulated_channels() + floor, ceiling = pipettes.configuration.z_range + + self.assertEqual(ceiling, min((await pipettes.probe_z_max()).values())) + self.assertEqual(floor, PipettesConfiguration().z_range[0]) + + async def test_probing_reads_the_channels_and_changes_nothing(self): + """It is called for the raise as much as for the reading, so it leaves the window alone: what + is done with what it read is setup's to decide.""" + pipettes = await simulated_channels() + floor, _ = pipettes.configuration.z_range + # A window that is not the one probing would arrive at, so a probe that set it would show. + pipettes.configuration.z_range = (floor + 10.0, 300.0) + + reached = await pipettes.probe_z_max() + + self.assertEqual(pipettes.configuration.z_range, (floor + 10.0, 300.0)) + self.assertEqual(len(reached), len(pipettes.configuration.channels)) diff --git a/pylabrobot/hamilton/star/driver/features/x_arm.py b/pylabrobot/hamilton/star/driver/features/x_arm.py new file mode 100644 index 00000000000..9d039fa2d68 --- /dev/null +++ b/pylabrobot/hamilton/star/driver/features/x_arm.py @@ -0,0 +1,603 @@ +"""The X-arm: the carriage that runs along a rail and carries whatever is mounted on it.""" + +import dataclasses +import datetime +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple, cast + +from pylabrobot.hamilton.protocol.text.framing import parse_firmware_version_date +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.resource import Resource + +if TYPE_CHECKING: + from pylabrobot.hamilton.star.driver.features.head96 import Head96 + from pylabrobot.hamilton.star.driver.features.head384 import Head384 + from pylabrobot.hamilton.star.driver.features.iswap import iSWAP + from pylabrobot.hamilton.star.driver.features.pipettes import Pipettes + from pylabrobot.hamilton.star.driver.master import STARDriver + +logger = logging.getLogger(__name__) + +# The command set splits at firmware 5.0: the ranges and encodings below were recorded from an arm +# below it, which is the generation this driver has been driven against. A 5.0 or higher arm takes +# a wider current limiter, written in two digits rather than one, and has moves this one does not. +RECORDED_FIRMWARE_BELOW_MAJOR = 5 + + +@dataclass +class XArmConfiguration: + """Configuration and geometry for an X drive (left or right). + + The installed-module bits combine byte 1 (xl/xr) and byte 2 (xn/xo). The arm + geometry comes from three queries, one per field: `width` from the extended + configuration (QM), as `xu` for the left drive and `xv` for the right, in tenths + of a millimetre; `x_range` from the X-drive range (RU); and `workspace_x_range` + and `wrap_size` from the working envelope (UA). All are None on a drive built + from the module bits alone (e.g. a simulated configuration) and populated when + `request_device_configuration` builds the drive. `model` and `reference_point` + follow from `width`. + + `width` is read per drive, and the two drives on one device do not have to agree: + it is what a drive says about itself, not a figure a kind of arm has. + + The two drives' module bits never overlap: a module occupies one fixed CAN node - the 96-head is + `H0`, the iSWAP `R0` - so a device has one of it, and the bits say which arm carries it rather + than how many there are. Where a module genuinely can be several, the node list indexes it + instead (`Ln` for XL channels, `On` for robotic ones). The pipetting channels are one chain, + `P1` to `PG`, addressed together as `PX`, which is why the device reports a single channel + count and not one per arm. + """ + + pip_installed: bool = False + iswap_installed: bool = False + head96_installed: bool = False + nano_pipettor_installed: bool = False + head384_installed: bool = False + xl_channels_installed: bool = False + tube_gripper_installed: bool = False + imaging_channel_installed: bool = False + # byte 2 from here: xn on the left drive, xo on the right. + robotic_channel_installed: bool = False + gel_card_gripper_installed: bool = False + puncher_handler_installed: bool = False + + width: Optional[float] = None + large: Optional[bool] = None + x_range: Optional[Tuple[float, float]] = None + workspace_x_range: Optional[Tuple[float, float]] = None + wrap_size: Optional[float] = None # zero when no arm is installed + firmware_version: Optional[str] = None + + # -- device facts of the drive, the same for every arm of this generation -- + x_mm_per_increment: float = 0.1 + x_range_increments: Tuple[int, int] = (0, 30_000) # what the move accepts; x_range is narrower + acceleration_level_range: Tuple[int, int] = (1, 5) # index into five curves, not a rate + acceleration_level_default: int = 4 + current_limit_range: Tuple[int, int] = (0, 7) + current_limit_default: int = 7 + + def with_device_facts_of(self, other: "XArmConfiguration") -> "XArmConfiguration": + """This configuration, with the device facts of another in place of its own. + + What keeps a corrected device fact across a re-read, since discovery rebuilds an arm's + configuration from the device's reply. + + Args: + other: the configuration to take the device facts from. + + Returns: + XArmConfiguration: A new one. Neither of these is changed. + """ + return dataclasses.replace( + self, + x_mm_per_increment=other.x_mm_per_increment, + x_range_increments=other.x_range_increments, + acceleration_level_range=other.acceleration_level_range, + acceleration_level_default=other.acceleration_level_default, + current_limit_range=other.current_limit_range, + current_limit_default=other.current_limit_default, + ) + + # -- conversions: the wire counts in steps, the driver speaks mm --------------------------- + + def x_increments_to_mm(self, increments: int) -> float: + """Where along its rail the arm is, in mm, from the steps the drive counts in.""" + return round(increments * self.x_mm_per_increment, 2) + + def x_mm_to_increments(self, mm: float) -> int: + """An arm position in steps, from mm.""" + return round(mm / self.x_mm_per_increment) + + # How wide the large arm is, in mm, end to end. Measured on the part, because the drive does not + # say: what it reports is a reach measured from the point it tracks, not a size, and the two + # differ by tens of millimetres. A tape across one reads 400; the manufacturer's model of the + # same part gives 400.49. + # + # One arm, measured once. A second one is what would show whether this belongs to the arm or to + # the device it was measured on. + large_arm_size_x: float = 400.49 + + # Where the drive's tracked point sits on the large arm, in mm from its left edge. Measured on + # the part two ways that agree to the hundredth: the midpoint of the two rails the channels hang + # from - odd channels at 141.35, even at 304.64 - and the centre of the opening between them. + # + # A property of the arm, so it is stated rather than derived from the reported width. The two do + # agree: half the reported width is the reach from this point to the arm's right edge, which + # comes to 2 x (400.49 - 223.00) = 354.98, against the 354.0 a device answers. + large_arm_reference_from_left: float = 223.00 + + @property + def is_large(self) -> Optional[bool]: + """Whether this is the large arm, spanning both rails. + + The device says so outright, in the drive-size bit read with the rest of its configuration. A + configuration that predates that bit - a declaration written before it was carried - has only + the reported width to go on, and a large arm reports far more than a small one, so the width + stands in. It is a fallback and not the answer: the width is settable, and two devices with + the same arm report different ones. + """ + if self.large is not None: + return self.large + return None if self.width is None else self.width > 300 + + @property + def size_x(self) -> float: + """How wide the arm is, in mm, end to end. + + Not what it reports. The reported width is a reach measured from the point the drive tracks, + so it says nothing about how much room the part takes. + """ + if self.is_large is None: + raise RuntimeError("arm geometry not resolved") + if not self.is_large: + raise RuntimeError("no small arm has been measured, so how wide one is is not known") + return self.large_arm_size_x + + @property + def model(self) -> str: + """Arm variant: a large drive spans both rails, a small one a single rail. + + Taken from the drive-size bit the device sets, not from `width`. The width is a value written + into the instrument rather than measured off it - two devices with the same arm answer 354.0 + and 370.0 - so a variant derived from it follows whatever someone configured. + """ + if self.is_large is None: + raise RuntimeError("arm geometry not resolved") + return ( + "hamilton_legacy_star_dual_rail_arm" + if self.is_large + else "hamilton_legacy_star_single_right_rail_arm" + ) + + @property + def reference_point(self) -> Literal["center", "right"]: + """Where along the reported width the tracked X refers to. + + The middle of it for a large arm, its right end for a small one. Measured along the width the + drive reports, not along the arm: what stands beyond that width is not part of what it is + counting from. + + Returns: + Which point along the arm's reported width its X refers to. + """ + if self.is_large is None: + raise RuntimeError("arm geometry not resolved") + return "center" if self.is_large else "right" + + @property + def reference_point_from_left(self) -> float: + """Where the tracked X sits, in mm from the arm's left edge. + + Measured on the arm rather than derived from what it reports. The reported width is a reach + from this point to the arm's right edge, doubled, so it does answer the question - but it is a + value someone wrote into the instrument, and a device left at the default answers 370.0 for an + arm whose reach is 354.98. The part does not move when that number does. + """ + if self.is_large is None: + raise RuntimeError("arm geometry not resolved") + if not self.is_large: + raise RuntimeError("no small arm has been measured, so where it is tracked is not known") + return self.large_arm_reference_from_left + + +class XArm: + """One X-arm, on the left or the right rail. + + Reached as `driver.left_x_arm` / `driver.right_x_arm`. Its `configuration` is the arm's own + slice of what the driver read off the device at setup: what is mounted on the arm, how wide it + is, how far it travels, and how far along X what it carries reaches. + """ + + def __init__( + self, + driver: "STARDriver", + side: Literal["left", "right"] = "left", + configuration: Optional[XArmConfiguration] = None, + ): + """ + Args: + driver: the driver to send commands through. + side: which rail this arm runs on. A STAR always has a left arm; a right arm is an option. + configuration: this arm's configuration, written where the device's holds it. Defaults to + whatever the device answered for this rail. + + Raises: + RuntimeError: If a configuration is given before the device has been read. + """ + self._driver = driver + # The arm on the deck, when the driver was given one. Setup puts it there; moves keep it in + # step. Without a deck it stays None and nothing is modelled. + self.resource: Optional[Resource] = None + # What this arm carries. The firmware requires the feature bits of the two drives to be + # disjoint, so a feature is on one arm or the other and never on both. Setup builds each + # from this arm's own bits. + self.pipettes: Optional["Pipettes"] = None + self.head96: Optional["Head96"] = None + self.head384: Optional["Head384"] = None + self.iswap: Optional["iSWAP"] = None + self.side = side + if configuration is not None: + self.configuration = configuration + + @property + def parameter_prefix(self) -> str: + """The letter every parameter to this arm's drive starts with: `l` on the left, `s` on the + + Returns: + The letter, `l` or `s`. + right. The X-drive board carries a drive per arm, each with its own commands.""" + return "l" if self.side == "left" else "s" + + # -- session / discovery --------------------------------------------------- + + @property + def configuration(self) -> XArmConfiguration: + """This arm's configuration and geometry. + + Returns: + This arm's configuration. + + Raises: + RuntimeError: If setup has not run, so no configuration has been read yet. + ValueError: If no arm is installed on this rail. + """ + configuration = self._driver.configuration + if configuration is None: + raise RuntimeError("no configuration read; have you called `star.setup()`?") + arm = configuration.left_arm if self.side == "left" else configuration.right_arm + if arm is None: + raise ValueError(f"no {self.side} X-arm is installed") + return arm + + @configuration.setter + def configuration(self, configuration: XArmConfiguration) -> None: + """Put this arm's configuration where the device's holds it. + + The device reports both arms in one reply, so an arm's configuration is a field of the + device's and this writes into that field. + + Args: + configuration: what this arm is to be configured with. + + Raises: + RuntimeError: If the device has not been read yet, so there is nowhere to put it. + """ + device = self._driver.configuration + if device is None: + raise RuntimeError("no configuration read; have you called `star.setup()`?") + if self.side == "left": + device.left_arm = configuration + else: + device.right_arm = configuration + + async def request_firmware_version(self) -> Tuple[str, datetime.date]: + """Request the X-drive board's firmware version and build date. + + Both arms run off the same board, so this reports the same for either side. + + Returns: + The version string and its build date, e.g. `("1.4S 2012-04-25", date(2012, 4, 25))`. + """ + resp = await self._driver.send_command(module="X0", command="RF") + return resp.split("rf")[-1], parse_firmware_version_date(resp) + + async def request_initialization_status(self) -> bool: + """Request whether this arm's drive reports itself initialized. + + Returns: + Whether it is initialized. + """ + resp = await self._driver.send_command( + module="X0", command="QW", fmt="qw#", mn="1" if self.side == "left" else "2" + ) + return cast(int, resp["qw"]) == 1 + + async def discover(self): + """Read what this arm is. Read-only: nothing moves.""" + version, _ = await self.request_firmware_version() + self.configuration.firmware_version = version + major = version.split(".", 1)[0] + if major.isdigit() and int(major) >= RECORDED_FIRMWARE_BELOW_MAJOR: + logger.warning( + "this X-arm reports firmware %s; the ranges and encodings here were recorded from an arm " + "below %d.0, so its current limiter and the moves it accepts may differ. Set them on " + "XArmConfiguration to correct it.", + version, + RECORDED_FIRMWARE_BELOW_MAJOR, + ) + + def narrow_travel_for_left_side_panel(self) -> None: + """Take the left side panel out of this arm's travel, if one is fitted. + + The drive reports the travel of an unobstructed device, and a panel is bolted on and off in + seconds, so it is declared rather than discovered. What strikes it first is a head, which + reaches far in front of the carriage, so an arm carrying one stops while its channel A1 + is still clear. An arm carrying both stops for whichever needs the most room. Called once setup + has read where the heads sit, since that is what decides how much travel the panel costs. + """ + c = self.configuration + if not self._driver.left_side_panel_installed or c.x_range is None: + return + x_min, x_max = c.x_range + clear = x_min + for head in (self.head96, self.head384): + if head is None or head.configuration.x_offset is None: + continue + clear = max( + clear, head.configuration.min_x_clear_of_left_side_panel + head.configuration.x_offset + ) + if clear > x_min: + logger.debug( + "left side panel fitted: %s X-arm travel narrowed from %s to %s mm", self.side, x_min, clear + ) + c.x_range = (clear, x_max) + + # -- initialization -------------------------------------------------------- + + async def initialize(self, current_limit: Optional[int] = None): + """Initialize this arm's drive. This moves it. + + Args: + current_limit: the motor current limit. Defaults to + `configuration.current_limit_default`. + Raises: + ValueError: If the current limit is outside what the drive accepts. + """ + c = self.configuration + # The parameter is sent, so what the drive does is written here rather than left to the drive's + # own default, which nothing would record. + current_limit = c.current_limit_default if current_limit is None else current_limit + low, high = c.current_limit_range + if not low <= current_limit <= high: + raise ValueError(f"current_limit must be between {low} and {high}, is {current_limit}") + + parameters: Dict[str, Any] = {f"{self.parameter_prefix}w": f"{current_limit:01}"} + return await self._driver.send_command( + module="X0", command="XI" if self.side == "left" else "SI", **parameters + ) + + # ---------------------------------------- + # Movement + # ---------------------------------------- + + def _check_reachable(self, x: float) -> None: + """Raise if `x` is outside this arm's travel range. + + `x` is the arm's position at its reference point - its center on a dual-rail arm, its right + edge on a single-rail arm - so the bound is that point's travel, not the wider span the + arm reaches around it. + + Args: + x: target X position in mm. + + Raises: + RuntimeError: If the arm's geometry was not resolved. + ValueError: If `x` is outside the travel range. + """ + x_range = self.configuration.x_range + if x_range is None: + raise RuntimeError(f"{self.side} X-arm geometry not resolved") + x_min, x_max = x_range + if not x_min <= x <= x_max: + raise ValueError(f"{self.side} X-arm x={x}mm is outside its travel range [{x_min}, {x_max}].") + + @property + def reference_anchor(self) -> Literal["l", "c", "r"]: + """Where along its width this arm's x refers to, as a resource anchor: the centre of a + + Returns: + The anchor, as PyLabRobot names one. + dual-rail arm, the right edge of a single-rail one.""" + return "c" if self.configuration.reference_point == "center" else "r" + + def update_location_by_reference_point(self, x: float) -> None: + """Record where this arm is on the resource that models it. + + The device positions the arm by its reference point - the middle of the width it reports for a + large arm, the right end of it for a small one - while a resource is located by its left front + bottom corner, so the two differ by how far along the arm that point sits. Measured along the + reported width rather than taken as an anchor on the box: the box is the whole arm, which + reaches further right than the drive counts. Does nothing when the driver was given no deck, + and so has nothing to model. + + Args: + x: where the reference point is now, in mm. + """ + if self.resource is None or self.resource.location is None: + return + self.resource.location = Coordinate( + x - self.configuration.reference_point_from_left, + self.resource.location.y, + self.resource.location.z, + ) + + # -- x motion -------------------------------------------------------------- + + async def request_position(self) -> float: + """Request where along its rail the arm is. + + Each drive has its own read, answering with the position twice - in tenths of a millimetre and + in motor counts. The first is what this returns. + + The device is the authority on where the arm is, so what it answers is recorded on the + resource that models it. + + Returns: + The position in mm. + + Raises: + ValueError: If the device answered without a position. + """ + read_command = "RX" if self.side == "left" else "RS" + resp = cast(str, await self._driver.send_command(module="X0", command=read_command)) + read = resp.split(read_command.lower(), 1)[-1].strip().strip("'\u201a\u201b").split() + if not read: + raise ValueError(f"no position in the reply: {resp!r}") + x = self.configuration.x_increments_to_mm(int(read[0])) + self.update_location_by_reference_point(x) + return x + + # TODO: on a device with two arms, check the other arm's position before moving. They share one + # rail, so a move can drive one arm into the other, and neither the drive nor `_check_reachable` + # knows about it - the travel range is the arm's own, measured as though it were alone. What is + # needed is the other arm's position, the width of both (`configuration.width`) and how far each + # reaches around its reference point (`wrap_size`), so a move that would close the gap is refused + # before it starts. Add a `make_space: bool` alongside it: when the far arm is in the way, move it + # clear first rather than refusing - which is what an operator would do by hand, and what a + # protocol wants when the two arms work the same deck. Untestable here: this device has one arm. + async def _record_where_it_stopped(self) -> None: + """Read where this arm came to rest, and record it. + + For a move's `finally`. A move that failed part way left the arm somewhere no target describes. + Its own failure is logged and swallowed: it must not replace the move's exception, which is the + one that says what went wrong. + """ + try: + await self.request_position() + except Exception: + logger.warning("could not read where the %s X-arm stopped; its model is stale", self.side) + + async def move_x( + self, + x: float, + acceleration_level: int = 3, + current_limit: int = 7, + settle_reads: int = 20, + ): + """Move the arm to an absolute X position. + + Collision risk: this moves the arm and everything mounted on it, with no regard for what is + in the way. + + Args: + x: target X position in mm, at the arm's reference point. Must lie within the arm's travel + range (`configuration.x_range`). + acceleration_level: which acceleration curve to use. The drive's own default is + `configuration.acceleration_level_default`; this is the gentler one legacy sends. The + hardest curve leaves the arm oscillating about its target rather than approaching it, so it + takes longer to come to rest and further still with a 96-head parked forward - it arrives + either way, but the settling read below has more to wait for. + current_limit: the motor current limit. + settle_reads: how many reads to spend waiting for the arm to come to rest. Each is a command + round trip, about 10 ms, against a settle of 27 to 90 ms where this was measured. + Raises: + ValueError: If `x` is outside the arm's travel range, or an argument is out of range. + RuntimeError: If the arm's geometry was not resolved. + """ + c = self.configuration + self._check_reachable(x) + low, high = c.acceleration_level_range + if not low <= acceleration_level <= high: + raise ValueError( + f"acceleration_level must be between {low} and {high}, is {acceleration_level}" + ) + low, high = c.current_limit_range + if not low <= current_limit <= high: + raise ValueError(f"current_limit must be between {low} and {high}, is {current_limit}") + + try: + p = self.parameter_prefix + parameters: Dict[str, Any] = { + f"{p}a": f"{c.x_mm_to_increments(x):05}", + f"{p}r": f"{acceleration_level:01}", + f"{p}w": f"{current_limit:01}", + } + resp = await self._driver.send_command( + module="X0", command="XP" if self.side == "left" else "SP", **parameters + ) + except Exception: + # Only on the way out: the settle reads below already record where the arm came to rest, so + # a `finally` here would ask the device a second time on every successful move. + await self._record_where_it_stopped() + raise + + # The reply arrives when the move ends, not when the arm stops. Two reads in a row at the + # target say it has: the extremes of a swing never are. Each read records where the arm is. + self.update_location_by_reference_point(x) + at_target = 0 + for _ in range(settle_reads): + reached = await self.request_position() + at_target = at_target + 1 if abs(reached - x) <= c.x_mm_per_increment else 0 + if at_target == 2: + return resp + logger.warning( + "the %s X-arm was sent to %s mm and had not come to rest there after %d reads", + self.side, + x, + settle_reads, + ) + return resp + + async def _unchecked_fw_move_x_with_attached_components_at_z_safety(self, x: float): + """Move this arm to an X position with all attached components in Z-safety position. Nothing is + guarded and nothing is recorded. + + The master raises what the arm carries before it travels, where `move_x` travels with the arm + as it stands and leaves getting to Z safety to the caller. + + Args: + x: where to send the arm, in mm at its reference point. + """ + return await self._driver.send_command( + module="C0", + command="KX" if self.side == "left" else "KR", + xs=self.configuration.x_mm_to_increments(x), + ) + + async def move_x_relative( + self, + distance: float, + acceleration_level: int = 3, + current_limit: int = 7, + ): + """Move the arm by a distance from where it is now. + + Collision risk: this moves the arm and everything mounted on it, with no regard for what is + in the way. + + Where the arm is is read from the device and the distance added to it, so a relative move is + an absolute move to a place worked out here - and is bounded by the arm's travel range like any + other. + + Args: + distance: how far to move, in mm. Positive moves along the rail towards higher x, negative + towards lower. + acceleration_level: which acceleration curve to use. + current_limit: the motor current limit. + Raises: + ValueError: If the arm would end up outside its travel range, or an argument is outside what + the drive accepts. + RuntimeError: If the arm's geometry was not resolved. + """ + return await self.move_x( + await self.request_position() + distance, + acceleration_level=acceleration_level, + current_limit=current_limit, + ) + + async def _switch_drive_power_off(self): + """Switch this arm's drive power off, leaving it free to be pushed by hand.""" + return await self._driver.send_command( + module="X0", command="XO" if self.side == "left" else "SO" + ) diff --git a/pylabrobot/hamilton/star/driver/features/x_arm_tests.py b/pylabrobot/hamilton/star/driver/features/x_arm_tests.py new file mode 100644 index 00000000000..5d3cc256720 --- /dev/null +++ b/pylabrobot/hamilton/star/driver/features/x_arm_tests.py @@ -0,0 +1,416 @@ +import dataclasses +import json +import pathlib +import tempfile +import unittest +from types import SimpleNamespace +from typing import Any, List, Optional, cast + +from pylabrobot.hamilton.protocol.text.framing import assemble_command +from pylabrobot.hamilton.star.conftest import BARE_X_ARM +from pylabrobot.hamilton.star.device import RECORDING_STAR +from pylabrobot.hamilton.star.driver.configuration import ( + DeviceConfiguration, + read_configuration, + to_jsonable, +) +from pylabrobot.hamilton.star.driver.features.x_arm import XArm, XArmConfiguration +from pylabrobot.hamilton.star.driver.simulator import STARSimulationDriver +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.hamilton import STARDeck +from pylabrobot.resources.hamilton.hamilton_decks import HamiltonDeck + +# What each read answers, keyed by the command that asks: the arm at 362.9 mm, in tenths of a +# millimetre and in motor counts, as the drive reports it. +REPLIES = {"RX": "rx +0003629 +0000036290", "RS": "rs +0003629 +0000036290"} + + +# The device this package ships a recording of, read through the one reader there is: tests need a +# device to start from, and this is the one they stand in for. +RECORDED_DEVICE = cast(DeviceConfiguration, read_configuration(RECORDING_STAR)["device"]) + + +def record(arm: XArm) -> List[str]: + """Record what this arm sends, answering as its drive would. Returns the list it fills.""" + sent: List[str] = [] + + async def recorded(module: str, command: str, fmt: Optional[Any] = None, **kwargs: Any): + sent.append(assemble_command(module=module, command=command, id_=None, **kwargs)) + if command == "QW": + return {"qw": 1} + if command in REPLIES: + return f"{module}{command}{REPLIES[command]}" + return None + + arm._driver.send_command = recorded # type: ignore[assignment] + return sent + + +async def _both_arms() -> STARSimulationDriver: + """A device with an arm on each rail, set up.""" + both = dataclasses.replace( + RECORDED_DEVICE, + right_arm=BARE_X_ARM, + ) + driver = STARSimulationDriver( + deck=STARDeck(), + declared_configuration_json=declaring(device=both), + ) + await driver.setup() + return driver + + +def declaring(**parts: object) -> str: + """The shipped recording with parts swapped out, written where it can be read back. + + A declaration is read from a file and nothing else, so a test that needs a device no recording + describes writes one. Everything not named here stays as the recorded STAR has it. + + Args: + parts: `device` for the device itself, or a feature name for something the left arm carries. + + Returns: + The path it was written to. + """ + tree = json.loads(pathlib.Path(RECORDING_STAR).read_text()) + for name, part in parts.items(): + if name == "device": + tree["device"] = to_jsonable(part) + else: + tree["arms"]["left"][name] = to_jsonable(part) + written = pathlib.Path(tempfile.mkdtemp()) / "declared.json" + written.write_text(json.dumps(tree)) + return str(written) + + +class TestPerDriveCommands(unittest.IsolatedAsyncioTestCase): + """The X-drive board carries a drive per arm, each with its own commands: the command's first + letter and every parameter's first letter change with the drive. A relative move reads where the + arm is and moves it absolutely, so it sends the same command an absolute move does - here the + read is answered by the model, so only the move reaches the wire.""" + + async def test_left_drive(self): + driver = await _both_arms() + arm = cast(XArm, driver.left_x_arm) + sent = record(arm) + await XArm.initialize(arm) + await XArm.move_x(arm, 500.0) + await XArm.move_x_relative(arm, -12.5) + await XArm._switch_drive_power_off(arm) + self.assertEqual( + sent, + ["X0XIlw7", "X0XPla05000lr3lw7", "X0XPla04875lr3lw7", "X0XO"], + ) + + async def test_right_drive(self): + driver = await _both_arms() + arm = cast(XArm, driver.right_x_arm) + sent = record(arm) + await XArm.initialize(arm) + await XArm.move_x(arm, 500.0) + await XArm.move_x_relative(arm, -12.5) + await XArm._switch_drive_power_off(arm) + self.assertEqual( + sent, + ["X0SIsw7", "X0SPsa05000sr3sw7", "X0SPsa04875sr3sw7", "X0SO"], + ) + + async def test_reads_ask_about_this_arms_drive(self): + driver = await _both_arms() + for arm, status, position in ( + (cast(XArm, driver.left_x_arm), "X0QWmn1", "X0RX"), + (cast(XArm, driver.right_x_arm), "X0QWmn2", "X0RS"), + ): + sent = record(arm) + self.assertTrue(await XArm.request_initialization_status(arm)) + self.assertEqual(await XArm.request_position(arm), 362.9) + self.assertEqual(sent, [status, position]) + + +class TestModelFollowsTheArm(unittest.IsolatedAsyncioTestCase): + """The resource on the deck says where the arm is, and only the device can change that.""" + + async def test_a_move_moves_the_model(self): + driver = await _both_arms() + arm = cast(XArm, driver.left_x_arm) + await arm.move_x(500.0) + self.assertEqual(await arm.request_position(), 500.0) + + async def test_a_refused_target_sends_nothing_and_moves_nothing(self): + driver = await _both_arms() + arm = cast(XArm, driver.left_x_arm) + await arm.move_x(500.0) + sent = record(arm) + with self.assertRaises(ValueError): + await arm.move_x(5_000.0) + self.assertEqual(sent, []) + self.assertEqual(await arm.request_position(), 500.0) + + async def test_setup_does_not_duplicate_the_arm(self): + driver = await _both_arms() + await driver.setup() + arms = [ + child.name for child in cast(HamiltonDeck, driver.deck).children if child.category == "x_arm" + ] + self.assertEqual(sorted(arms), ["left_x_arm", "right_x_arm"]) + + async def test_a_rejected_move_records_where_the_arm_stopped(self): + """The arm stops somewhere neither the old position nor the target describes, so the device + is asked where it ended up. Driven against a stub rather than the simulator, whose reads answer + from the model and so cannot report a stop the model does not know about.""" + driver = await _both_arms() + resource = cast(HamiltonDeck, driver.deck).get_resource("left_x_arm") + + async def refuse(module: str, command: str, fmt=None, **kwargs): + if command == "XP": + raise RuntimeError("error 51: drive blocked") + return f"{module}{command}rx +0006400 +0000064000" # 640 mm, part way to the target + + arm = XArm( + SimpleNamespace( + left_side_panel_installed=False, configuration=driver.configuration, send_command=refuse + ), # type: ignore[arg-type] + side="left", + ) + arm.resource = resource + with self.assertRaises(RuntimeError): + await arm.move_x(900.0) + seated = cast(Coordinate, resource.location) + self.assertEqual(seated.x + arm.configuration.reference_point_from_left, 640.0) + + async def test_a_failed_recovery_leaves_the_moves_own_error(self): + driver = await _both_arms() + + async def refuse_everything(module: str, command: str, fmt=None, **kwargs): + raise RuntimeError("error 51: drive blocked" if command == "XP" else "no answer") + + arm = XArm( + SimpleNamespace( + left_side_panel_installed=False, + configuration=driver.configuration, + send_command=refuse_everything, + ), # type: ignore[arg-type] + side="left", + ) + with self.assertRaises(RuntimeError) as raised: + await arm.move_x(900.0) + self.assertIn("drive blocked", str(raised.exception)) + + async def test_a_relative_move_is_bounded_like_an_absolute_one(self): + """It resolves to a target, so a distance that would take the arm off the rail is refused + before anything reaches the wire.""" + driver = await _both_arms() + arm = cast(XArm, driver.left_x_arm) + await arm.move_x(500.0) + sent = record(arm) + with self.assertRaises(ValueError): + await arm.move_x_relative(5_000.0) + self.assertEqual(sent, []) + self.assertEqual(await arm.request_position(), 500.0) + + async def test_a_move_waits_until_the_arm_is_at_the_target(self): + """The reply comes before the arm has stopped, so the move reads until two reads in a row find + it at the target. Driven against a stub, since a simulated read answers from the model.""" + driver = await _both_arms() + resource = cast(HamiltonDeck, driver.deck).get_resource("left_x_arm") + approach = iter( + [ + "rx +0004985 +0000049850", # still arriving + "rx +0005003 +0000050030", # past it + "rx +0005000 +0000050000", # there + "rx +0005000 +0000050000", # and still there + ] + ) + sent: List[str] = [] + + async def answer(module: str, command: str, fmt=None, **kwargs): + sent.append(assemble_command(module=module, command=command, id_=None, **kwargs)) + if command != "RX": + return None + return f"{module}{command}{next(approach, 'rx +0005000 +0000050000')}" + + arm = XArm( + SimpleNamespace( + left_side_panel_installed=False, configuration=driver.configuration, send_command=answer + ), # type: ignore[arg-type] + side="left", + ) + arm.resource = resource + await arm.move_x(500.0) + self.assertEqual(sent.count("X0RX"), 4) + seated = cast(Coordinate, resource.location) + self.assertEqual(seated.x + arm.configuration.reference_point_from_left, 500.0) + + async def test_an_arm_reversing_is_not_mistaken_for_one_at_the_target(self): + """An arm at the end of a swing is momentarily still, so reads across that moment agree with + each other - but not with the target, which is what the move waits for.""" + driver = await _both_arms() + swing = iter( + [ + "rx +0005004 +0000050040", # the top of the overshoot, twice: still, but not there + "rx +0005004 +0000050040", + "rx +0005001 +0000050010", + "rx +0005000 +0000050000", + "rx +0005000 +0000050000", + ] + ) + reads = 0 + + async def answer(module: str, command: str, fmt=None, **kwargs): + nonlocal reads + if command != "RX": + return None + reads += 1 + return f"{module}{command}{next(swing, 'rx +0005000 +0000050000')}" + + arm = XArm( + SimpleNamespace( + left_side_panel_installed=False, configuration=driver.configuration, send_command=answer + ), # type: ignore[arg-type] + side="left", + ) + arm.resource = cast(HamiltonDeck, driver.deck).get_resource("left_x_arm") + await arm.move_x(500.0) + self.assertEqual(reads, 5) + seated = cast(Coordinate, arm.resource.location) + self.assertEqual(seated.x + arm.configuration.reference_point_from_left, 500.0) + + async def test_an_arm_that_never_arrives_gives_up_and_keeps_what_it_read(self): + driver = await _both_arms() + reads = 0 + + async def answer(module: str, command: str, fmt=None, **kwargs): + nonlocal reads + if command != "RX": + return None + reads += 1 + return f"{module}{command}rx +0004980 +0000049800" # 498.0, and it stays there + + arm = XArm( + SimpleNamespace( + left_side_panel_installed=False, configuration=driver.configuration, send_command=answer + ), # type: ignore[arg-type] + side="left", + ) + arm.resource = cast(HamiltonDeck, driver.deck).get_resource("left_x_arm") + with self.assertLogs("pylabrobot.hamilton.star.driver.features.x_arm", level="WARNING"): + await arm.move_x(500.0, settle_reads=3) + self.assertEqual(reads, 3) + seated = cast(Coordinate, arm.resource.location) + self.assertEqual(seated.x + arm.configuration.reference_point_from_left, 498.0) + + +class TestConfiguringAnArm(unittest.IsolatedAsyncioTestCase): + """An arm's configuration is a field of the device's, since the device reports both arms in one + reply. Writing to it goes through to that field, and what no device answers survives a re-read. + """ + + async def test_what_is_written_is_where_the_device_holds_it(self): + driver = await _both_arms() + arm = cast(XArm, driver.left_x_arm) + corrected = dataclasses.replace(arm.configuration, current_limit_range=(0, 15)) + + arm.configuration = corrected + + self.assertEqual(arm.configuration.current_limit_range, (0, 15)) + self.assertIs(cast(DeviceConfiguration, driver.configuration).left_arm, corrected) + + async def test_the_constructor_takes_one_too(self): + """The same write, done where every other feature takes its configuration.""" + driver = await _both_arms() + corrected = dataclasses.replace( + cast(XArm, driver.left_x_arm).configuration, current_limit_default=3 + ) + + arm = XArm(driver, side="left", configuration=corrected) + + self.assertEqual(arm.configuration.current_limit_default, 3) + self.assertIs(cast(DeviceConfiguration, driver.configuration).left_arm, corrected) + + async def test_it_refuses_before_the_device_has_been_read(self): + """There is nowhere to put it: the field it writes to belongs to a configuration that is only + built once the device has answered.""" + driver = STARSimulationDriver(deck=STARDeck(), declared_configuration_json=RECORDING_STAR) + with self.assertRaises(RuntimeError): + XArm(driver, side="left", configuration=BARE_X_ARM) + + async def test_a_simulated_device_then_answers_what_was_written(self): + """A simulated device answers from the configuration it was given, and its discovery keeps an + arm's device facts across a re-read as a physical device's does - so what was written stays.""" + driver = await _both_arms() + arm = cast(XArm, driver.left_x_arm) + arm.configuration = dataclasses.replace(arm.configuration, current_limit_default=3) + + await driver.discover() + + self.assertEqual(arm.configuration.current_limit_default, 3) + + def test_device_facts_carry_over_and_readings_do_not(self): + """What a physical device's discovery does with a configured arm. It rebuilds one from the + reply, then takes the device facts off the arm as it was configured: those are what no device + answers, so a re-read must not put them back to what this generation documents.""" + configured = dataclasses.replace( + BARE_X_ARM, current_limit_range=(0, 15), current_limit_default=15 + ) + answered = dataclasses.replace(BARE_X_ARM, width=354.0, x_range=(95.0, 1340.2)) + + kept = answered.with_device_facts_of(configured) + + self.assertEqual(kept.current_limit_range, (0, 15)) + self.assertEqual(kept.current_limit_default, 15) + self.assertEqual(kept.width, 354.0) + self.assertEqual(kept.x_range, (95.0, 1340.2)) + # Neither of the two it was worked out from is changed. + self.assertEqual(configured.width, BARE_X_ARM.width) + self.assertEqual(answered.current_limit_default, BARE_X_ARM.current_limit_default) + + +class TestGeometryDoesNotFollowTheReportedWidth(unittest.TestCase): + """The arm is a part; the width a drive reports about it is a setting. + + `xu` is written into the instrument rather than measured off it, and a device left at the + default answers 370.0 for the same arm another answers 354.0 for. So nothing about the arm's + geometry may move when that number does. + """ + + # What devices have actually answered - 354.0 on one large arm, 370.0 the default another was + # left at, 246.0 on a half arm - and then the largest the field can hold, which is four digits + # in tenths of a millimetre. No arm is any of the last two sizes, and that is the point: the + # part must not follow the number, whether the number is a default, another arm's, or absurd. + REPORTED_WIDTHS = (354.0, 370.0, 246.0, 999.9) + + def test_a_large_arm_is_the_same_part_whatever_width_it_reports(self): + for width in self.REPORTED_WIDTHS: + with self.subTest(width=width): + arm = XArmConfiguration(width=width, large=True) + self.assertEqual(arm.size_x, 400.49) + self.assertEqual(arm.reference_point_from_left, 223.00) + + def test_the_reported_width_is_the_reach_to_the_right_edge_doubled(self): + """What the number does mean, checked against the part rather than assumed.""" + reported = 354.0 + arm = XArmConfiguration(width=reported, large=True) + reach = arm.size_x - arm.reference_point_from_left + self.assertAlmostEqual(2 * reach, reported, delta=1.0) + + def test_the_variant_comes_from_the_drive_size_bit_not_the_width(self): + wide_but_small = XArmConfiguration(width=370.0, large=False) + narrow_but_large = XArmConfiguration(width=246.0, large=True) + self.assertEqual(wide_but_small.model, "hamilton_legacy_star_single_right_rail_arm") + self.assertEqual(narrow_but_large.model, "hamilton_legacy_star_dual_rail_arm") + self.assertEqual(wide_but_small.reference_point, "right") + self.assertEqual(narrow_but_large.reference_point, "center") + + def test_a_configuration_without_the_bit_falls_back_to_the_width(self): + """A declaration written before the bit was carried has only the width to go on.""" + self.assertIs(XArmConfiguration(width=354.0).is_large, True) + self.assertIs(XArmConfiguration(width=246.0).is_large, False) + self.assertIsNone(XArmConfiguration().is_large) + + def test_a_small_arm_has_not_been_measured(self): + """It refuses rather than answering with the large arm's numbers.""" + small = XArmConfiguration(width=246.0, large=False) + with self.assertRaises(RuntimeError): + _ = small.size_x + with self.assertRaises(RuntimeError): + _ = small.reference_point_from_left diff --git a/pylabrobot/hamilton/star/driver/lock.py b/pylabrobot/hamilton/star/driver/lock.py new file mode 100644 index 00000000000..8486ba0b104 --- /dev/null +++ b/pylabrobot/hamilton/star/driver/lock.py @@ -0,0 +1,71 @@ +import asyncio +from contextlib import AsyncExitStack, asynccontextmanager +from typing import Tuple + + +class _FirmwareLock: + """Coordinates firmware commands by the subsystem they drive. + + The module a command is addressed to is not what has to be serialized. The C0 master is a + router: `C0 II` drives the autoload and `C0 DI` drives the channels, and the device runs + those two together. What cannot overlap is two commands on one physical subsystem, whether they + are addressed to it directly or through C0. + + So a command names the subsystem it drives and each subsystem has one mutex. A command that + reaches wider than one subsystem names how much wider instead: `EVERY_SUBSYSTEM` runs alone, + and `EVERY_SUBSYSTEM_BUT_THE_AUTOLOAD` leaves the autoload free to come up alongside. + + Read-only request (`R*`) and query (`Q*`) commands are not coordinated here at all: the master + answers them while a command is in flight. + """ + + # The pipetting channels are the one subsystem that is not a single module: P1 to PG share a + # mutex, because the device drives them as a set here. + CHANNELS = "channels" + AUTOLOAD = "I0" + + # Scopes a single command can hold, for the ones that are not addressed at a single subsystem. + EVERY_SUBSYSTEM = "every subsystem" + EVERY_SUBSYSTEM_BUT_THE_AUTOLOAD = "every subsystem but the autoload" + + _SUBSYSTEMS = ("C0", "X0", "H0", "D0", "R0", AUTOLOAD, CHANNELS) + _WITHOUT_THE_AUTOLOAD = ("C0", "X0", "H0", "D0", "R0", CHANNELS) + + def __init__(self): + self._locks = {name: asyncio.Lock() for name in self._SUBSYSTEMS} + + @classmethod + def subsystem_of(cls, module: str) -> str: + """The subsystem a command addressed to `module` drives, when it names no other. + + A module this does not know drives something unaccounted for, so it is taken to reach every + subsystem and runs alone rather than running unlocked. + + Args: + module: the module the command is addressed to. + + Returns: + The subsystem key. + """ + if module.startswith("P"): + return cls.CHANNELS + return module if module in cls._SUBSYSTEMS else cls.EVERY_SUBSYSTEM + + @asynccontextmanager + async def subsystem(self, key: str): + """Run a command on one subsystem, or on the wider scope it names. + + Args: + key: the subsystem the command drives, or one of the wider scopes: `EVERY_SUBSYSTEM`, or + `EVERY_SUBSYSTEM_BUT_THE_AUTOLOAD`. + """ + names: Tuple[str, ...] + if key == self.EVERY_SUBSYSTEM_BUT_THE_AUTOLOAD: + names = self._WITHOUT_THE_AUTOLOAD + else: + resolved = key if key == self.EVERY_SUBSYSTEM else self.subsystem_of(key) + names = self._SUBSYSTEMS if resolved == self.EVERY_SUBSYSTEM else (resolved,) + async with AsyncExitStack() as stack: + for name in names: # always in declaration order, so two callers cannot deadlock + await stack.enter_async_context(self._locks[name]) + yield diff --git a/pylabrobot/hamilton/star/driver/master.py b/pylabrobot/hamilton/star/driver/master.py new file mode 100644 index 00000000000..34ac303f72c --- /dev/null +++ b/pylabrobot/hamilton/star/driver/master.py @@ -0,0 +1,1817 @@ +"""The STAR master module, responsible for +- carrying the connection & transport, +- firmware protocol generation and parsing, +- orchestrating higher level tasks. +""" + +import asyncio +import dataclasses +import datetime +import json +import logging +from typing import Any, Dict, FrozenSet, List, Literal, Optional, Tuple, Union, cast, overload + +from pylabrobot.events import emit_event +from pylabrobot.hamilton.protocol.text.framing import ( + assemble_channel_command, + parse_firmware_version_date, + parse_fw_string, +) +from pylabrobot.hamilton.protocol.text.router import ReplyRouter +from pylabrobot.hamilton.star.driver.configuration import ( + DeviceConfiguration, + read_configuration, + to_jsonable, +) +from pylabrobot.hamilton.star.driver.errors import ( + STAR_MODULE_ID_LENGTH, + check_fw_string_error, +) +from pylabrobot.hamilton.star.driver.features.autoload import Autoload +from pylabrobot.hamilton.star.driver.features.cover import FrontCover +from pylabrobot.hamilton.star.driver.features.head96 import Head96 +from pylabrobot.hamilton.star.driver.features.head384 import Head384 +from pylabrobot.hamilton.star.driver.features.iswap import iSWAP, iSWAPConfiguration +from pylabrobot.hamilton.star.driver.features.pipettes import Pipettes +from pylabrobot.hamilton.star.driver.features.x_arm import XArm, XArmConfiguration +from pylabrobot.hamilton.star.driver.lock import _FirmwareLock +from pylabrobot.hamilton.star.resource_model import ( + ROTATION_DRIVE_COLUMN_ABOVE_REPORTED_Z, + iswap_gripper, + iswap_head, + iswap_link_1, + iSWAPHead, +) +from pylabrobot.hamilton.star.resource_model import head96 as head96_pipette +from pylabrobot.hamilton.star.resource_model import head384 as head384_pipette +from pylabrobot.io.io import IOBase +from pylabrobot.io.usb import USB +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.end_effector import MechanicalGripper +from pylabrobot.resources.hamilton.hamilton_decks import HamiltonDeck +from pylabrobot.resources.hamilton.tip_creators import HamiltonTip, TipPickupMethod, TipSize +from pylabrobot.resources.manipulator import LinkBody +from pylabrobot.resources.n_channel_pipettes import NChannelPipette +from pylabrobot.resources.resource import Resource + +logger = logging.getLogger(__name__) + +# What a declaration and a device have to agree on for the one to stand for the other: what is +# fitted and how much of it. Everything else is either identity, which is the device's own, or +# geometry, which follows from what is fitted. +_DECLARATION_MUST_MATCH = frozenset( + { + "num_pip_channels", + "instrument_size_slots", + "autoload_installed", + "kb_iswap_installed", + "head96_installed", + "head384_installed", + "main_front_cover_monitoring_installed", + } +) + +ID_VENDOR = 0x08AF +ID_PRODUCT = 0x8000 + + +def _range(values: Optional[Tuple[float, float]]) -> str: + """A `(low, high)` range in mm, or a note that it was not resolved.""" + return "unresolved" if values is None else f"{values[0]} to {values[1]} mm" + + +class STARDriver: + """Interface for the Hamilton STARDriver.""" + + def __init__( + self, + device_address: Optional[int] = None, + serial_number: Optional[str] = None, + deck: Optional[HamiltonDeck] = None, + declared_configuration_json: Optional[str] = None, + packet_read_timeout: int = 3, + write_timeout: int = 30, + read_timeout: int = 120, + left_side_panel_installed: bool = False, + io: Optional[IOBase] = None, + ): + """Create a new STAR interface. + + Args: + device_address: the USB device address of the Hamilton STAR. Only useful if using more than + one Hamilton device over USB. + serial_number: the serial number of the Hamilton STAR. Only useful if using more than one + Hamilton device over USB. + deck: the deck to reflect the device into. Optional: without one the driver still drives the + device, and nothing about where things are is modelled. + declared_configuration_json: path to a JSON file holding the declared configuration for the + device, as `save_configuration` writes one. The only way a configuration is read from a + file. If given, (1) against a physical device, discovery cross-checks the declaration + against what the device answers, (2) in simulation, the device answers as the declaration + says instead of from the simulation default. + packet_read_timeout: timeout in seconds for reading a single packet. + read_timeout: timeout in seconds for reading a full response. + write_timeout: timeout in seconds for writing a command. + left_side_panel_installed: whether the device has its left side panel on. Declared, not + read: it comes off in seconds, and the reported travel range does not follow it. With one + fitted, an arm carrying a head stops while the head is still clear of it. + io: an already-built USB handle to use instead of opening one from the arguments above. + """ + + self.io: IOBase = io or USB( + human_readable_device_name=f"Hamilton {'STAR'}", + id_vendor=ID_VENDOR, + id_product=ID_PRODUCT, + device_address=device_address, + write_timeout=write_timeout, + serial_number=serial_number, + ) + + # Coordinates commands on the shared link: one at a time per module, and a C0 master + # command alone. Read-only requests are exempt, see `send_command`. + self._lock = _FirmwareLock() + self._replies = ReplyRouter( + io=self.io, + module_id_length=STAR_MODULE_ID_LENGTH, + parse_id=self.get_id_from_fw_response, + raise_for_error=check_fw_string_error, + packet_read_timeout=packet_read_timeout, + read_timeout=read_timeout, + ) + + # What was declared this device is, read once here: the one place a configuration comes off a + # file. Empty when nothing was declared. + self.declared_configuration_json = declared_configuration_json + self.declared: Dict[str, Any] = ( + {} if declared_configuration_json is None else read_configuration(declared_configuration_json) + ) + + self._num_channels: Optional[int] = None + + self._connected = False + + self.left_side_panel_installed = left_side_panel_installed + + # The deck to reflect the device into, or None to drive it without a resource model. With one, + # setup builds a resource per feature as a child of it; without, nothing is modelled and + # driver functionality is limited due to lack of information available. + self.deck = deck + + self.configuration: Optional[DeviceConfiguration] = None + + self.firmware: Dict[str, str] = {} + # Which table index each tip type was written to. The table is volatile, so this is + # rebuilt per session as tips are first used. + self._tip_type_indices: Dict[int, int] = {} + + # Subsystems. Each reads what it needs off `configuration`, so they are usable once setup has + # run and raise a clear error before that. Each arm appears only if setup finds one installed. + self.front_cover: Optional[FrontCover] = None + self.left_x_arm: Optional[XArm] = None + self.right_x_arm: Optional[XArm] = None + self.autoload: Optional[Autoload] = None + + # ---------------------------------------- + # Connection and lifecycle + # ---------------------------------------- + + async def setup( + self, + skip_device_initialization: bool = False, + skip_pipettes: bool = False, + skip_iswap: bool = False, + skip_head96: bool = False, + skip_head384: bool = False, + skip_autoload: bool = False, + ): + """Connect to the device, find out what it is, and bring it up. + + This moves the device: everything that can be initialized is. `discover` is the read-only + part; it reads the device without moving it, and needs the link already open. + + Repeatable. Discovery re-reads the device, and initialization does nothing on a device that + is already up. A setup that fails part way closes the link. + + Every argument only ever does less. Each leaves that feature exactly as the device had it - + which for one that reports itself down means its drives keep no reference, and it will refuse + to move until something initializes it. Discovery still reads it either way, so what is + skipped is the moving, not the finding out. + + Args: + skip_device_initialization: do not run the device's own initialization procedure on a device + that reports itself down. A device that reports itself up is still raised to Z safety, + which is not motion this can decline: nothing may travel laterally while a channel is low. + skip_pipettes: do not initialize the channels. + skip_iswap: do not initialize or park the iSWAP. + skip_head96: do not initialize the 96-head. + skip_head384: do not initialize the 384-head. + skip_autoload: do not initialize or park the autoload. + """ + skipped = frozenset( + name + for name, skip in ( + ("pipettes", skip_pipettes), + ("iswap", skip_iswap), + ("head96", skip_head96), + ("head384", skip_head384), + ) + if skip + ) + logger.debug("Setting up STAR on %s ...", self._describe_link()) + # A repeated setup goes over the link the first one opened: opening it again would start a + # second thread reading the same replies. + if not self._connected: + await self._open() + self._connected = True + + try: + # 1. What is on the other end, and what does it carry? + logger.debug("[PHASE 1] Discovery") + await self.discover() + + # 2. Bring the device to a known state. The autoload homes alongside it: it is its own + # unit, and the device procedure holds every drive but its. Phase 3 reads its state + # again, so a device that does home it there loses nothing but the overlap. + # It runs alone. The autoload used to be brought up alongside it, to save the time the + # procedure takes, and that put a C0 command and an I0 command in flight together: on + # 2026-09-04 the autoload answered one with the id of its own previous command, and the + # caller waited for a reply that had already arrived. Legacy runs the procedure by itself + # and gathers the modules afterwards; so does this. + logger.debug("[PHASE 2] Device initialization") + autoload = self.autoload if not skip_autoload else None + already_initialized = await self.request_initialization_status() + if skip_device_initialization and not already_initialized: + logger.debug("device reports not initialized, and initializing it was skipped") + else: + already_initialized = await self.initialize() + + # 3. Each feature brings itself up. They sit on different modules, so they run together; + # the autoload, iSWAP and 96-head join this gather as they land. The channels only need + # it when the device procedure did not just run, or when something is still mounted. + logger.debug("[PHASE 3] Feature initialization") + initializing = [self._initialize_arm(arm, already_initialized, skipped) for arm in self.arms] + if autoload is not None: + initializing.append(autoload.initialize()) + await asyncio.gather(*initializing) + + # A command that answered is not a module that came up, so each is asked again. The channels + # report no initialization of their own. + down = [ + type(feature).__name__ + for feature in self.features + if not isinstance(feature, Pipettes) + and not await self.request_initialization_status(feature.configuration.module) + ] + if not await self.request_initialization_status(): + down.insert(0, "device") + if down: + logger.warning("setup finished with these not initialized: %s", ", ".join(down)) + + # 4. What was found, as resources on the deck - when the driver was given one to reflect + # into. Each is a child of the deck, so a device with a deck carries one tree. + if self.deck is not None: + logger.debug("[PHASE 4] Feature resources") + await self._create_capability_resources() + + except BaseException: + await self.stop() + raise + + logger.info("%s", self.format_setup_summary()) + + async def _open(self): + """Open the link and start reading replies.""" + await self.io.setup() + self._replies.start() + + async def _close(self): + """Stop reading replies and close the link.""" + self._replies.stop() + await self.io.stop() + + async def features_below_safe_z(self, tolerance: float = 0.5) -> List[str]: + """Which channels, heads, iSWAP and the autoload wheel report below where they are safe. + + Read back rather than taken on trust. A retract that answered without arriving leaves the + device looking safe while a lateral move would drive whatever is still low into whatever is in + the way, and the answer to the move command does not say where the drive stopped. + + A feature with no window probed is not judged: there is no height to hold it to. + + Args: + tolerance: how far below the top of the window still counts as up, in mm. + + Returns: + One entry per channel, head, arm or wheel that is low, naming it and where it says it is. + Empty + when everything is up, which is the only state anything may travel laterally in. + """ + low: List[str] = [] + for arm in self.arms: + pipettes = arm.pipettes + if pipettes is not None and pipettes.configuration.z_range is not None: + safe = pipettes.configuration.z_range[1] + try: + positions = await pipettes.request_stop_disc_z_positions() + except Exception: + low.append(f"{arm.side} channels (where they are could not be read)") + else: + low += [ + f"{arm.side} channel {channel} at {z:.1f} mm, safe is {safe:.1f} mm" + for channel, z in positions.items() + if z < safe - tolerance + ] + for head, name in ((arm.head96, "head96"), (arm.head384, "head384")): + if head is None: + continue + safe = head.configuration.z_range[1] + try: + z = await head.request_z_position() + except Exception: + low.append(f"{arm.side} {name} (where it is could not be read)") + else: + if z < safe - tolerance: + low.append(f"{arm.side} {name} at {z:.1f} mm, safe is {safe:.1f} mm") + iswap = arm.iswap + if iswap is not None: + safe = iswap.configuration.rotation_drive_z_range[1] + try: + z = await iswap.rotation_drive_request_z_position() + except Exception: + low.append(f"{arm.side} iSWAP (where it is could not be read)") + else: + if z < safe - tolerance: + low.append(f"{arm.side} iSWAP at {z:.1f} mm, safe is {safe:.1f} mm") + + autoload = self.autoload + if autoload is not None: + try: + at_safe_z = await autoload.wheel_is_at_safe_z() + except Exception: + low.append("autoload wheel (where it is could not be read)") + else: + if not at_safe_z: + low.append("autoload wheel below its safe Z") + return low + + async def stop(self): + """Close the link, leaving the device safe to move laterally. + + The device keeps its state; only this driver lets go of it. Every channel, head and the + autoload wheel are moved up to Z safety first: a driver that let go with any of them low + would leave the next lateral move from anything else to crash it. + + An iSWAP parks after them, which retracts it, but only with empty fingers: one still holding a + plate would carry it home and leave it wherever the fingers next let go. + + The link closes whether or not that succeeds. This also runs when setup failed part way, + where there may be nothing up to move yet and the failure that matters is the one about to + propagate. + + Repeatable. A device already let go of is left alone: retracting through a closed link + fails on every subsystem, and the warnings that produces read exactly like a device that + would not come up. + """ + if not self._connected: + logger.debug("the link is already closed; nothing to put down") + return + + try: + # The channels, each head and the autoload wheel take different firmware locks and drive + # different Z axes, so they go up together rather than one after another. The wheel is on + # its own unit and travels its own rail, but it is the same hazard: left down, it is what + # the sled carries into whatever the sled next passes. + safe_z_moves: List[Any] = [] + for arm in self.arms: + if arm.pipettes is not None: + safe_z_moves.append(arm.pipettes.move_to_safe_z()) + for head in (arm.head96, arm.head384): + if head is not None: + safe_z_moves.append(head.move_to_safe_z()) + # The arm is the same hazard as the rest: parking retracts it, and an arm left low is + # driven through whatever it is over on the way home. + if arm.iswap is not None: + safe_z_moves.append(arm.iswap.rotation_drive_move_to_safe_z_height()) + if self.autoload is not None: + safe_z_moves.append(self.autoload.wheel_move_to_safe_z()) + + # Every subsystem gets its chance to reach safe Z, so one that cannot does not leave the + # rest of them low. + results = await asyncio.gather(*safe_z_moves, return_exceptions=True) + failed = [result for result in results if isinstance(result, BaseException)] + for failure in failed: + logger.warning("could not move a subsystem to Z safety", exc_info=failure) + + # Asked, not assumed: a move that answers has not said where it stopped, and this is the + # one moment the answer decides whether anything may travel laterally. + low = await self.features_below_safe_z() + if low: + logger.warning( + "not everything is at Z safety, so the iSWAP stays where it is: %s", "; ".join(low) + ) + + # The iSWAP parks instead: parking retracts it, which is lateral motion, so it waits until + # everything sharing its arm is up. If anything is still low, it stays where it is. + if not failed and not low: + parks = [] + for arm in self.arms: + if arm.iswap is None: + continue + # Asked, not assumed, and asked of the arm rather than the model: parking closes the + # gripper and retracts it, so an arm still holding a plate carries it home and leaves it + # wherever the fingers next let go. An arm that cannot say counts as holding something. + try: + holding = await arm.iswap.request_plate_gripped() + except Exception: + logger.warning( + "could not read whether the %s iSWAP is holding a plate, so it stays where it is", + arm.side, + exc_info=True, + ) + continue + if holding: + logger.warning("the %s iSWAP is holding a plate, so it stays where it is", arm.side) + continue + parks.append(arm.iswap.park()) + for failure in await asyncio.gather(*parks, return_exceptions=True): + if isinstance(failure, BaseException): + logger.warning("could not park the iSWAP", exc_info=failure) + + # And the deck goes dark. An indicator left lit outlives the session that lit it - it says a + # carrier is being handled by software that is no longer there. On its own path, and its own + # failure: a deck that will not go dark is not a reason to hold the link open. + if self.autoload is not None: + try: + await self.autoload.clear_loading_indicators() + except Exception: + logger.warning("could not put the loading indicators out", exc_info=True) + except Exception: + logger.warning( + "could not bring the device to a safe state; closing the link anyway", exc_info=True + ) + finally: + self._connected = False + # The device's tip type table is volatile, so what this session wrote to it does not + # survive the device going down. + self._tip_type_indices.clear() + await self._close() + + @property + def connected(self) -> bool: + """Whether the link is open. Commands can be sent only while it is.""" + return self._connected + + def _describe_link(self) -> str: + """How this device is reached, in whatever terms its transport is addressed by.""" + fields = self.io.serialize() + link = type(self.io).__name__ + vendor, product = fields.get("id_vendor"), fields.get("id_product") + if vendor is not None and product is not None: + link += f" {vendor:#06x}:{product:#06x}" + named = [ + f"{label} {fields[key]}" + for label, key in ( + ("address", "device_address"), + ("serial", "serial_number"), + ("port", "port"), + ) + if fields.get(key) + ] + return link + (f" ({', '.join(named)})" if named else "") + + # ---------------------------------------- + # Low-level I/O + # ---------------------------------------- + + @overload + async def send_command( + self, + module: str, + command: str, + auto_id: bool = True, + tip_pattern: Optional[List[bool]] = None, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + *, + fmt: Any, + subsystem: Optional[str] = None, + **kwargs: Any, + ) -> Dict[str, Any]: ... + + @overload + async def send_command( + self, + module: str, + command: str, + auto_id: bool = True, + tip_pattern: Optional[List[bool]] = None, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + fmt: None = None, + subsystem: Optional[str] = None, + **kwargs: Any, + ) -> str: ... + + async def send_command( + self, + module: str, + command: str, + auto_id=True, + tip_pattern: Optional[List[bool]] = None, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + fmt: Optional[Any] = None, + subsystem: Optional[str] = None, + **kwargs, + ): + """Assemble a firmware command, send it, and parse the reply if a format is given. + + Modules share one physical link between control PC and the device but due to the STAR's + architecture containing multiple control boards, for different drives, we can parallelise + certain subsystems. + To prevent collisions, we use a lock system inside this method which ensures that only + compatible commands are sent in parallel. + + A command addressed to C0 can drive a subsystem of its own, e.g. `C0 DI` the channels and + `C0 II` the autoload, enabling parallelisation of known orthogonal C0 commands.\ + A C0 command naming no subsystem explicitly is assumed to not allow parallelisation and + runs alone. + Read-only `R` and `Q` commands take no lock. + + Args: + module: the module to address the command to. + command: the two-letter command code. + subsystem: which subsystem the command drives, when that is not the module it is addressed + to. Only a C0 command needs it. + + Returns: + The parsed reply when a format was given, and the raw reply otherwise. + + Raises: + RuntimeError: If the link is not open. + """ + kwargs_ = dict( + auto_id=auto_id, + tip_pattern=tip_pattern, + write_timeout=write_timeout, + read_timeout=read_timeout, + fmt=fmt, + **kwargs, + ) + if command[0] in ("R", "Q"): + return await self._send(module, command, **kwargs_) + key = subsystem or (_FirmwareLock.EVERY_SUBSYSTEM if module == "C0" else module) + async with self._lock.subsystem(key): + return await self._send(module, command, **kwargs_) + + async def _send( + self, + module: str, + command: str, + auto_id=True, + tip_pattern: Optional[List[bool]] = None, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + fmt: Optional[Any] = None, + **kwargs, + ): + """Assemble, send and parse, without coordinating against anything else in flight.""" + self._require_connection() + id_ = self._replies.next_id() if auto_id else None + # Always the channel-aware assembler: a list parameter has to be terminated against the + # device's channel count whether or not the caller named which channels are involved, and + # `tip_pattern=None` means each list already holds one value per channel it names. + # + # The count is only read when there is a list to terminate. Discovery has to send commands + # before it knows the count, and asking for it there would refuse the reads that establish it. + carries_a_list = any(isinstance(value, list) for value in kwargs.values()) + cmd = assemble_channel_command( + module=module, + command=command, + id_=id_, + tip_pattern=tip_pattern, + num_channels=self.num_channels if carries_a_list else 0, + **kwargs, + ) + event_data = { + "transport": "hamilton_usb", + "driver": type(self).__name__, + "module": module, + "command": command, + "command_id": id_, + "raw_command": cmd, + } + emit_event("firmware.command.started", **event_data) + try: + resp = cast( + str, + await self._replies.send( + cmd=cmd, + id_=id_, + write_timeout=write_timeout, + read_timeout=read_timeout, + ), + ) + result = self._parse_response(resp, fmt) if fmt is not None else resp + except BaseException as error: + emit_event( + "firmware.command.failed", + **event_data, + error_type=type(error).__name__, + error_message=str(error), + ) + raise + emit_event("firmware.command.completed", **event_data, response=resp) + return result + + async def send_raw_command( + self, + command: str, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + wait: bool = True, + ) -> Optional[str]: + """Send a raw command to the device. + + Returns: + Whatever the device answered, or None when nothing came back. + + Raises: + RuntimeError: If the link is not open. + """ + self._require_connection() + return await self._replies.send_raw( + command=command, + write_timeout=write_timeout, + read_timeout=read_timeout, + wait=wait, + ) + + def _require_connection(self) -> None: + """Raise unless the link is open. A command may not be sent into a closed link.""" + if not self._connected: + raise RuntimeError("not connected to a device; call `setup` first") + + def get_id_from_fw_response(self, resp: str) -> Optional[int]: + """Get the id from a firmware response.""" + parsed = parse_fw_string(resp, "id####") + if "id" in parsed and parsed["id"] is not None: + return int(parsed["id"]) + return None + + def _parse_response(self, resp: str, fmt: Any) -> Dict[str, Any]: + """Parse a response from the device.""" + return parse_fw_string(resp, fmt) + + # ---------------------------------------- + # What the arms carry + # ---------------------------------------- + + @property + def arms(self) -> List[XArm]: + """The arms this device has, left first.""" + return [arm for arm in (self.left_x_arm, self.right_x_arm) if arm is not None] + + @property + def features(self) -> List[Union[Pipettes, Head96, Head384, iSWAP, Autoload]]: + """Every feature this device is fitted with: each arm's channels, heads and iSWAP, then the + autoload.""" + features: List[Union[Pipettes, Head96, Head384, iSWAP, Autoload]] = [] + for arm in self.arms: + for feature in (arm.pipettes, arm.head96, arm.head384, arm.iswap): + if feature is not None: + features.append(feature) + if self.autoload is not None: + features.append(self.autoload) + return features + + def _require_one_arm(self, reaching_for: str) -> Optional[XArm]: + """Check to enable simple accessors which are only unambiguous when there is only one Xarm. + + e.g.: + `star.head96` is ambiguous on a device with two arms. + -> requires explicit declaration of the arm that has the head: + `star.left_x_arm.head96` or `star.right_x_arm.head96`. + + Args: + reaching_for: what the caller was after. Used to word the refusal. + + Returns: + The device's one arm, or None when it has none. + + Raises: + ValueError: If the device has more than one arm. + """ + arms = self.arms + if len(arms) > 1: + raise ValueError( + f"this device has two X-arms, so `{reaching_for}` is ambiguous - reach it through " + f"`left_x_arm.{reaching_for}` or `right_x_arm.{reaching_for}`." + ) + return arms[0] if arms else None + + @property + def pipettes(self) -> Optional[Pipettes]: + """The pipetting channels, on a device with one arm.""" + arm = self._require_one_arm("pipettes") + return arm.pipettes if arm is not None else None + + @property + def head96(self) -> Optional[Head96]: + """The 96-head, on a device with one arm.""" + arm = self._require_one_arm("head96") + return arm.head96 if arm is not None else None + + @property + def head384(self) -> Optional[Head384]: + """The 384-head, on a device with one arm.""" + arm = self._require_one_arm("head384") + return arm.head384 if arm is not None else None + + @property + def iswap(self) -> Optional[iSWAP]: + """The iSWAP, on a device with one arm.""" + arm = self._require_one_arm("iswap") + return arm.iswap if arm is not None else None + + @property + def x_arm(self) -> XArm: + """The device's X-arm, on a device that has only one. + + Most STARs carry a single arm, where explicit naming can be cumbersome. + A device with two has no single X-arm, and this refuses instead of picking one. + + Returns: + The device's one arm. + + Raises: + RuntimeError: If setup has not run, so it is not yet known which arms are installed. + ValueError: If the device has no arm, or more than one. + """ + if self.configuration is None: + raise RuntimeError("no configuration read; have you called `star.setup()`?") + installed = { + name: arm + for name, arm in (("left_x_arm", self.left_x_arm), ("right_x_arm", self.right_x_arm)) + if arm is not None + } + if not installed: + raise ValueError("this device reports no X-arm installed.") + if len(installed) > 1: + raise ValueError( + f"this device has {len(installed)} X-arms ({', '.join(installed)}), so `x_arm` is " + f"ambiguous. Use the one you mean by name." + ) + return next(iter(installed.values())) + + @property + def num_channels(self) -> int: + """The number of pipette channels present on the robot.""" + if self._num_channels is None: + raise RuntimeError("channel count not read; have you called `star.setup()`?") + return self._num_channels + + # ---------------------------------------- + # Device queries + # ---------------------------------------- + + async def request_device_serial_number(self) -> str: + """Request what the device calls itself. + + Not the USB serial this driver may have picked the device off the bus with: that identifies a + handle, this identifies the device answering on it. + + Returns: + The serial number the device answers. + """ + # One `sn` field, not the two the older driver named: a repeated name parses to the first + # match, so the second was inert there and would be here. Whether the device answers a serial + # in two four-character halves is unsettled - if it does, this reads the first half only, and a + # reading off a device is what will say. + resp = await self.send_command(module="C0", command="RI", fmt="si####sn&&&&") + return cast(str, resp["sn"]) + + async def request_firmware_version(self) -> Tuple[str, datetime.date]: + """Request the master's firmware version and build date. + + Returns: + The version string and its build date, e.g. `("7.6S", date(2021, 11, 5))`. + """ + resp = await self.send_command(module="C0", command="RF") + return resp.split("rf")[-1], parse_firmware_version_date(resp) + + async def request_cover_input_status(self) -> Tuple[bool, bool, bool]: + """Read the three inputs on the cover connector. + + An device-level query about the cover feature. `front_cover.request_position` reports the + cover's own position. + + TODO: establish what each input carries. Every reading so far is 000, except during a run + where the operator closed the cover between two reads and it went 000 -> 100. + + Returns: + Whether each of the three inputs is set. + + Raises: + ValueError: If the response contains fewer than three inputs. + """ + resp = await self.send_command(module="C0", command="RW") + read = resp.split("rw", 1)[-1].strip().strip("'") + if len(read) < 3: + raise ValueError(f"expected three inputs in the reply: {resp!r}") + return read[0] == "1", read[1] == "1", read[2] == "1" + + async def request_maximal_ranges_of_x_drives(self) -> Dict[str, Tuple[float, float]]: + """Request the maximal travel range of each X drive. + + Returns: + The `(minimum, maximum)` X position in mm each drive can reach, keyed by side: + `{"left": (min, max), "right": (min, max)}`. + """ + resp = await self.send_command(module="C0", command="RU") + values = [int(v) / 10 for v in resp.split("ru")[-1].strip().split()] + left_min, left_max, right_min, right_max = values + return {"left": (left_min, left_max), "right": (right_min, right_max)} + + async def request_working_envelopes_per_arm( + self, + ) -> Dict[str, Tuple[float, Tuple[float, float]]]: + """Request the working envelope of each installed arm. + + Returns: + Per side, `(wrap_size, (workspace_min, workspace_max))` in mm, keyed by side. A + `wrap_size` of 0 means that arm is not installed. + """ + resp = await self.send_command(module="C0", command="UA", subsystem="C0") + values = [int(v) / 10 for v in resp.split("ua")[-1].strip().split()] + left_wrap, right_wrap, left_min, left_max, right_min, right_max = values + return { + "left": (left_wrap, (left_min, left_max)), + "right": (right_wrap, (right_min, right_max)), + } + + async def request_device_configuration(self) -> DeviceConfiguration: + """Request the device's installed hardware and geometry. + + Combines the device configuration (RM) and the extended configuration (QM). Each installed + X-drive's geometry is resolved from the X-drive range (RU) and working-envelope (UA) queries; + `right_arm` is None when no second arm is installed. + + Returns: + What the device reports it carries. + """ + device = await self.send_command(module="C0", command="RM", fmt="kb**kp##") + extended = await self.send_command( + module="C0", + command="QM", + fmt="ka******ke********xt##xa##xw#####xl**xn**xr**xo**xm#####xx#####xu####xv####kc#kr#" + + "ys###kl###km###ym####yu####yx####", + ) + + ranges = await self.request_maximal_ranges_of_x_drives() + wraps = await self.request_working_envelopes_per_arm() + + def _resolve_arm( + byte1: int, byte2: int, side: Literal["left", "right"], width: float, large: bool + ) -> Optional[XArmConfiguration]: + wrap, workspace_x_range = wraps[side] + if wrap == 0: # arm not installed + return None + answered = XArmConfiguration( + pip_installed=bool(byte1 & (1 << 0)), + iswap_installed=bool(byte1 & (1 << 1)), + head96_installed=bool(byte1 & (1 << 2)), + nano_pipettor_installed=bool(byte1 & (1 << 3)), + head384_installed=bool(byte1 & (1 << 4)), + xl_channels_installed=bool(byte1 & (1 << 5)), + tube_gripper_installed=bool(byte1 & (1 << 6)), + imaging_channel_installed=bool(byte1 & (1 << 7)), + robotic_channel_installed=bool(byte2 & (1 << 0)), + gel_card_gripper_installed=bool(byte2 & (1 << 1)), + puncher_handler_installed=bool(byte2 & (1 << 2)), + width=width, + large=large, + x_range=ranges[side], + workspace_x_range=workspace_x_range, + wrap_size=wrap, + ) + # Only the fields above come off the device. The rest are device facts, which nothing + # reports and a caller may have corrected for an arm this driver was not recorded against, + # so they are carried over rather than reset to what this generation documents. Every other + # feature keeps its configuration across a re-read because the object survives; an arm's is + # rebuilt here, so what was set on it is carried by hand. + if self.configuration is None: + return answered + carried = self.configuration.left_arm if side == "left" else self.configuration.right_arm + return answered if carried is None else answered.with_device_facts_of(carried) + + kb = device["kb"] + ka = extended["ka"] + return DeviceConfiguration( + pip_type_1000ul=bool(kb & (1 << 0)), + kb_iswap_installed=bool(kb & (1 << 1)), + main_front_cover_monitoring_installed=bool(kb & (1 << 2)), + autoload_installed=bool(kb & (1 << 3)), + wash_station_1_installed=bool(kb & (1 << 4)), + wash_station_2_installed=bool(kb & (1 << 5)), + temp_controlled_carrier_1_installed=bool(kb & (1 << 6)), + temp_controlled_carrier_2_installed=bool(kb & (1 << 7)), + num_pip_channels=device["kp"], + left_x_drive_large=bool(ka & (1 << 0)), + head96_installed=bool(ka & (1 << 1)), + right_x_drive_large=bool(ka & (1 << 2)), + pump_station_1_installed=bool(ka & (1 << 3)), + pump_station_2_installed=bool(ka & (1 << 4)), + wash_station_1_type_cr=bool(ka & (1 << 5)), + wash_station_2_type_cr=bool(ka & (1 << 6)), + left_cover_installed=bool(ka & (1 << 7)), + right_cover_installed=bool(ka & (1 << 8)), + additional_front_cover_monitoring_installed=bool(ka & (1 << 9)), + pump_station_3_installed=bool(ka & (1 << 10)), + multi_channel_nano_pipettor_installed=bool(ka & (1 << 11)), + head384_installed=bool(ka & (1 << 12)), + xl_channels_installed=bool(ka & (1 << 13)), + tube_gripper_installed=bool(ka & (1 << 14)), + waste_direction_left=bool(ka & (1 << 15)), + iswap_gripper_wide=bool(ka & (1 << 16)), + additional_channel_nano_pipettor_installed=bool(ka & (1 << 17)), + imaging_channel_installed=bool(ka & (1 << 18)), + robotic_channel_installed=bool(ka & (1 << 19)), + channel_order_ox_first=bool(ka & (1 << 20)), + x0_interface_ham_can=bool(ka & (1 << 21)), + park_heads_with_iswap_off=bool(ka & (1 << 22)), + configuration_data_3=extended["ke"], + instrument_size_slots=extended["xt"], + autoload_size_slots=extended["xa"], + tip_waste_x_position=extended["xw"] / 10, + left_arm=_resolve_arm( + extended["xl"], extended["xn"], "left", extended["xu"] / 10, bool(ka & (1 << 0)) + ), + right_arm=_resolve_arm( + extended["xr"], extended["xo"], "right", extended["xv"] / 10, bool(ka & (1 << 2)) + ), + min_iswap_collision_free_position=extended["xm"] / 10, + max_iswap_collision_free_position=extended["xx"] / 10, + left_x_arm_width=extended["xu"] / 10, + right_x_arm_width=extended["xv"] / 10, + num_xl_channels=extended["kc"], + num_robotic_channels=extended["kr"], + min_raster_pitch_pip_channels=extended["ys"] / 10, + min_raster_pitch_xl_channels=extended["kl"] / 10, + min_raster_pitch_robotic_channels=extended["km"] / 10, + pip_maximal_y_position=extended["ym"] / 10, + left_arm_min_y_position=extended["yu"] / 10, + right_arm_min_y_position=extended["yx"] / 10, + ) + + async def request_initialization_status(self, module: str = "C0") -> bool: + """Whether a module reports itself initialized. + + Every module answers the same query: the master and each subsystem. + + Args: + module: the module to ask. Defaults to the master, which reports for the device. + + Returns: + True if the module is initialized. + """ + resp = await self.send_command(module=module, command="QW", fmt="qw#") + return cast(int, resp["qw"]) == 1 + + # ---------------------------------------- + # Tip types + # ---------------------------------------- + + async def define_tip_needle( + self, + tip_type_table_index: int, + has_filter: bool, + tip_length: float, + maximum_tip_volume: float, + tip_size: TipSize, + pickup_method: TipPickupMethod, + ): + """Write one entry of the device's tip type table. + + The table is volatile. It is written from scratch after every power on, and what a run defines + lasts only while the device stays up. + + Args: + tip_type_table_index: which entry to write, 1 to 99. + has_filter: whether the tip has a filter. + tip_length: how far the tip stands proud of what holds it, in mm. Its total length past its + fitting depth. + maximum_tip_volume: what the tip holds, in uL. The firmware caps it at the channel's + capacity. + tip_size: which collar the tip has, which is how the device identifies it. + pickup_method: whether it is collected from a rack or out of wash liquid. + Raises: + ValueError: If an argument is outside what the command accepts. + """ + length_increments = round(tip_length * 10) + volume_increments = round(maximum_tip_volume * 10) + if not 0 <= tip_type_table_index <= 99: + raise ValueError(f"tip_type_table_index must be between 0 and 99, is {tip_type_table_index}") + if not 1 <= length_increments <= 1999: + raise ValueError(f"tip_length must be between 0.1 and 199.9 mm, is {tip_length}") + if not 1 <= volume_increments <= 56000: + raise ValueError( + f"maximum_tip_volume must be between 0.1 and 5600.0 uL, is {maximum_tip_volume}" + ) + + return await self.send_command( + module="C0", + command="TT", + subsystem="C0", + tt=f"{tip_type_table_index:02}", + tf=has_filter, + tl=f"{length_increments:04}", + tv=f"{volume_increments:05}", + tg=tip_size.value, + tu=pickup_method.value, + ) + + async def get_or_assign_tip_type_index(self, tip: HamiltonTip) -> int: + """The table index this tip is defined at, defining it if it is new to this session. + + Every command that mounts tips names one of these indices, not the tip itself. A tip that has + not been written into the table has no index to name. + + Args: + tip: the tip to look up. + + Returns: + Its index in the device's tip type table. + + Raises: + ValueError: If the table is full. + """ + tip_hash = hash(tip) + if tip_hash not in self._tip_type_indices: + index = len(self._tip_type_indices) + 1 + if index > 99: + raise ValueError("the tip type table is full: 99 tip types have already been defined.") + await self.define_tip_needle( + tip_type_table_index=index, + has_filter=tip.has_filter, + tip_length=tip.total_tip_length - tip.fitting_depth, + # Floored at 1.0 uL so a teaching or probe needle with no capacity registers the way the + # firmware's own non-pipetting tools do. It does not affect pickup, which goes by length + # and collar. + maximum_tip_volume=max(tip.maximal_volume, 1.0), + tip_size=tip.tip_size, + pickup_method=tip.pickup_method, + ) + self._tip_type_indices[tip_hash] = index + return self._tip_type_indices[tip_hash] + + # ---------------------------------------- + # Discovery and initialization + # ---------------------------------------- + + def _check_declared_against(self, discovered: DeviceConfiguration) -> None: + """Raise if what was declared cannot stand for what the device answered. + + Only what decides whether the two are the same kind of device: which features are fitted, how + many channels, and what each arm carries. Identity is left out, since a declaration taken off + one device describes another of the same build and its serial and firmware are its own. So is + geometry, which follows from what is fitted. + + Args: + discovered: what the device answered. + + Raises: + ValueError: If any of those disagree, naming each. + """ + declared = self.declared.get("device") + if declared is None: + return + + differences = [ + f"{name}: declared {getattr(declared, name)!r}, device answers {getattr(discovered, name)!r}" + for name in _DECLARATION_MUST_MATCH + if getattr(declared, name) != getattr(discovered, name) + ] + for side in ("left_arm", "right_arm"): + declared_arm, discovered_arm = getattr(declared, side), getattr(discovered, side) + if (declared_arm is None) != (discovered_arm is None): + differences.append( + f"{side}: declared {'an arm' if declared_arm else 'none'}, " + f"device answers {'an arm' if discovered_arm else 'none'}" + ) + elif declared_arm is not None and discovered_arm is not None: + # What the arm carries, not how big it is: geometry is the frame's, and a declaration off + # another frame of the same build is still a fair description of what is bolted on. + differences += [ + f"{side}.{field.name}: declared {getattr(declared_arm, field.name)!r}, " + f"device answers {getattr(discovered_arm, field.name)!r}" + for field in dataclasses.fields(declared_arm) + if field.name.endswith("_installed") + and getattr(declared_arm, field.name) != getattr(discovered_arm, field.name) + ] + if differences: + raise ValueError( + "the declared configuration does not describe this device:\n " + "\n ".join(differences) + ) + + async def discover(self): + """Read what device is on the other end, and build the subsystems it turns out to have. + + Read-only: nothing moves. Call `initialize` to bring the device up. + """ + self.configuration = await self.request_device_configuration() + # Which device answered, and what it is running. Read into the same configuration the rest of + # discovery fills, so a saved one says where it came from. A device that will not answer keeps + # nothing rather than failing setup: the identity is for telling recordings apart, and nothing + # the driver does depends on it. + try: + self.configuration.serial_number = await self.request_device_serial_number() + except Exception: + logger.warning("the device did not say what it calls itself; leaving its serial unrecorded") + try: + ( + self.configuration.firmware_version, + self.configuration.firmware_date, + ) = await self.request_firmware_version() + except Exception: + logger.warning("the device did not report its firmware; leaving its version unrecorded") + self._check_declared_against(self.configuration) + self._num_channels = self.configuration.num_pip_channels + + # Built for what the device turns out to have, and only if not already there: a caller can + # hand a feature its configuration before setup, and re-running setup keeps it. + if self.configuration.left_arm is not None and self.left_x_arm is None: + self.left_x_arm = XArm(self, side="left") + if self.configuration.right_arm is not None and self.right_x_arm is None: + self.right_x_arm = XArm(self, side="right") + # What an arm carries is what its own configuration bits claim. The firmware requires the two + # drives' bits to be disjoint, so no feature can be on both. + for arm in (self.left_x_arm, self.right_x_arm): + if arm is None: + continue + a = arm.configuration + if a.pip_installed and self.configuration.num_pip_channels > 0 and arm.pipettes is None: + arm.pipettes = Pipettes(self) + if a.head96_installed and arm.head96 is None: + arm.head96 = Head96(self) + if a.head384_installed and arm.head384 is None: + arm.head384 = Head384(self) + if a.iswap_installed and arm.iswap is None: + arm.iswap = iSWAP(self) + if self.configuration.autoload_installed and self.autoload is None: + self.autoload = Autoload(self) + if self.configuration.main_front_cover_monitoring_installed and self.front_cover is None: + self.front_cover = FrontCover(self) + + # Each feature reads its own modules, and they are different modules, so they read at + # once. Both arms run off the same X-drive board, so only one of them asks it. + arms = [arm for arm in (self.left_x_arm, self.right_x_arm) if arm is not None] + # Through the arms, not through the accessors above: those refuse on a device with two, and + # setup has to reach every feature the device has whichever arm holds it. + reading = [] + for arm in arms: + reading.append(arm.discover()) + if arm.pipettes is not None: + reading.append(arm.pipettes.discover()) + if arm.head96 is not None: + reading.append(arm.head96.discover()) + if arm.head384 is not None: + reading.append(arm.head384.discover()) + if arm.iswap is not None: + reading.append(arm.iswap.discover()) + if self.autoload is not None: + reading.append(self.autoload.discover()) + await asyncio.gather(*reading) + # Once the head has said where it sits, an arm can take a declared side panel out of its own + # travel: the offset is what decides how much the panel costs. + for arm in arms: + arm.narrow_travel_for_left_side_panel() + + # Read once, at discovery, and recorded there: a feature that would not say, or reported + # nothing, is left out. + reported = { + "master": self.configuration.firmware_version, + "pipettes": next( + (a.pipettes.configuration.channels[0].firmware_version for a in arms if a.pipettes), None + ), + "x_arm": None if not arms else arms[0].configuration.firmware_version, + "head96": next((a.head96.configuration.firmware_version for a in arms if a.head96), None), + "head384": next((a.head384.configuration.firmware_version for a in arms if a.head384), None), + "iswap": next((a.iswap.configuration.firmware_version for a in arms if a.iswap), None), + "autoload": None if self.autoload is None else self.autoload.configuration.firmware_version, + } + self.firmware = {name: v for name, v in reported.items() if v is not None} + + async def initialize(self, read_timeout: int = 300) -> bool: + """Bring the device itself to a known state. + + This moves it. A device that reports itself uninitialized runs the initialization procedure. + One that reports itself initialized is left where it is, except that the channels are raised + to Z safety. Nothing may move laterally while a channel is low. + + The procedure runs only on a device that is down, and `setup` initializes the features in the + same call. There is deliberately no way to run it on a device that is up, and the procedure + itself is private for the same reason: it takes the features' drives out of reference while + the device goes on reporting itself initialized, and nothing that ran afterwards could tell. + + This is the device-level step only. `setup` is what initializes the features after it. + + Args: + read_timeout: how long to wait for the procedure, in seconds. + + Returns: + Whether the device reported itself already initialized before this ran. + """ + already_initialized = await self.request_initialization_status() + + if not already_initialized: + logger.debug( + "device reports not initialized - running the initialization procedure (up to %d s)", + read_timeout, + ) + await self._pre_initialize(read_timeout=read_timeout) + else: + logger.debug("device reports initialized - raising the channels to Z safety only") + for arm in self.arms: + if arm.pipettes is not None: + # Probing how high the channels reach raises them, so it doubles as that raise, as the + # head's does. + reached = await arm.pipettes.probe_z_max() + c = arm.pipettes.configuration + c.z_range = (c.z_range[0], min(reached.values())) + # A head is retracted whatever its own status says: the retract is what keeps it clear + # of the iSWAP, which shares the arm's X drive and moves while features initialize. + for head in (arm.head96, arm.head384): + if head is not None: + head_z = await head.probe_z_max() + head.configuration.z_range = (head.configuration.z_range[0], head_z) + + return already_initialized + + async def _pre_initialize(self, read_timeout: int = 300): + """Run the device's initialization procedure. + + Leaves the channels at Z safety and their Y drive without a reference: afterwards they report + Y positions outside the range they reach, and the firmware refuses to move them, answering + that the Y drive is not initialized. C0 goes on reporting the device as initialized, so no + status read tells the difference. `setup` brings the features back up; a caller reaching for + this on its own has to initialize them itself. + + The default read timeout is a wide margin over what the command has been measured to take. + + The autoload is a separate unit with an initialize of its own. It is left out of what this + holds, and can be brought up alongside. + + Args: + read_timeout: how long to wait for the procedure, in seconds. + """ + resp = await self.send_command( + module="C0", + command="VI", + subsystem=_FirmwareLock.EVERY_SUBSYSTEM_BUT_THE_AUTOLOAD, + read_timeout=read_timeout, + ) + logger.debug( + "the device initialization procedure has run; the features are not initialized and their " + "drives have no reference until they are" + ) + return resp + + async def _channels_keep_no_reference(self, arm: XArm) -> bool: + """Whether the channels report a Y position none of them can reach. + + A device whose initialization procedure ran without its features being initialized answers + that it is initialized while its channels' Y drive holds no reference. The status read cannot + tell the difference, and a firmware that refuses the next move says so only once something + tries; where the channels say they are does tell, so that is what is asked. + + Warns and returns. Putting the reference back means initializing the channels, which discards + whatever is mounted and moves them, so it is the caller's to do: `pipettes.initialize()`. + + Args: + arm: the arm whose channels to ask. + + Returns: + True if any channel reports outside the Y range that arm reaches. False when nothing could + be read, or no geometry was discovered to judge against: a reading that cannot be taken is + not evidence of a fault. + """ + if arm.pipettes is None or self.configuration is None: + return False + low = ( + self.configuration.left_arm_min_y_position + if arm.side == "left" + else self.configuration.right_arm_min_y_position + ) + high = self.configuration.pip_maximal_y_position + try: + positions = await arm.pipettes.request_y_positions() + except Exception: + logger.warning("could not read where the channels are; taking their reference on trust") + return False + unreachable = [y for y in positions if not low <= y <= high] + if unreachable: + logger.warning( + "%d of %d channels report outside the %.1f to %.1f mm they reach: their Y drive keeps no " + "reference, whatever the device answers about being initialized. `pipettes.initialize()` " + "puts it back, and discards whatever is mounted doing so", + len(unreachable), + len(positions), + low, + high, + ) + return bool(unreachable) + + async def _initialize_arm( + self, arm: XArm, already_initialized: bool, skipped: FrozenSet[str] = frozenset() + ): + """Initialize everything one arm carries, one after another. + + The channels, the iSWAP and the 96-head share the arm's X drive. Initializing one while + another is moving is refused, so they go in the order the legacy routine uses. Two arms have + two drives, and a device with both initializes them alongside each other. + + Args: + arm: the arm whose features to initialize. + already_initialized: whether the device reported itself up before this setup ran. + skipped: which of `pipettes`, `iswap`, `head96` and `head384` to leave as the device has + them. A skipped feature is not raised to safety either, so nothing else may travel + laterally on this arm until something brings it up. + """ + if arm.pipettes is None: + logger.debug("channels: none installed - skipped") + elif "pipettes" in skipped: + logger.debug("channels: initializing them was skipped") + else: + tips = await arm.pipettes.sense_tip_presence() + if already_initialized: + # Said, not acted on: initializing discards whatever is mounted and moves the channels, + # which is not something to do off a reading. The caller decides. + await self._channels_keep_no_reference(arm) + if not already_initialized or any(tips): + logger.debug( + "channels: %d of %d carrying tips, device %s - initializing", + sum(tips), + len(tips), + "was already up" if already_initialized else "has just been homed", + ) + await arm.pipettes.initialize() + else: + logger.debug("channels: already up and nothing mounted - skipped") + # Probing how high the channels reach raises them, so it doubles as the safety raise and + # runs on every setup rather than only the first. + reached = await arm.pipettes.probe_z_max() + # One ceiling for all of them, since one window is what `_check_reachable` holds every + # channel to. The floor is left as it stands: nothing here measures how low they go. + c = arm.pipettes.configuration + c.z_range = (c.z_range[0], min(reached.values())) + + if arm.iswap is not None and "iswap" in skipped: + logger.debug("iSWAP: initializing it was skipped") + elif arm.iswap is not None: + if not await self.request_initialization_status("R0"): + logger.debug("iSWAP reports itself uninitialized - initializing") + await arm.iswap.initialize() + await arm.iswap.park() + + for head, name in ((arm.head96, "head96"), (arm.head384, "head384")): + if head is None: + continue + if name in skipped: + logger.debug("%s: initializing it was skipped", name) + continue + # A STAR deck carries a trash for the 96-head. A head told nowhere else to eject ejects there, + # centred over it, as legacy does. + if ( + name == "head96" + and head.configuration.tip_discard_location is None + and self.deck is not None + and self.deck.has_resource("trash_core96") + ): + head.configuration.tip_discard_location = cast(Head96, head)._position_centred_in( + self.deck.get_resource("trash_core96") + ) + if not await self.request_initialization_status(head.configuration.module): + if head.configuration.tip_discard_location is None: + logger.warning( + "the %s reports itself uninitialized, and there is nowhere configured to eject at. " + "Set %s.configuration.tip_discard_location, or pass it to %s.initialize().", + name, + name, + name, + ) + else: + logger.debug("%s reports itself uninitialized - initializing", name) + await head.initialize() + # Probing how far a head reaches retracts it, so it doubles as the safety retract and + # runs on every setup rather than only the first. The floor is what the drive documents. + retracted = await head.probe_z_max() + head.configuration.z_range = (head.configuration.z_range[0], retracted) + + def format_setup_summary(self) -> str: + """One block describing the device that was found: how it is reached, what firmware every + module runs, whether an autoload is fitted, how many arms there are, and per arm its + dimensions, how many channels it carries and whether it carries a 96-head, a 384-head and an + iSWAP. + + Returns: + A multi-line summary, or a note that setup has not run. + """ + c = self.configuration + if c is None: + return "[Hamilton STAR] not discovered yet" + + firmware = ( + ", ".join(f"{name} {version}" for name, version in self.firmware.items()) or "unknown" + ) + + fitted = [f"{c.instrument_size_slots} slots"] + for number, installed in ((1, c.wash_station_1_installed), (2, c.wash_station_2_installed)): + if installed: + fitted.append(f"wash station {number}") + + autoload = "none" + if c.autoload_installed: + autoload = "installed" + if self.autoload is not None and self.autoload.configuration.autoload_type is not None: + autoload = self.autoload.configuration.autoload_type + + arms = [arm for arm in (self.left_x_arm, self.right_x_arm) if arm is not None] + lines = [ + f"[Hamilton STAR] Connected on {self._describe_link()}", + f" Firmware: {firmware}", + f" Arms: {len(arms)}", + f" Configuration: {', '.join(fitted)}", + f" Autoload: {autoload}", + ] + for arm in arms: + a = arm.configuration + # Read through the feature, not the arm's own bit, so the summary cannot report channels + # the driver did not build. The two disagree only on a device whose configuration says both. + channels = "none" + if arm.pipettes is not None and a.pip_installed: + channels = f"{c.num_pip_channels} ({'1000uL' if c.pip_type_1000ul else '300uL'})" + elif arm.pipettes is None and a.pip_installed: + channels = "none, but this arm reports the module installed" + heads = [] + for head, installed, label in ( + (arm.head96, a.head96_installed, "96-head"), + (arm.head384, a.head384_installed, "384-head"), + ): + described = "none" + if installed: + described = "installed" + if head is not None and head.configuration.head_type is not None: + described = head.configuration.head_type + heads.append(f"{label}: {described}") + iswap = "none" + if a.iswap_installed: + iswap = f"{'wide' if c.iswap_gripper_wide else 'small'} gripper" + lines.append( + f" {arm.side}: {a.model}, {a.width} mm wide, " + f"travel {_range(a.x_range)}, workspace {_range(a.workspace_x_range)}" + ) + lines.append(f" channels: {channels} | {' | '.join(heads)} | iSWAP: {iswap}") + if sum(arm.configuration.pip_installed for arm in arms) > 1: + lines.append(" (the device reports one channel count for the device, not per arm)") + return "\n".join(lines) + + # ---------------------------------------- + # Configuration system + # ---------------------------------------- + + def _saved_configuration(self) -> Dict[str, Any]: + """Every configuration this device holds, shaped as the device is. + + Walked rather than listed: the device's own configuration, then whatever each arm turns out to + carry under the side carrying it, then whatever is fitted to the device itself. Nothing here + fixes how many of anything a device may have, so one that grows a second head is saved without + this having to change. + + Returns: + What `save_configuration` writes. + + Raises: + RuntimeError: If nothing has been read off the device yet. + """ + if self.configuration is None: + raise RuntimeError("nothing has been read off this device; call `setup` first") + + saved: Dict[str, Any] = {"device": to_jsonable(self.configuration), "arms": {}} + for arm in self.arms: + carried = { + name: to_jsonable(feature.configuration) + for name, feature in ( + ("pipettes", arm.pipettes), + ("head96", arm.head96), + ("head384", arm.head384), + ("iswap", arm.iswap), + ) + if feature is not None + } + if carried: + saved["arms"][arm.side] = carried + if self.autoload is not None: + saved["autoload"] = to_jsonable(self.autoload.configuration) + return saved + + def save_configuration(self, path: str, indent: Optional[int] = 2) -> None: + """Write what this device reported to a file, to be simulated from later. + + Args: + path: where to write it. + indent: how far to indent the JSON, or None to write it on one line. + + Raises: + RuntimeError: If nothing has been read off the device yet. + """ + with open(path, "w", encoding="utf-8") as f: + json.dump(self._saved_configuration(), f, indent=indent) + + # ---------------------------------------- + # Resource model + # ---------------------------------------- + + async def _create_capability_resources(self) -> None: + """Put what the device carries on the deck, where it is. + + Read once, at setup: each feature's resource is placed at the position read back from it. + A resource already on the deck is reused, and repeated setups do not duplicate it. + """ + if self.deck is None: + return + for arm in (self.left_x_arm, self.right_x_arm): + if arm is None: + continue + a = arm.configuration + if a.width is None: + logger.warning("the %s X-arm reported no width, so it is not modelled", arm.side) + continue + arm.resource = self.deck.get_or_create_x_arm( + name=f"{arm.side}_x_arm", + x=await arm.request_position(), + size_x=a.size_x, + reference_point_from_left=a.reference_point_from_left, + model=a.model, + ) + await self._create_pipette_resources() + await self._create_autoload_resource() + await self._create_head_resources() + await self._create_iswap_resource() + + async def _create_pipette_resources(self) -> None: + """Put a resource on the arm for each pipetting channel, where it is. + + One resource per channel, not one for the block: the channels share the arm's X but each has + its own Y and Z. They are children of the arm's resource, as the 96-head is. Channels already + on the arm are reused, and repeated setups do not duplicate them. + + Each carries a `TipMountingShaft` at its lower end. A collected tip becomes a child of the + shaft, not of the channel. + """ + if self.deck is None: + return + arm = next((a for a in self.arms if a.pipettes is not None), None) + if arm is None or arm.pipettes is None or arm.resource is None: + return + + # One per channel the device reported at discovery, not a count assumed here. + c = arm.pipettes.configuration + arm.pipettes.resources = [] + + # Read before the resources exist, as the arm's own creation reads before it makes one. The + # reads record nothing while there is nothing to record on, and a simulated device answers + # where it powered up rather than from a resource that has not been placed yet. + ys = await arm.pipettes.request_y_positions() + zs = await arm.pipettes._unchecked_fw_request_lowest_z_positions() + + for channel in range(len(c.channels)): + name = f"pipette_channel_{channel}" + resource = next((r for r in arm.resource.children if r.name == name), None) + if resource is None: + width = c.channels[channel].width + if width is None: + logger.warning("channel %d reported no width, so it is not modelled", channel) + continue + resource = Resource( + name=name, + size_x=width, + size_y=width, + size_z=c.channel_size_z, + category="pipette_channel", + model="hamilton_star_pipette_channel", + ) + # Along X a channel sits at the arm's own reference point, so its centre lands there. The + # arm's reference point is not the middle of it: the part is wider than the width the drive + # reports, and reaches further right than the point it counts from. + anchor = resource.get_anchor(x=arm.pipettes.configuration.x_reference_anchor) + arm.resource.assign_child_resource( + resource, + location=Coordinate(arm.configuration.reference_point_from_left - anchor.x, 0.0, 0.0), + ) + arm.pipettes.add_tip_mounting_shaft(resource) + arm.pipettes.resources.append(resource) + + # Seat each one where the reads above found it. Recorded here rather than by the reads + # themselves, because the resources they would have recorded on did not exist yet. + for channel in range(len(arm.pipettes.resources)): + arm.pipettes.update_location_by_reference_point(channel, y=ys[channel], z=zs[channel]) + + async def _create_head_resources(self) -> None: + """Put each head on the arm it rides, where it is along Y. + + A child of the arm's resource, not of the deck. It then follows the arm in X with nothing + keeping the two in step. A head already on the arm is reused, and repeated setups do not + duplicate it. + + Raises: + RuntimeError: If a head's X offset was not read, leaving its position across the arm + unknown. + """ + if self.deck is None: + return + for arm in self.arms: + if arm.resource is None: + continue + for head, name, label, build in ( + (arm.head96, "head96", "96-head", head96_pipette), + (arm.head384, "head384", "384-head", head384_pipette), + ): + if head is None: + continue + c = head.configuration + # Where the head is, read before it has a resource to read from: the drive answers on a + # device, and a simulated one falls back to where it rests rather than reporting back the + # placeholder position it is about to be given. + y, z = await head.request_y_position(), await head.request_z_position() + existing = next((child for child in arm.resource.children if child.name == name), None) + resource = existing if isinstance(existing, NChannelPipette) else None + if resource is None: + if c.x_offset is None: + raise RuntimeError( + f"the {label}'s X offset was not read; have you called `star.setup()`?" + ) + # The definition, not a bare resource: it carries a mounting shaft per channel, which + # is what a collected tip becomes a child of. + resource = build(name=name, size_z=c.body_size_z) + # Channel A1 sits `x_offset` left of the point the drive tracks the arm by, and the arm + # is located by its own left edge, so A1 lands that far left of the reference point. What + # is placed is the head, whose own A1 stands inside it, so that inset comes off too - the + # head is measured from A1 the way a channel is measured from its axis. Y is set from the + # drive below. + arm.resource.assign_child_resource( + resource, + location=Coordinate( + arm.configuration.reference_point_from_left - c.x_offset - resource.reference_point.x, + 0.0, + 0.0, + ), + ) + head.resource = resource + head.update_location_by_reference_point(y=y, z=z) + + async def _create_iswap_resource(self) -> None: + """Put each iSWAP's rotation drive on the arm it rides, where it is. + + A child of the arm's resource, not of the deck, so it follows the arm in X with nothing keeping + the two in step. One already on the arm is reused, and repeated setups do not duplicate it. + + The drive is modelled, not the gripper: where the gripper is follows from the joint state + rather than from where the drive sits, and `request_pose` is what resolves that. + + Raises: + RuntimeError: If the drive's X offset was not read, leaving its position across the arm + unknown. + """ + if self.deck is None: + return + for arm in self.arms: + if arm.iswap is None or arm.resource is None: + continue + iswap = arm.iswap + c = iswap.configuration + # Read before it has a resource to read into, as the head's are. + y = await iswap.rotation_drive_request_y_position() + z = await iswap.rotation_drive_request_z_position() + angle = await iswap.rotation_drive_request_angle() + existing = next( + (child for child in arm.resource.children if child.name == "iswap_head"), None + ) + resource = existing if isinstance(existing, iSWAPHead) else None + if resource is None: + if c.rotation_drive_x_offset is None: + raise RuntimeError( + "the iSWAP rotation drive's X offset was not read; have you called `star.setup()`?" + ) + # How tall to model the column: nothing reports it, and what the device shows is its top + # standing level with the tops of the channel bodies when the drive is fully retracted. + # Both heights are taken on the deck, so neither needs the arm taking out. + tops = [ + ch.get_location_wrt(self.deck).z + ch.get_size_z() + for ch in (arm.pipettes.resources if arm.pipettes is not None else []) + if ch.location is not None + ] + retracted_base = ( + c.z_increments_to_mm(c.z_range_increments[1]) + + c.rotation_drive_z_offset_above_finger + + ROTATION_DRIVE_COLUMN_ABOVE_REPORTED_Z + ) + resource = iswap_head( + name="iswap_head", + diameter=c.rotation_drive_diameter, + size_z=round(max(tops) - retracted_base, 1) if tops else c.rotation_drive_size_z, + ) + # The drive sits `rotation_drive_x_offset` left of the point the drive tracks the arm by, + # and the arm is located by its own left edge, so it lands that far left of the reference + # point. + anchor = resource.reference_point + arm.resource.assign_child_resource( + resource, + location=Coordinate( + arm.configuration.reference_point_from_left - c.rotation_drive_x_offset - anchor.x, + 0.0, + 0.0, + ), + ) + iswap.resource = resource + iswap.link_1, iswap.gripper = self._create_iswap_arm(resource, c) + iswap.update_location_by_reference_point(y=y, z=z) + iswap.rotation_drive_update_angle(angle) + iswap.wrist_drive_update_angle(await iswap.wrist_drive_request_angle()) + # And how far the jaws stand open, which the read records, so the model starts in step with + # the arm rather than at whatever width the gripper was built holding. + await iswap.gripper_request_width() + + @staticmethod + def _create_iswap_arm( + resource: iSWAPHead, c: iSWAPConfiguration + ) -> Tuple[Optional[LinkBody], Optional[MechanicalGripper]]: + """Hang the arm off the carriage: one link, and the gripper it carries. + + Link 1 turns on the rotation drive; the gripper turns on the wrist that link 1 carries, so it + is a child of link 1 and its angle is measured from it. Where each points is written by + `rotation_drive_update_angle` and `wrist_drive_update_angle`. What is already there is reused. + + Args: + resource: the carriage they hang from. + c: the arm's configuration, which carries both lengths. + + Returns: + The link and the gripper, or `(None, None)` if the arm did not report its lengths. + """ + if c.link_1_length is None or c.tool_length is None: + logger.warning("the iSWAP reported no link lengths, so its arm is not modelled") + return None, None + link_1 = next((child for child in resource.children if isinstance(child, LinkBody)), None) + if link_1 is None: + link_1 = iswap_link_1(name="iswap_link_1", length=c.link_1_length) + # A member's origin is a corner, so it is placed by where its joint has to land: the joint + # goes on the drive's reference point, and the corner falls wherever that puts it. + resource.assign_child_resource( + link_1, location=resource.reference_point - link_1.proximal_joint + ) + gripper = next( + (child for child in link_1.children if isinstance(child, MechanicalGripper)), None + ) + if gripper is None: + # How far the jaws travel is the gripper drive's own window, converted, rather than a + # measurement of the fingers: what the drive accepts is what the jaws do. + gripper = iswap_gripper( + name="iswap_gripper", + # The Z drive is calibrated to the finger plane and reports its own bottom, so the grip + # centre is that far below the wrist the gripper hangs from. + tool_center_point=Coordinate(c.tool_length, 0.0, -c.rotation_drive_z_offset_above_finger), + jaw_range=( + c.gripper_increments_to_mm(c.gripper_range_increments[0]), + c.gripper_increments_to_mm(c.gripper_range_increments[1]), + ), + # And standing where an initialized gripper stands, so the model does not start out + # claiming a width nothing has read. Setup reads the jaws straight after and records it. + jaw_width=( + c.gripper_increments_to_mm(c.gripper_drive_predefined_increments["home"]) + if c.gripper_drive_predefined_increments + else None + ), + ) + # Likewise: the gripper's own joint lands on the wrist, which is link 1's far joint. + link_1.assign_child_resource( + gripper, location=cast(Coordinate, link_1.distal_joint) - gripper.proximal_joint + ) + return link_1, gripper + + async def _create_autoload_resource(self) -> None: + """Put the autoload's sled on the deck, where it is, and the tray it draws carriers from. + + The tray is placed from the deck's features, not read off the device. It is bolted to the + device and has no drive to report its position. + """ + if self.autoload is None or self.deck is None: + return + x = await self.autoload.request_x_position() + self.autoload.resource = self.deck.get_or_create_autoload_sled( + name="autoload_sled", + x=x, + reference_point_from_left=self.autoload.configuration.reference_point_from_sled_left_edge, + ) + self.autoload.update_location_by_reference_point(x) + self.deck.get_or_create_autoload_loading_tray(name="autoload_loading_tray") diff --git a/pylabrobot/hamilton/star/driver/master_tests.py b/pylabrobot/hamilton/star/driver/master_tests.py new file mode 100644 index 00000000000..4614ebf6023 --- /dev/null +++ b/pylabrobot/hamilton/star/driver/master_tests.py @@ -0,0 +1,434 @@ +import contextlib +import dataclasses +import json +import pathlib +import tempfile +import unittest +import unittest.mock +from typing import List, cast + +import pylabrobot.hamilton.star.driver.master as master +import pylabrobot.hamilton.star.driver.simulator as simulator +from pylabrobot.hamilton.star.conftest import BARE_X_ARM +from pylabrobot.hamilton.star.device import RECORDING_STAR, RECORDING_STAR_HEAD384 +from pylabrobot.hamilton.star.driver.configuration import ( + DeviceConfiguration, + read_configuration, + to_jsonable, +) +from pylabrobot.hamilton.star.driver.features.autoload import Autoload +from pylabrobot.hamilton.star.driver.features.head96 import Head96 +from pylabrobot.hamilton.star.driver.simulator import STARSimulationDriver +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.hamilton import STARDeck + +# The device this package ships a recording of, read through the one reader there is: tests need a +# device to start from, and this is the one they stand in for. +RECORDED_DEVICE = cast(DeviceConfiguration, read_configuration(RECORDING_STAR)["device"]) + + +def declaring(**parts: object) -> str: + """The shipped recording with parts swapped out, written where it can be read back. + + A declaration is read from a file and nothing else, so a test that needs a device no recording + describes writes one. Everything not named here stays as the recorded STAR has it. + + Args: + parts: `device` for the device itself, or a feature name for something the left arm carries. + + Returns: + The path it was written to. + """ + tree = json.loads(pathlib.Path(RECORDING_STAR).read_text()) + for name, part in parts.items(): + if name == "device": + tree["device"] = to_jsonable(part) + else: + tree["arms"]["left"][name] = to_jsonable(part) + written = pathlib.Path(tempfile.mkdtemp()) / "declared.json" + written.write_text(json.dumps(tree)) + return str(written) + + +def declaring_a_device( + channels: int, head96: bool, head384: bool, iswap: bool, autoload: bool +) -> str: + """A declaration for a STAR fitted with exactly these, written where it can be read back. + + Built from the shipped recording so every value is one a device answered: only what is fitted + changes. The bits and the features are set together, since the bits are what the driver builds + from and the features are what it builds. + + Args: + channels: how many pipetting channels, 0 for none. + head96: whether a 96-head is fitted. + head384: whether a 384-head is fitted. + iswap: whether an iSWAP is fitted. + autoload: whether an autoload is fitted. + + Returns: + The path it was written to. + """ + tree = json.loads(pathlib.Path(RECORDING_STAR).read_text()) + device, arm, carried = tree["device"], tree["device"]["left_arm"], tree["arms"]["left"] + + device["num_pip_channels"] = channels + arm["pip_installed"] = channels > 0 + device["head96_installed"] = arm["head96_installed"] = head96 + device["head384_installed"] = arm["head384_installed"] = head384 + device["kb_iswap_installed"] = arm["iswap_installed"] = iswap + device["autoload_installed"] = autoload + + if head384: + # A 384-head is declared the way every other feature here is, out of the recording of a device + # fitted with one. Nothing stands in for a feature a declaration claims but does not describe. + carried["head384"] = json.loads(pathlib.Path(RECORDING_STAR_HEAD384).read_text())["arms"][ + "left" + ]["head384"] + for name, fitted in (("head96", head96), ("iswap", iswap), ("pipettes", channels > 0)): + if not fitted: + carried.pop(name, None) + if not autoload: + tree.pop("autoload", None) + if channels > 0: + # One recorded channel stands for all of them: they are the same part. + recorded = carried["pipettes"]["channels"] + carried["pipettes"]["channels"] = (recorded * channels)[:channels] + + written = pathlib.Path(tempfile.mkdtemp()) / "declared.json" + written.write_text(json.dumps(tree)) + return str(written) + + +class TestXArm(unittest.IsolatedAsyncioTestCase): + """`STARDriver.x_arm` is the single-arm shorthand, and its job is which error it raises.""" + + async def test_before_setup(self): + with self.assertRaises(RuntimeError): + STARSimulationDriver(deck=STARDeck(), declared_configuration_json=RECORDING_STAR).x_arm + + async def test_one_arm(self): + star = STARSimulationDriver(deck=STARDeck(), declared_configuration_json=RECORDING_STAR) + await star.setup() + self.assertIs(star.x_arm, star.left_x_arm) + + async def test_two_arms(self): + both = dataclasses.replace( + RECORDED_DEVICE, + right_arm=BARE_X_ARM, + ) + star = STARSimulationDriver( + deck=STARDeck(), + declared_configuration_json=declaring(device=both), + ) + await star.setup() + with self.assertRaises(ValueError): + star.x_arm + + async def test_no_arms(self): + neither = dataclasses.replace(RECORDED_DEVICE, left_arm=None, right_arm=None) + star = STARSimulationDriver( + deck=STARDeck(), + declared_configuration_json=declaring(device=neither), + ) + await star.setup() + with self.assertRaises(ValueError): + star.x_arm + + +class TestSimulation(unittest.IsolatedAsyncioTestCase): + """A simulated device has no firmware to ask, so the resource model is all it can answer from.""" + + async def test_simulation_needs_a_deck(self): + with self.assertRaises(ValueError): + STARSimulationDriver() + + +def keys_no_field_reads(saved: dict, configuration: object) -> List[str]: + """What a saved configuration holds that reading it back leaves out, nested ones included. + + Args: + saved: the configuration as JSON holds it. + configuration: what reading it back built. + + Returns: + Every key with no field to read it into, as a dotted path from `saved`. + """ + fields = {field.name: field for field in dataclasses.fields(configuration)} # type: ignore[arg-type] + dropped = [key for key in saved if key not in fields] + for key, value in saved.items(): + nested = getattr(configuration, key, None) + if key in fields and isinstance(value, dict) and dataclasses.is_dataclass(nested): + dropped += [f"{key}.{inner}" for inner in keys_no_field_reads(value, nested)] + return dropped + + +class TestRecordings(unittest.TestCase): + """What ships under recordings/ is read back whole: no key a configuration no longer has, and no + window left empty for a head to be built on.""" + + def test_every_key_is_a_field(self): + for path in sorted(pathlib.Path(RECORDING_STAR).parent.glob("*.json")): + saved = json.loads(path.read_text(encoding="utf-8")) + read = read_configuration(str(path)) + sections = [("device", saved["device"], read["device"])] + for side, carried in saved.get("arms", {}).items(): + sections += [ + (f"arms.{side}.{name}", value, read["arms"][side][name]) + for name, value in carried.items() + ] + if "autoload" in saved: + sections.append(("autoload", saved["autoload"], read["autoload"])) + for where, value, configuration in sections: + with self.subTest(recording=path.name, section=where): + self.assertEqual(keys_no_field_reads(value, configuration), []) + + def test_every_head_has_a_z_range(self): + for path in sorted(pathlib.Path(RECORDING_STAR).parent.glob("*.json")): + for side, carried in read_configuration(str(path))["arms"].items(): + for name in ("head96", "head384"): + if name in carried: + with self.subTest(recording=path.name, head=name): + self.assertIsNotNone(carried[name].z_range) + + +class TestDeclaredConfiguration(unittest.IsolatedAsyncioTestCase): + """A simulated device answers what it was declared to be, and discovery checks what it answered + against the declaration as it does on a physical device.""" + + async def test_setup_checks_what_the_device_answered(self): + star = STARSimulationDriver(deck=STARDeck(), declared_configuration_json=RECORDING_STAR) + check = master.STARDriver._check_declared_against + with unittest.mock.patch.object( + master.STARDriver, "_check_declared_against", autospec=True, side_effect=check + ) as checked: + await star.setup() + checked.assert_called_once() + self.assertIsNot(star.configuration, star.declared["device"]) + + async def test_a_device_that_is_not_what_was_declared_is_refused(self): + star = STARSimulationDriver(deck=STARDeck(), declared_configuration_json=RECORDING_STAR) + star.simulated_configuration = dataclasses.replace( + star.simulated_configuration, autoload_installed=False + ) + with self.assertRaisesRegex(ValueError, "autoload_installed"): + await star.setup() + + +class TestRepeatedSetup(unittest.IsolatedAsyncioTestCase): + """Setup is repeatable: a second one re-reads the device over the link the first one opened.""" + + async def test_a_second_setup_does_not_open_the_link_again(self): + star = STARSimulationDriver(deck=STARDeck(), declared_configuration_json=RECORDING_STAR) + with unittest.mock.patch.object(star, "_open", wraps=star._open) as opened: + await star.setup() + await star.setup() + self.assertEqual(opened.call_count, 1) + + +# What each initialization step is called in the sequences below, and where it is defined. The simulated +# classes override some of them, so each is recorded where a simulated run would reach it. +MOVING_STEPS = [ + (simulator.STARSimulationDriver, "_pre_initialize", "VI device"), + (simulator.Pipettes, "probe_z_max", "ZA channels to safe Z"), + (simulator.SimulatedPipettes, "initialize", "DI channels"), + (simulator.SimulatedISWAP, "initialize", "FI iSWAP"), + (simulator.iSWAP, "park", "iSWAP park"), + (simulator._SimulatedHead, "initialize", "EI 96-head"), + (simulator._SimulatedHead, "probe_z_max", "EV 96-head probe and retract"), + (simulator.SimulatedAutoload, "initialize", "II autoload"), + (Autoload, "park", "autoload park"), +] + + +@contextlib.contextmanager +def recorded_moves(): + """Record every setup step that moves the device, in the order setup runs them.""" + moves: List[str] = [] + with contextlib.ExitStack() as stack: + for owner, name, label in MOVING_STEPS: + real = owner.__dict__[name] + + def wrap(real=real, label=label): + async def recorded(self, *args, **kwargs): + moves.append(label) + return await real(self, *args, **kwargs) + + return recorded + + stack.enter_context(unittest.mock.patch.object(owner, name, wrap())) + yield moves + + +class TestSetupSequence(unittest.IsolatedAsyncioTestCase): + """Setup moves the device, and the order it moves it in is what keeps the arm's modules from + driving into each other. It follows the legacy routine: the channels reach Z safety and the head + retracts before the iSWAP moves on the shared left X-drive, and the head is only initialized once + its own status has been asked. The 96-head retract runs on every setup, since that retract is + what keeps it clear.""" + + async def run_setup( + self, device_up: bool, head_up: bool, eject_position: bool, trash96: bool = True + ) -> List[str]: + star = simulator.STARSimulationDriver( + deck=STARDeck(with_trash96=trash96), + initialized=device_up, + declared_configuration_json=RECORDING_STAR, + ) + star.initialized["H0"] = head_up + cast(Head96, star.head96).configuration.tip_discard_location = ( + Coordinate(-263.8, 108.3, 200.0) if eject_position else None + ) + with recorded_moves() as moves: + await star.setup() + return list(moves) + + async def test_everything_already_up(self): + self.assertEqual( + await self.run_setup(device_up=True, head_up=True, eject_position=True), + [ + "ZA channels to safe Z", + "EV 96-head probe and retract", + "ZA channels to safe Z", + "iSWAP park", + "EV 96-head probe and retract", + "II autoload", + "autoload park", + ], + ) + + async def test_head_down_on_a_device_that_is_up(self): + self.assertEqual( + await self.run_setup(device_up=True, head_up=False, eject_position=True), + [ + "ZA channels to safe Z", + "EV 96-head probe and retract", + "ZA channels to safe Z", + "iSWAP park", + "EI 96-head", + "EV 96-head probe and retract", + "II autoload", + "autoload park", + ], + ) + + async def test_head_down_with_nowhere_to_eject(self): + """It is still retracted, because that is what keeps it clear of the iSWAP; it is just not + initialized, since initializing throws off whatever is mounted and there is nowhere to drop it: + no location of its own, and a deck with no trash for it.""" + self.assertEqual( + await self.run_setup(device_up=True, head_up=False, eject_position=False, trash96=False), + [ + "ZA channels to safe Z", + "EV 96-head probe and retract", + "ZA channels to safe Z", + "iSWAP park", + "EV 96-head probe and retract", + "II autoload", + "autoload park", + ], + ) + + async def warnings_after_setup( + self, head_up: bool, eject_position: bool, trash96: bool = True + ) -> List[str]: + with unittest.mock.patch.object(master.logger, "warning") as warning: + await self.run_setup( + device_up=True, head_up=head_up, eject_position=eject_position, trash96=trash96 + ) + return [call.args[0] % call.args[1:] for call in warning.call_args_list] + + async def test_a_head_told_nowhere_to_eject_ejects_at_the_decks_trash(self): + """A STAR deck carries a trash for the 96-head, and a head with no eject location of its own is + initialized over it, centred, as legacy does.""" + star = simulator.STARSimulationDriver( + deck=STARDeck(), initialized=True, declared_configuration_json=RECORDING_STAR + ) + star.initialized["H0"] = False + head = cast(Head96, star.head96) + assert star.deck is not None + + with recorded_moves() as moves: + await star.setup() + + self.assertIn("EI 96-head", moves) + self.assertEqual( + head.configuration.tip_discard_location, + head._position_centred_in(star.deck.get_resource("trash_core96")), + ) + + async def test_a_feature_left_down_is_named_once_setup_has_run(self): + warnings = await self.warnings_after_setup(head_up=False, eject_position=False, trash96=False) + self.assertTrue( + any("setup finished with these not initialized" in w and "Head96" in w for w in warnings), + warnings, + ) + + async def test_nothing_is_named_when_everything_came_up(self): + warnings = await self.warnings_after_setup(head_up=True, eject_position=True) + self.assertFalse( + any("setup finished with these not initialized" in w for w in warnings), warnings + ) + + async def test_device_not_up(self): + """The device procedure homes every drive, so nothing is raised beforehand. It runs alone: + the autoload is its own unit, but bringing it up alongside puts a C0 command and an I0 command + in flight together, and the device has been seen answering one of them with the id of the + other's predecessor. It comes up with the rest of the features afterwards, as it does in + legacy.""" + self.assertEqual( + await self.run_setup(device_up=False, head_up=False, eject_position=True), + [ + "VI device", + "DI channels", + "ZA channels to safe Z", + "FI iSWAP", + "iSWAP park", + "EI 96-head", + "EV 96-head probe and retract", + "II autoload", + "autoload park", + ], + ) + + +class TestEveryConfiguration(unittest.IsolatedAsyncioTestCase): + """Every combination of what a STAR can be fitted with, and what each one builds. + + What the declaration says is fitted is what the driver has to end up carrying, and nothing else. + A feature built from a bit that was not set, or missing where one was, is the failure this is + looking for. + """ + + async def test_each_combination_builds_exactly_what_it_declares(self): + for channels in (4, 8, 12, 16): + for head96 in (False, True): + for head384 in (False, True): + for iswap in (False, True): + for autoload in (False, True): + with self.subTest( + channels=channels, + head96=head96, + head384=head384, + iswap=iswap, + autoload=autoload, + ): + driver = STARSimulationDriver( + deck=STARDeck(), + declared_configuration_json=declaring_a_device( + channels=channels, + head96=head96, + head384=head384, + iswap=iswap, + autoload=autoload, + ), + ) + await driver.setup() + arm = driver.x_arm + self.assertEqual(arm.pipettes is not None, channels > 0) + if channels > 0: + self.assertEqual(driver.num_channels, channels) + self.assertEqual(arm.head96 is not None, head96) + self.assertEqual(arm.head384 is not None, head384) + self.assertEqual(arm.iswap is not None, iswap) + self.assertEqual(driver.autoload is not None, autoload) diff --git a/pylabrobot/hamilton/star/driver/recordings/README.md b/pylabrobot/hamilton/star/driver/recordings/README.md new file mode 100644 index 00000000000..4e2a7cec5e1 --- /dev/null +++ b/pylabrobot/hamilton/star/driver/recordings/README.md @@ -0,0 +1,147 @@ +# Device recordings + +What a real STAR reported about itself, kept as data so a simulated one can stand in for it. + +**None of the three shipped here says which device it came from.** Only the STAR was read off a +device at all, and that reading predates `C0 RI`, so its `serial_number` is null and the STARlet and +STARplus inherit its firmware strings along with everything else derived from it. The first +recording taken with `save_configuration` on a real device will carry both, and is worth taking for +that reason alone. A device that will not answer `C0 RI` leaves the field null too, with a warning +at setup - so a null serial means either an old recording or a device that would not say, and the +file cannot tell you which. + +A recording is written by the driver, not by hand: + +```python +star = STAR(driver=STARDriver()) +await star.setup() +star.driver.save_configuration("star_legacy_2021_8ch_head96_autoload1D.json") +``` + +and read back through `declared_configuration_json`, which is the one way a configuration is read +from a file: + +```python +star = STAR(simulation=True, declared_configuration_json="my_star.json") +await star.setup() +``` + +It means two different things depending on what is on the other end. A **simulated** device answers +as the declaration says, standing in for the device it records. A **physical** device answers for +itself, and the declaration is cross-checked against it: setup refuses if the two disagree on which +features are fitted, how many channels, or what each arm carries. Identity and geometry are not +compared, so a declaration taken off one device still describes another of the same build. + +Five recordings ship with this package. `STAR`, `STARLet` and `STARPlus` hand the 96-head one for +their frame to a **simulated** device when nothing else is declared; the two 384-head ones are +declared by name. A physical device is never given any of them: + +| frame | head | recording | +|---|---|---| +| STAR | 96 | `star_legacy_2021_8ch_head96_autoload1D.json` | +| STARlet | 96 | `starlet_legacy_2021_8ch_head96_autoload1D.json` | +| STARplus | 96 | `starplus_legacy_2021_8ch_head96.json` | +| STAR | 384 | `star_legacy_2021_8ch_head384_autoload1D.json` | +| STARlet | 384 | `starlet_legacy_2021_8ch_head384_autoload1D.json` | + +Only the first was read off a device. The others are derived from it. The two other frames move +everything the right-hand end of the deck sets by the difference in deck length, and leave +everything belonging to the arm itself where it is; the STARplus is derived without an autoload, +because the recorded sled does not travel far enough to reach that frame's last track. The two +384-head ones swap the head the arm carries, flipping the bits that say which head is fitted and +putting the 384-head's documented configuration where the 96-head's reading was. Replace any of +them wholesale with a recording when there is a device to take one from. + +A 384-head device is what the 384-head's own configuration is declared through, rather than a +fragment file: it is a device that exists, so it is described the way every other device here is. +Field 1 of its name is carried over from the STAR it was derived from, since the convention below +takes that field from a 96-head and one of these has none. + +## What is in one + +Shaped as the device is, so nothing fixes how many of anything a device may have: + +```json +{ + "device": { ... }, + "arms": { "left": { "pipettes": {...}, "head96": {...}, "iswap": {...} } }, + "autoload": { ... } +} +``` + +The device's own configuration is what the master answered. Each feature sits under the arm that +carries it; a feature fitted to the device rather than to an arm sits beside `arms`. A device that +grows a second head is one more entry, with no change to the format. + +## What is not + +- **Documented defaults.** Values the driver holds because a drive documents them, not because a + device reported them, stay in the code. The 384-head is the standing example: no 384-head has + been read off any device, so its offsets and drive defaults live in `simulator.py`. + + Some fields break this rule knowingly: the iSWAP's + `rotation_drive_predefined_z_positions_increments`, the 96-head's + `predefined_y_positions_increments` and `predefined_z_positions_increments`, and the whole of + the 384-head, which no device has been read for at all. Each carries its drive's documented + defaults rather than a reading, because a simulated device has to answer where it parks and + nothing else here says. They are in the file rather than the code so that the + first device read overwrites a value in the same place, instead of leaving a constant to be + found and deleted. Until then they are documented defaults sitting where readings belong, and + the device they describe may hold something else. +- **Where the device is.** Rest positions, probed Z heights, which track the autoload sits on. That + is state, not configuration, and it changes every run. + +## Naming + +A convention to follow, not something the driver does. `save_configuration` writes exactly the path +it is given and nothing else: it chooses no directory, invents no name, and appends no extension. +Where a recording goes and what it is called are yours, so give it a full path. + +Six fields, in the order below, joined by `_`. Every one can be read off the device or the file it +wrote, so a name can always be worked out from what you have. Two recordings of the same class of +device then sort together, and a reader can tell them apart without opening either. + +| field | value | read from | +|---|---|---| +| 0 | `star`, `starlet`, `starplus` | `device.instrument_size_slots` (54, 30, 76) | +| 1 | `legacy`, `FM` | `arms..head96.instrument_type` | +| 2 | build year, `YYYY` | `device.firmware_date` | +| 3 | `8ch`, `12ch`, `16ch` | `device.num_pip_channels` | +| 4 | `head96`, `head384` | `device.head96_installed` / `device.head384_installed` | +| 5 | `autoload1D`, `autoload2D` | `autoload.autoload_type` | + +Leave a field out only when the device has none of that thing: a device with no autoload ends at +field 4. Do not reorder, and do not abbreviate a field to make a name shorter, because the position +is what carries the meaning. + +The device this package ships a recording of reads as: + +``` +star_legacy_2021_8ch_head96_autoload1D.json +``` + +### What a recording cannot yet tell you + +**Field 1 is not confirmed.** `instrument_type` is decoded from the third of the 96-head's hardware +tokens, and whether that token is populated on every build is unverified. A device reading `legacy` +may be one that does not report the token rather than one that is legacy. Confirm against the +device before trusting the field on an FM. + +**Three stored tables are documented defaults rather than readings.** The iSWAP's +`rotation_drive_predefined_z_positions_increments` and the 96-head's two predefined tables were +never read off a device, for the reason given above. Each has a reader that records what came +back - `rotation_drive_request_predefined_z_positions`, `request_predefined_y_positions` and +`request_predefined_z_positions` - so one call apiece on a device makes them real. + +**Two more identity facts are still unread.** A recording says which device answered and what it +was running, through `device.serial_number` and `device.firmware_version`. Two things the older +driver could read are not implemented here: + +| fact | how it is read | why it might matter | +|---|---|---| +| download date | `C0 RO` | another candidate for field 2, though whether it dates the build or the last firmware download is unverified | +| electronic board type | `C0 QB` | four board generations are known, and which one a device has may bear on the encodings that apply to it | + +`device.serial_number` is what the device answers, not the USB serial a driver may have picked it +off the bus with. It is null in the recording shipped here: that device's serial was never read, +and it is not ours to invent. diff --git a/pylabrobot/hamilton/star/driver/recordings/star_legacy_2021_8ch_head384_autoload1D.json b/pylabrobot/hamilton/star/driver/recordings/star_legacy_2021_8ch_head384_autoload1D.json new file mode 100644 index 00000000000..cfeab1d6a0a --- /dev/null +++ b/pylabrobot/hamilton/star/driver/recordings/star_legacy_2021_8ch_head384_autoload1D.json @@ -0,0 +1,491 @@ +{ + "device": { + "pip_type_1000ul": true, + "kb_iswap_installed": true, + "main_front_cover_monitoring_installed": false, + "autoload_installed": true, + "wash_station_1_installed": false, + "wash_station_2_installed": false, + "temp_controlled_carrier_1_installed": false, + "temp_controlled_carrier_2_installed": false, + "num_pip_channels": 8, + "left_x_drive_large": true, + "right_x_drive_large": false, + "pump_station_1_installed": false, + "pump_station_2_installed": false, + "wash_station_1_type_cr": false, + "wash_station_2_type_cr": false, + "left_cover_installed": false, + "right_cover_installed": false, + "additional_front_cover_monitoring_installed": false, + "pump_station_3_installed": false, + "multi_channel_nano_pipettor_installed": false, + "xl_channels_installed": false, + "tube_gripper_installed": false, + "waste_direction_left": false, + "iswap_gripper_wide": true, + "additional_channel_nano_pipettor_installed": false, + "imaging_channel_installed": false, + "robotic_channel_installed": false, + "channel_order_ox_first": false, + "x0_interface_ham_can": false, + "park_heads_with_iswap_off": false, + "configuration_data_3": 0, + "instrument_size_slots": 54, + "autoload_size_slots": 54, + "tip_waste_x_position": 1340.0, + "left_arm": { + "pip_installed": true, + "iswap_installed": true, + "head96_installed": false, + "nano_pipettor_installed": false, + "head384_installed": true, + "xl_channels_installed": false, + "tube_gripper_installed": false, + "imaging_channel_installed": false, + "robotic_channel_installed": false, + "gel_card_gripper_installed": false, + "puncher_handler_installed": false, + "width": 354.0, + "x_range": [ + 95.0, + 1340.2 + ], + "workspace_x_range": [ + -323.2, + 1517.2 + ], + "wrap_size": 595.2, + "firmware_version": "1.4S 2012-04-25", + "x_mm_per_increment": 0.1, + "x_range_increments": [ + 0, + 30000 + ], + "acceleration_level_range": [ + 1, + 5 + ], + "acceleration_level_default": 4, + "current_limit_range": [ + 0, + 7 + ], + "current_limit_default": 7 + }, + "right_arm": null, + "min_iswap_collision_free_position": 350.0, + "max_iswap_collision_free_position": 1140.0, + "left_x_arm_width": 354.0, + "right_x_arm_width": 370.0, + "num_xl_channels": 0, + "num_robotic_channels": 0, + "min_raster_pitch_pip_channels": 9.0, + "min_raster_pitch_xl_channels": 36.0, + "min_raster_pitch_robotic_channels": 36.0, + "pip_maximal_y_position": 606.5, + "left_arm_min_y_position": 6.0, + "right_arm_min_y_position": 6.0, + "firmware_version": "7.6S 25 2021_11_05 (GRU C0)", + "firmware_date": "2021-11-05", + "serial_number": "STAR2", + "head96_installed": false, + "head384_installed": true + }, + "arms": { + "left": { + "pipettes": { + "hardware_query_first_year": 2017, + "initialize_y_range": [ + 217.5, + 405.0 + ], + "initialize_begin_of_tip_deposit": 245.0, + "initialize_end_of_tip_deposit": 122.0, + "initialize_z_position_at_end": 360.0, + "initialize_tip_type": 4, + "initialize_discarding_method": 0, + "initialize_read_timeout": 120, + "x_reference_anchor": "c", + "y_reference_anchor": "c", + "z_reference_anchor": "b", + "y_drive_mm_per_increment": 0.046302083, + "z_drive_mm_per_increment": 0.01072765, + "z_range_increments": [ + 9320, + 31200 + ], + "z_range": null, + "dispensing_drive_mm_per_increment": 0.002734375, + "dispensing_drive_uL_per_increment": 0.046876, + "channel_size_z": 140.0, + "channels": [ + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + } + ] + }, + "head384": { + "module": "D0", + "retract_command": "JV", + "initialize_command": "JI", + "tip_presence_command": "QK", + "position_command": "QJ", + "y_parameter": "yk", + "z_parameter": "je", + "z_end_parameter": "zg", + "x_offset_parameter": "kd", + "head_types": { + "0": "Low volume head", + "1": "High volume head", + "2": "STP head" + }, + "drive_parameters": { + "yv": 5, + "yr": 3, + "zv": 5, + "zr": 3 + }, + "first_documented_firmware_year": 2009, + "firmware_version": "0.0S 2015-08-07 (D0 simulated)", + "firmware_date": "2015-08-07", + "x_offset": 260.0, + "channel_pitch": 4.5, + "channel_columns": 24, + "channel_rows": 16, + "body_size_z": 140.0, + "min_x_clear_of_left_side_panel": -100.0, + "min_tool_bottom_z": 99.98, + "supports_clot_monitoring_clld": false, + "head_type": "High volume head", + "tip_discard_location": null, + "z_drive_mm_per_increment": 0.005, + "y_drive_mm_per_increment": 0.015625, + "y_drive_acceleration_mm_per_increment": 15.625, + "z_drive_acceleration_mm_per_increment": 5.0, + "dispensing_drive_mm_per_increment": 0.00063333, + "z_speed_range_increments": [ + 50, + 20000 + ], + "z_acceleration_range_increments": [ + 5, + 100 + ], + "predefined_y_position_origin": 22000, + "predefined_z_position_origin": 35000, + "predefined_y_positions_increments": { + "home": 13485, + "predefined_1": 13000, + "predefined_2": 13000, + "predefined_3": 13000, + "predefined_4": 13000, + "predefined_5": 13000, + "predefined_6": 13000, + "predefined_7": 13000, + "predefined_8": 13000, + "predefined_9": 13000 + }, + "predefined_z_positions_increments": { + "home": 32000, + "predefined_1": 32000, + "predefined_2": 32000, + "predefined_3": 32000, + "predefined_4": 32000, + "predefined_5": 32000, + "predefined_6": 32000, + "predefined_7": 32000, + "predefined_8": 32000, + "predefined_9": 32000 + }, + "traversal_z_position": 245.0, + "y_drive_current_limit_default": 4, + "z_drive_current_limit_default": 7, + "current_limit_range": [ + 0, + 7 + ], + "y_speed_default_increments": 20000, + "y_acceleration_default_increments": 32, + "z_speed_default_increments": 17000, + "z_acceleration_default_increments": 80, + "y_drive_speed_firmware_reported": null, + "y_drive_acceleration_firmware_reported": null, + "z_drive_speed_firmware_reported": null, + "z_drive_acceleration_firmware_reported": null, + "supports_lld_absolute_threshold_check": false, + "y_range_increments": [ + 7100, + 36100 + ], + "y_speed_range_increments": [ + 50, + 20000 + ], + "y_acceleration_range_increments": [ + 5, + 32 + ], + "z_range_increments": [ + 33200, + 67200 + ] + }, + "iswap": { + "firmware_version": "4.1S 2011-12-19", + "firmware_date": null, + "rotation_drive_x_offset": 32.8, + "rotation_drive_predefined_increments": { + "home": 13000, + "left": -29007, + "front": 156, + "right": 29068, + "parking": 29500, + "extra_1": 29068, + "extra_2": 29068, + "extra_3": 29068, + "extra_4": 29068 + }, + "link_1_length": 137.8, + "wrist_drive_predefined_increments": { + "home": -26577, + "right": -26577, + "straight": -8860, + "left": 9044, + "reverse": 26858, + "parking": -26577, + "extra_1": -26577, + "extra_2": -26577, + "extra_3": -26577 + }, + "tool_length": 137.7, + "y_range_increments": [ + 0, + 14000 + ], + "y_mm_per_increment": 0.046302083, + "y_speed_range_increments": [ + 50, + 8000 + ], + "rotation_drive_diameter": 30.5, + "rotation_drive_safety_radius": 90.0, + "rotation_drive_size_z": 120.0, + "z_range_increments": [ + -187, + 26661 + ], + "z_mm_per_increment": 0.01072765, + "z_speed_range_increments": [ + 50, + 15000 + ], + "z_acceleration_range_increments": [ + 5, + 999 + ], + "rotation_drive_z_offset_above_finger": 13.0, + "rotation_range_increments": [ + -30032, + 30032 + ], + "rotation_deg_per_increment": 0.00309619077, + "wrist_range_increments": [ + -30000, + 30000 + ], + "wrist_deg_per_increment": 0.00507968798, + "gripper_range_increments": [ + 12780, + 24120 + ], + "gripper_mm_per_increment": 0.00554337, + "rotation_drive_predefined_y_positions_increments": { + "home": 9855, + "lower_limit": 7000, + "upper_limit": 9000, + "parking": 13550, + "pre_parking": 12600, + "extra_1": 9855, + "extra_2": 9855, + "extra_3": 9855, + "extra_4": 9855, + "extra_5": 9855 + }, + "rotation_drive_predefined_z_positions_increments": { + "home": 25600, + "parking": 25400, + "extra_1": 24600, + "extra_2": 24600, + "extra_3": 24600, + "extra_4": 24600, + "extra_5": 24600, + "extra_6": 24600, + "extra_7": 24600, + "extra_8": 24600 + }, + "gripper_drive_predefined_increments": { + "home": 13100, + "extra_1": 24120, + "closed": 12960, + "plate_type_1": 12780, + "plate_type_2": 13100, + "plate_type_3": 13100, + "plate_type_4": 13100, + "plate_type_5": 13100, + "plate_type_6": 13100, + "plate_type_7": 13100 + } + } + } + }, + "autoload": { + "firmware_version": "3.4S f 2017-01-09", + "firmware_date": null, + "autoload_type": "1D barcode scanner", + "y_positions": { + "loading_tray": 0, + "carrier_identification": 1, + "deck": 2 + }, + "z_positions": { + "below": 0, + "above": 1 + }, + "scanner_rotations": { + "vertical": 0, + "horizontal": 1, + "undefined": 2 + }, + "barcode_reading_directions": { + "vertical": 0, + "horizontal": 1 + }, + "barcode_symbologies": null, + "barcode_2d_symbologies": null, + "scan_directions": { + "vertical": 0, + "horizontal": 1, + "omnidirectional": 2, + "vertical and horizontal": 3 + }, + "x_drive_mm_per_increment": 0.1, + "loading_indicators_installed": null, + "initialization_track": 1, + "adjustment_date": "2025-06-11", + "adjusted": true, + "drive_zero_on_the_deck": 100.0, + "reference_point_from_sled_left_edge": 109.0, + "x_drive_range_increments": [ + 0, + 12500 + ], + "x_drive_speed_range_increments": [ + 20, + 3000 + ], + "x_drive_speed_default": 2500, + "x_drive_acceleration_ramp_range": [ + 1, + 3 + ], + "x_drive_acceleration_ramp_default": 3, + "z_drive_mm_per_increment": 0.004166666666666667, + "z_drive_range_increments": [ + 0, + 3000 + ], + "z_drive_speed_range_increments": [ + 20, + 2000 + ], + "z_drive_speed_default": 1750, + "z_drive_acceleration_ramp_range": [ + 1, + 4 + ], + "z_drive_acceleration_ramp_default": 4, + "y_drive_mm_per_increment": 0.06404424, + "y_drive_range_increments": [ + 0, + 9999 + ], + "y_drive_speed_range_increments": [ + 20, + 2500 + ], + "y_drive_speed_default": 2000, + "y_drive_acceleration_ramp_range": [ + 1, + 6 + ], + "y_drive_acceleration_ramp_default": 6, + "motor_current_limit_range": [ + 0, + 7 + ], + "motor_current_limit_default": 7, + "acceleration_ramp_increments_per_second_squared": 2500 + } +} diff --git a/pylabrobot/hamilton/star/driver/recordings/star_legacy_2021_8ch_head96_autoload1D.json b/pylabrobot/hamilton/star/driver/recordings/star_legacy_2021_8ch_head96_autoload1D.json new file mode 100644 index 00000000000..d42d47a632d --- /dev/null +++ b/pylabrobot/hamilton/star/driver/recordings/star_legacy_2021_8ch_head96_autoload1D.json @@ -0,0 +1,496 @@ +{ + "device": { + "pip_type_1000ul": true, + "kb_iswap_installed": true, + "main_front_cover_monitoring_installed": false, + "autoload_installed": true, + "wash_station_1_installed": false, + "wash_station_2_installed": false, + "temp_controlled_carrier_1_installed": false, + "temp_controlled_carrier_2_installed": false, + "num_pip_channels": 8, + "left_x_drive_large": true, + "right_x_drive_large": false, + "pump_station_1_installed": false, + "pump_station_2_installed": false, + "wash_station_1_type_cr": false, + "wash_station_2_type_cr": false, + "left_cover_installed": false, + "right_cover_installed": false, + "additional_front_cover_monitoring_installed": false, + "pump_station_3_installed": false, + "multi_channel_nano_pipettor_installed": false, + "xl_channels_installed": false, + "tube_gripper_installed": false, + "waste_direction_left": false, + "iswap_gripper_wide": true, + "additional_channel_nano_pipettor_installed": false, + "imaging_channel_installed": false, + "robotic_channel_installed": false, + "channel_order_ox_first": false, + "x0_interface_ham_can": false, + "park_heads_with_iswap_off": false, + "configuration_data_3": 0, + "instrument_size_slots": 54, + "autoload_size_slots": 54, + "tip_waste_x_position": 1340.0, + "left_arm": { + "pip_installed": true, + "iswap_installed": true, + "head96_installed": true, + "nano_pipettor_installed": false, + "head384_installed": false, + "xl_channels_installed": false, + "tube_gripper_installed": false, + "imaging_channel_installed": false, + "robotic_channel_installed": false, + "gel_card_gripper_installed": false, + "puncher_handler_installed": false, + "width": 354.0, + "x_range": [ + 95.0, + 1340.2 + ], + "workspace_x_range": [ + -323.2, + 1517.2 + ], + "wrap_size": 595.2, + "firmware_version": "1.4S 2012-04-25", + "x_mm_per_increment": 0.1, + "x_range_increments": [ + 0, + 30000 + ], + "acceleration_level_range": [ + 1, + 5 + ], + "acceleration_level_default": 4, + "current_limit_range": [ + 0, + 7 + ], + "current_limit_default": 7 + }, + "right_arm": null, + "min_iswap_collision_free_position": 350.0, + "max_iswap_collision_free_position": 1140.0, + "left_x_arm_width": 354.0, + "right_x_arm_width": 370.0, + "num_xl_channels": 0, + "num_robotic_channels": 0, + "min_raster_pitch_pip_channels": 9.0, + "min_raster_pitch_xl_channels": 36.0, + "min_raster_pitch_robotic_channels": 36.0, + "pip_maximal_y_position": 606.5, + "left_arm_min_y_position": 6.0, + "right_arm_min_y_position": 6.0, + "firmware_version": "7.6S 25 2021_11_05 (GRU C0)", + "firmware_date": "2021-11-05", + "serial_number": "STAR1", + "head96_installed": true, + "head384_installed": false + }, + "arms": { + "left": { + "pipettes": { + "hardware_query_first_year": 2017, + "initialize_y_range": [ + 217.5, + 405.0 + ], + "initialize_begin_of_tip_deposit": 245.0, + "initialize_end_of_tip_deposit": 122.0, + "initialize_z_position_at_end": 360.0, + "initialize_tip_type": 4, + "initialize_discarding_method": 0, + "initialize_read_timeout": 120, + "x_reference_anchor": "c", + "y_reference_anchor": "c", + "z_reference_anchor": "b", + "y_drive_mm_per_increment": 0.046302083, + "z_drive_mm_per_increment": 0.01072765, + "z_range_increments": [ + 9320, + 31200 + ], + "z_range": null, + "dispensing_drive_mm_per_increment": 0.002734375, + "dispensing_drive_uL_per_increment": 0.046876, + "channel_size_z": 140.0, + "channels": [ + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + } + ] + }, + "head96": { + "module": "H0", + "retract_command": "EV", + "initialize_command": "EI", + "tip_presence_command": "QH", + "position_command": "QI", + "y_parameter": "yh", + "z_parameter": "za", + "z_end_parameter": "ze", + "x_offset_parameter": "kf", + "head_types": { + "0": "Low volume head", + "1": "High volume head", + "2": "96 head II", + "3": "96 head TADM" + }, + "drive_parameters": { + "yv": 5, + "yr": 5, + "zv": 5, + "zr": 6, + "dv": 5, + "dr": 6, + "sv": 5, + "sr": 6 + }, + "first_documented_firmware_year": 2010, + "firmware_version": "5.0S i 2021-10-22 (H0 XE167)", + "firmware_date": "2021-10-22", + "x_offset": 368.2, + "channel_pitch": 9.0, + "channel_columns": 12, + "channel_rows": 8, + "body_size_z": 140.0, + "min_x_clear_of_left_side_panel": -100.0, + "min_tool_bottom_z": 99.98, + "supports_clot_monitoring_clld": false, + "head_type": "96 head II", + "tip_discard_location": null, + "z_drive_mm_per_increment": 0.005, + "y_drive_mm_per_increment": 0.015625, + "y_drive_acceleration_mm_per_increment": 0.015625, + "z_drive_acceleration_mm_per_increment": 0.005, + "dispensing_drive_mm_per_increment": 0.001025641026, + "z_speed_range_increments": [ + 50, + 20000 + ], + "z_acceleration_range_increments": [ + 5000, + 100000 + ], + "predefined_y_position_origin": 0, + "predefined_z_position_origin": 0, + "predefined_y_positions_increments": { + "home": 35485, + "predefined_1": 35000, + "predefined_2": 35000, + "predefined_3": 35000, + "predefined_4": 35000, + "predefined_5": 35000, + "predefined_6": 35000, + "predefined_7": 35000, + "predefined_8": 35000, + "predefined_9": 35000 + }, + "predefined_z_positions_increments": { + "home": 67100, + "predefined_1": 67100, + "predefined_2": 67100, + "predefined_3": 67100, + "predefined_4": 67100, + "predefined_5": 67100, + "predefined_6": 67100, + "predefined_7": 67100, + "predefined_8": 67100, + "predefined_9": 67100 + }, + "traversal_z_position": 245.0, + "y_drive_current_limit_default": 15, + "z_drive_current_limit_default": 15, + "current_limit_range": [ + 0, + 15 + ], + "y_speed_default_increments": 25000, + "y_acceleration_default_increments": 35000, + "z_speed_default_increments": 17000, + "z_acceleration_default_increments": 80000, + "y_drive_speed_firmware_reported": null, + "y_drive_acceleration_firmware_reported": null, + "z_drive_speed_firmware_reported": null, + "z_drive_acceleration_firmware_reported": null, + "dispensing_drive_speed_firmware_reported": null, + "dispensing_drive_acceleration_firmware_reported": null, + "squeezer_drive_speed_firmware_reported": null, + "squeezer_drive_acceleration_firmware_reported": null, + "stop_disc_type": "core_ii", + "instrument_type": "legacy", + "dispensing_drive_uL_per_increment": 0.019340933, + "squeezer_drive_mm_per_increment": 0.0002086672009, + "tip_command_y_range": [ + 108.0, + 560.0 + ], + "tip_engage_correction_low_volume": 2.0, + "tip_engage_correction_other": -2.0, + "tip_drop_clearance": 1.45, + "dispensing_drive_position_before_rack_pickup": 218.19, + "y_increment_floor": 6528 + }, + "iswap": { + "firmware_version": "4.1S 2011-12-19", + "firmware_date": null, + "rotation_drive_x_offset": 32.8, + "rotation_drive_predefined_increments": { + "home": 13000, + "left": -29007, + "front": 156, + "right": 29068, + "parking": 29500, + "extra_1": 29068, + "extra_2": 29068, + "extra_3": 29068, + "extra_4": 29068 + }, + "link_1_length": 137.8, + "wrist_drive_predefined_increments": { + "home": -26577, + "right": -26577, + "straight": -8860, + "left": 9044, + "reverse": 26858, + "parking": -26577, + "extra_1": -26577, + "extra_2": -26577, + "extra_3": -26577 + }, + "tool_length": 137.7, + "y_range_increments": [ + 0, + 14000 + ], + "y_mm_per_increment": 0.046302083, + "y_speed_range_increments": [ + 50, + 8000 + ], + "rotation_drive_diameter": 30.5, + "rotation_drive_safety_radius": 90.0, + "rotation_drive_size_z": 120.0, + "z_range_increments": [ + -187, + 26661 + ], + "z_mm_per_increment": 0.01072765, + "z_speed_range_increments": [ + 50, + 15000 + ], + "z_acceleration_range_increments": [ + 5, + 999 + ], + "rotation_drive_z_offset_above_finger": 13.0, + "rotation_range_increments": [ + -30032, + 30032 + ], + "rotation_deg_per_increment": 0.00309619077, + "wrist_range_increments": [ + -30000, + 30000 + ], + "wrist_deg_per_increment": 0.00507968798, + "gripper_range_increments": [ + 12780, + 24120 + ], + "gripper_mm_per_increment": 0.00554337, + "rotation_drive_predefined_y_positions_increments": { + "home": 9855, + "lower_limit": 7000, + "upper_limit": 9000, + "parking": 13550, + "pre_parking": 12600, + "extra_1": 9855, + "extra_2": 9855, + "extra_3": 9855, + "extra_4": 9855, + "extra_5": 9855 + }, + "rotation_drive_predefined_z_positions_increments": { + "home": 25600, + "parking": 25400, + "extra_1": 24600, + "extra_2": 24600, + "extra_3": 24600, + "extra_4": 24600, + "extra_5": 24600, + "extra_6": 24600, + "extra_7": 24600, + "extra_8": 24600 + }, + "gripper_drive_predefined_increments": { + "home": 13100, + "extra_1": 24120, + "closed": 12960, + "plate_type_1": 12780, + "plate_type_2": 13100, + "plate_type_3": 13100, + "plate_type_4": 13100, + "plate_type_5": 13100, + "plate_type_6": 13100, + "plate_type_7": 13100 + } + } + } + }, + "autoload": { + "firmware_version": "3.4S f 2017-01-09", + "firmware_date": null, + "autoload_type": "1D barcode scanner", + "y_positions": { + "loading_tray": 0, + "carrier_identification": 1, + "deck": 2 + }, + "z_positions": { + "below": 0, + "above": 1 + }, + "scanner_rotations": { + "vertical": 0, + "horizontal": 1, + "undefined": 2 + }, + "barcode_reading_directions": { + "vertical": 0, + "horizontal": 1 + }, + "barcode_symbologies": null, + "barcode_2d_symbologies": null, + "scan_directions": { + "vertical": 0, + "horizontal": 1, + "omnidirectional": 2, + "vertical and horizontal": 3 + }, + "x_drive_mm_per_increment": 0.1, + "loading_indicators_installed": null, + "initialization_track": 1, + "adjustment_date": "2025-06-11", + "adjusted": true, + "drive_zero_on_the_deck": 100.0, + "reference_point_from_sled_left_edge": 109.0, + "x_drive_range_increments": [ + 0, + 12500 + ], + "x_drive_speed_range_increments": [ + 20, + 3000 + ], + "x_drive_speed_default": 2500, + "x_drive_acceleration_ramp_range": [ + 1, + 3 + ], + "x_drive_acceleration_ramp_default": 3, + "z_drive_mm_per_increment": 0.004166666666666667, + "z_drive_range_increments": [ + 0, + 3000 + ], + "z_drive_speed_range_increments": [ + 20, + 2000 + ], + "z_drive_speed_default": 1750, + "z_drive_acceleration_ramp_range": [ + 1, + 4 + ], + "z_drive_acceleration_ramp_default": 4, + "y_drive_mm_per_increment": 0.06404424, + "y_drive_range_increments": [ + 0, + 9999 + ], + "y_drive_speed_range_increments": [ + 20, + 2500 + ], + "y_drive_speed_default": 2000, + "y_drive_acceleration_ramp_range": [ + 1, + 6 + ], + "y_drive_acceleration_ramp_default": 6, + "motor_current_limit_range": [ + 0, + 7 + ], + "motor_current_limit_default": 7, + "acceleration_ramp_increments_per_second_squared": 2500 + } +} diff --git a/pylabrobot/hamilton/star/driver/recordings/starlet_legacy_2021_8ch_head384_autoload1D.json b/pylabrobot/hamilton/star/driver/recordings/starlet_legacy_2021_8ch_head384_autoload1D.json new file mode 100644 index 00000000000..f3e2962341f --- /dev/null +++ b/pylabrobot/hamilton/star/driver/recordings/starlet_legacy_2021_8ch_head384_autoload1D.json @@ -0,0 +1,491 @@ +{ + "device": { + "serial_number": "STARlet2", + "firmware_version": "7.6S 25 2021_11_05 (GRU C0)", + "firmware_date": "2021-11-05", + "pip_type_1000ul": true, + "kb_iswap_installed": true, + "main_front_cover_monitoring_installed": false, + "autoload_installed": true, + "wash_station_1_installed": false, + "wash_station_2_installed": false, + "temp_controlled_carrier_1_installed": false, + "temp_controlled_carrier_2_installed": false, + "num_pip_channels": 8, + "left_x_drive_large": true, + "right_x_drive_large": false, + "pump_station_1_installed": false, + "pump_station_2_installed": false, + "wash_station_1_type_cr": false, + "wash_station_2_type_cr": false, + "left_cover_installed": false, + "right_cover_installed": false, + "additional_front_cover_monitoring_installed": false, + "pump_station_3_installed": false, + "multi_channel_nano_pipettor_installed": false, + "xl_channels_installed": false, + "tube_gripper_installed": false, + "waste_direction_left": false, + "iswap_gripper_wide": true, + "additional_channel_nano_pipettor_installed": false, + "imaging_channel_installed": false, + "robotic_channel_installed": false, + "channel_order_ox_first": false, + "x0_interface_ham_can": false, + "park_heads_with_iswap_off": false, + "configuration_data_3": 0, + "instrument_size_slots": 30, + "autoload_size_slots": 30, + "tip_waste_x_position": 800.0, + "left_arm": { + "pip_installed": true, + "iswap_installed": true, + "head96_installed": false, + "nano_pipettor_installed": false, + "head384_installed": true, + "xl_channels_installed": false, + "tube_gripper_installed": false, + "imaging_channel_installed": false, + "robotic_channel_installed": false, + "gel_card_gripper_installed": false, + "puncher_handler_installed": false, + "width": 354.0, + "x_range": [ + 95.0, + 800.2 + ], + "workspace_x_range": [ + -323.2, + 977.2 + ], + "wrap_size": 595.2, + "firmware_version": "1.4S 2012-04-25", + "x_mm_per_increment": 0.1, + "x_range_increments": [ + 0, + 30000 + ], + "acceleration_level_range": [ + 1, + 5 + ], + "acceleration_level_default": 4, + "current_limit_range": [ + 0, + 7 + ], + "current_limit_default": 7 + }, + "right_arm": null, + "min_iswap_collision_free_position": 350.0, + "max_iswap_collision_free_position": 600.0, + "left_x_arm_width": 354.0, + "right_x_arm_width": 370.0, + "num_xl_channels": 0, + "num_robotic_channels": 0, + "min_raster_pitch_pip_channels": 9.0, + "min_raster_pitch_xl_channels": 36.0, + "min_raster_pitch_robotic_channels": 36.0, + "pip_maximal_y_position": 606.5, + "left_arm_min_y_position": 6.0, + "right_arm_min_y_position": 6.0, + "head96_installed": false, + "head384_installed": true + }, + "arms": { + "left": { + "pipettes": { + "hardware_query_first_year": 2017, + "initialize_y_range": [ + 217.5, + 405.0 + ], + "initialize_begin_of_tip_deposit": 245.0, + "initialize_end_of_tip_deposit": 122.0, + "initialize_z_position_at_end": 360.0, + "initialize_tip_type": 4, + "initialize_discarding_method": 0, + "initialize_read_timeout": 120, + "x_reference_anchor": "c", + "y_reference_anchor": "c", + "z_reference_anchor": "b", + "y_drive_mm_per_increment": 0.046302083, + "z_drive_mm_per_increment": 0.01072765, + "z_range_increments": [ + 9320, + 31200 + ], + "z_range": null, + "dispensing_drive_mm_per_increment": 0.002734375, + "dispensing_drive_uL_per_increment": 0.046876, + "channel_size_z": 140.0, + "channels": [ + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + } + ] + }, + "head384": { + "module": "D0", + "retract_command": "JV", + "initialize_command": "JI", + "tip_presence_command": "QK", + "position_command": "QJ", + "y_parameter": "yk", + "z_parameter": "je", + "z_end_parameter": "zg", + "x_offset_parameter": "kd", + "head_types": { + "0": "Low volume head", + "1": "High volume head", + "2": "STP head" + }, + "drive_parameters": { + "yv": 5, + "yr": 3, + "zv": 5, + "zr": 3 + }, + "first_documented_firmware_year": 2009, + "firmware_version": "0.0S 2015-08-07 (D0 simulated)", + "firmware_date": "2015-08-07", + "x_offset": 260.0, + "channel_pitch": 4.5, + "channel_columns": 24, + "channel_rows": 16, + "body_size_z": 140.0, + "min_x_clear_of_left_side_panel": -100.0, + "min_tool_bottom_z": 99.98, + "supports_clot_monitoring_clld": false, + "head_type": "High volume head", + "tip_discard_location": null, + "z_drive_mm_per_increment": 0.005, + "y_drive_mm_per_increment": 0.015625, + "y_drive_acceleration_mm_per_increment": 15.625, + "z_drive_acceleration_mm_per_increment": 5.0, + "dispensing_drive_mm_per_increment": 0.00063333, + "z_speed_range_increments": [ + 50, + 20000 + ], + "z_acceleration_range_increments": [ + 5, + 100 + ], + "predefined_y_position_origin": 22000, + "predefined_z_position_origin": 35000, + "predefined_y_positions_increments": { + "home": 13485, + "predefined_1": 13000, + "predefined_2": 13000, + "predefined_3": 13000, + "predefined_4": 13000, + "predefined_5": 13000, + "predefined_6": 13000, + "predefined_7": 13000, + "predefined_8": 13000, + "predefined_9": 13000 + }, + "predefined_z_positions_increments": { + "home": 32000, + "predefined_1": 32000, + "predefined_2": 32000, + "predefined_3": 32000, + "predefined_4": 32000, + "predefined_5": 32000, + "predefined_6": 32000, + "predefined_7": 32000, + "predefined_8": 32000, + "predefined_9": 32000 + }, + "traversal_z_position": 245.0, + "y_drive_current_limit_default": 4, + "z_drive_current_limit_default": 7, + "current_limit_range": [ + 0, + 7 + ], + "y_speed_default_increments": 20000, + "y_acceleration_default_increments": 32, + "z_speed_default_increments": 17000, + "z_acceleration_default_increments": 80, + "y_drive_speed_firmware_reported": null, + "y_drive_acceleration_firmware_reported": null, + "z_drive_speed_firmware_reported": null, + "z_drive_acceleration_firmware_reported": null, + "supports_lld_absolute_threshold_check": false, + "y_range_increments": [ + 7100, + 36100 + ], + "y_speed_range_increments": [ + 50, + 20000 + ], + "y_acceleration_range_increments": [ + 5, + 32 + ], + "z_range_increments": [ + 33200, + 67200 + ] + }, + "iswap": { + "firmware_version": "4.1S 2011-12-19", + "firmware_date": null, + "rotation_drive_x_offset": 32.8, + "rotation_drive_predefined_increments": { + "home": 13000, + "left": -29007, + "front": 156, + "right": 29068, + "parking": 29500, + "extra_1": 29068, + "extra_2": 29068, + "extra_3": 29068, + "extra_4": 29068 + }, + "link_1_length": 137.8, + "wrist_drive_predefined_increments": { + "home": -26577, + "right": -26577, + "straight": -8860, + "left": 9044, + "reverse": 26858, + "parking": -26577, + "extra_1": -26577, + "extra_2": -26577, + "extra_3": -26577 + }, + "tool_length": 137.7, + "y_range_increments": [ + 0, + 14000 + ], + "y_mm_per_increment": 0.046302083, + "y_speed_range_increments": [ + 50, + 8000 + ], + "rotation_drive_diameter": 30.5, + "rotation_drive_safety_radius": 90.0, + "rotation_drive_size_z": 120.0, + "z_range_increments": [ + -187, + 26661 + ], + "z_mm_per_increment": 0.01072765, + "z_speed_range_increments": [ + 50, + 15000 + ], + "z_acceleration_range_increments": [ + 5, + 999 + ], + "rotation_drive_z_offset_above_finger": 13.0, + "rotation_range_increments": [ + -30032, + 30032 + ], + "rotation_deg_per_increment": 0.00309619077, + "wrist_range_increments": [ + -30000, + 30000 + ], + "wrist_deg_per_increment": 0.00507968798, + "gripper_range_increments": [ + 12780, + 24120 + ], + "gripper_mm_per_increment": 0.00554337, + "rotation_drive_predefined_y_positions_increments": { + "home": 9855, + "lower_limit": 7000, + "upper_limit": 9000, + "parking": 13550, + "pre_parking": 12600, + "extra_1": 9855, + "extra_2": 9855, + "extra_3": 9855, + "extra_4": 9855, + "extra_5": 9855 + }, + "rotation_drive_predefined_z_positions_increments": { + "home": 25600, + "parking": 25400, + "extra_1": 24600, + "extra_2": 24600, + "extra_3": 24600, + "extra_4": 24600, + "extra_5": 24600, + "extra_6": 24600, + "extra_7": 24600, + "extra_8": 24600 + }, + "gripper_drive_predefined_increments": { + "home": 13100, + "extra_1": 24120, + "closed": 12960, + "plate_type_1": 12780, + "plate_type_2": 13100, + "plate_type_3": 13100, + "plate_type_4": 13100, + "plate_type_5": 13100, + "plate_type_6": 13100, + "plate_type_7": 13100 + } + } + } + }, + "autoload": { + "firmware_version": "3.4S f 2017-01-09", + "firmware_date": null, + "autoload_type": "1D barcode scanner", + "y_positions": { + "loading_tray": 0, + "carrier_identification": 1, + "deck": 2 + }, + "z_positions": { + "below": 0, + "above": 1 + }, + "scanner_rotations": { + "vertical": 0, + "horizontal": 1, + "undefined": 2 + }, + "barcode_reading_directions": { + "vertical": 0, + "horizontal": 1 + }, + "barcode_symbologies": null, + "barcode_2d_symbologies": null, + "scan_directions": { + "vertical": 0, + "horizontal": 1, + "omnidirectional": 2, + "vertical and horizontal": 3 + }, + "x_drive_mm_per_increment": 0.1, + "loading_indicators_installed": null, + "initialization_track": 1, + "adjustment_date": "2025-06-11", + "adjusted": true, + "drive_zero_on_the_deck": 100.0, + "reference_point_from_sled_left_edge": 109.0, + "x_drive_range_increments": [ + 0, + 12500 + ], + "x_drive_speed_range_increments": [ + 20, + 3000 + ], + "x_drive_speed_default": 2500, + "x_drive_acceleration_ramp_range": [ + 1, + 3 + ], + "x_drive_acceleration_ramp_default": 3, + "z_drive_mm_per_increment": 0.004166666666666667, + "z_drive_range_increments": [ + 0, + 3000 + ], + "z_drive_speed_range_increments": [ + 20, + 2000 + ], + "z_drive_speed_default": 1750, + "z_drive_acceleration_ramp_range": [ + 1, + 4 + ], + "z_drive_acceleration_ramp_default": 4, + "y_drive_mm_per_increment": 0.06404424, + "y_drive_range_increments": [ + 0, + 9999 + ], + "y_drive_speed_range_increments": [ + 20, + 2500 + ], + "y_drive_speed_default": 2000, + "y_drive_acceleration_ramp_range": [ + 1, + 6 + ], + "y_drive_acceleration_ramp_default": 6, + "motor_current_limit_range": [ + 0, + 7 + ], + "motor_current_limit_default": 7, + "acceleration_ramp_increments_per_second_squared": 2500 + } +} diff --git a/pylabrobot/hamilton/star/driver/recordings/starlet_legacy_2021_8ch_head96_autoload1D.json b/pylabrobot/hamilton/star/driver/recordings/starlet_legacy_2021_8ch_head96_autoload1D.json new file mode 100644 index 00000000000..c04b7c02fc1 --- /dev/null +++ b/pylabrobot/hamilton/star/driver/recordings/starlet_legacy_2021_8ch_head96_autoload1D.json @@ -0,0 +1,496 @@ +{ + "device": { + "serial_number": "STARlet1", + "firmware_version": "7.6S 25 2021_11_05 (GRU C0)", + "firmware_date": "2021-11-05", + "pip_type_1000ul": true, + "kb_iswap_installed": true, + "main_front_cover_monitoring_installed": false, + "autoload_installed": true, + "wash_station_1_installed": false, + "wash_station_2_installed": false, + "temp_controlled_carrier_1_installed": false, + "temp_controlled_carrier_2_installed": false, + "num_pip_channels": 8, + "left_x_drive_large": true, + "right_x_drive_large": false, + "pump_station_1_installed": false, + "pump_station_2_installed": false, + "wash_station_1_type_cr": false, + "wash_station_2_type_cr": false, + "left_cover_installed": false, + "right_cover_installed": false, + "additional_front_cover_monitoring_installed": false, + "pump_station_3_installed": false, + "multi_channel_nano_pipettor_installed": false, + "xl_channels_installed": false, + "tube_gripper_installed": false, + "waste_direction_left": false, + "iswap_gripper_wide": true, + "additional_channel_nano_pipettor_installed": false, + "imaging_channel_installed": false, + "robotic_channel_installed": false, + "channel_order_ox_first": false, + "x0_interface_ham_can": false, + "park_heads_with_iswap_off": false, + "configuration_data_3": 0, + "instrument_size_slots": 30, + "autoload_size_slots": 30, + "tip_waste_x_position": 800.0, + "left_arm": { + "pip_installed": true, + "iswap_installed": true, + "head96_installed": true, + "nano_pipettor_installed": false, + "head384_installed": false, + "xl_channels_installed": false, + "tube_gripper_installed": false, + "imaging_channel_installed": false, + "robotic_channel_installed": false, + "gel_card_gripper_installed": false, + "puncher_handler_installed": false, + "width": 354.0, + "x_range": [ + 95.0, + 800.2 + ], + "workspace_x_range": [ + -323.2, + 977.2 + ], + "wrap_size": 595.2, + "firmware_version": "1.4S 2012-04-25", + "x_mm_per_increment": 0.1, + "x_range_increments": [ + 0, + 30000 + ], + "acceleration_level_range": [ + 1, + 5 + ], + "acceleration_level_default": 4, + "current_limit_range": [ + 0, + 7 + ], + "current_limit_default": 7 + }, + "right_arm": null, + "min_iswap_collision_free_position": 350.0, + "max_iswap_collision_free_position": 600.0, + "left_x_arm_width": 354.0, + "right_x_arm_width": 370.0, + "num_xl_channels": 0, + "num_robotic_channels": 0, + "min_raster_pitch_pip_channels": 9.0, + "min_raster_pitch_xl_channels": 36.0, + "min_raster_pitch_robotic_channels": 36.0, + "pip_maximal_y_position": 606.5, + "left_arm_min_y_position": 6.0, + "right_arm_min_y_position": 6.0, + "head96_installed": true, + "head384_installed": false + }, + "arms": { + "left": { + "pipettes": { + "hardware_query_first_year": 2017, + "initialize_y_range": [ + 217.5, + 405.0 + ], + "initialize_begin_of_tip_deposit": 245.0, + "initialize_end_of_tip_deposit": 122.0, + "initialize_z_position_at_end": 360.0, + "initialize_tip_type": 4, + "initialize_discarding_method": 0, + "initialize_read_timeout": 120, + "x_reference_anchor": "c", + "y_reference_anchor": "c", + "z_reference_anchor": "b", + "y_drive_mm_per_increment": 0.046302083, + "z_drive_mm_per_increment": 0.01072765, + "z_range_increments": [ + 9320, + 31200 + ], + "z_range": null, + "dispensing_drive_mm_per_increment": 0.002734375, + "dispensing_drive_uL_per_increment": 0.046876, + "channel_size_z": 140.0, + "channels": [ + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + } + ] + }, + "head96": { + "module": "H0", + "retract_command": "EV", + "initialize_command": "EI", + "tip_presence_command": "QH", + "position_command": "QI", + "y_parameter": "yh", + "z_parameter": "za", + "z_end_parameter": "ze", + "x_offset_parameter": "kf", + "head_types": { + "0": "Low volume head", + "1": "High volume head", + "2": "96 head II", + "3": "96 head TADM" + }, + "drive_parameters": { + "yv": 5, + "yr": 5, + "zv": 5, + "zr": 6, + "dv": 5, + "dr": 6, + "sv": 5, + "sr": 6 + }, + "first_documented_firmware_year": 2010, + "firmware_version": "5.0S i 2021-10-22 (H0 XE167)", + "firmware_date": "2021-10-22", + "x_offset": 368.2, + "channel_pitch": 9.0, + "channel_columns": 12, + "channel_rows": 8, + "body_size_z": 140.0, + "min_x_clear_of_left_side_panel": -100.0, + "min_tool_bottom_z": 99.98, + "supports_clot_monitoring_clld": false, + "head_type": "96 head II", + "tip_discard_location": null, + "z_drive_mm_per_increment": 0.005, + "y_drive_mm_per_increment": 0.015625, + "y_drive_acceleration_mm_per_increment": 0.015625, + "z_drive_acceleration_mm_per_increment": 0.005, + "dispensing_drive_mm_per_increment": 0.001025641026, + "z_speed_range_increments": [ + 50, + 20000 + ], + "z_acceleration_range_increments": [ + 5000, + 100000 + ], + "predefined_y_position_origin": 0, + "predefined_z_position_origin": 0, + "predefined_y_positions_increments": { + "home": 35485, + "predefined_1": 35000, + "predefined_2": 35000, + "predefined_3": 35000, + "predefined_4": 35000, + "predefined_5": 35000, + "predefined_6": 35000, + "predefined_7": 35000, + "predefined_8": 35000, + "predefined_9": 35000 + }, + "predefined_z_positions_increments": { + "home": 67100, + "predefined_1": 67100, + "predefined_2": 67100, + "predefined_3": 67100, + "predefined_4": 67100, + "predefined_5": 67100, + "predefined_6": 67100, + "predefined_7": 67100, + "predefined_8": 67100, + "predefined_9": 67100 + }, + "traversal_z_position": 245.0, + "y_drive_current_limit_default": 15, + "z_drive_current_limit_default": 15, + "current_limit_range": [ + 0, + 15 + ], + "y_speed_default_increments": 25000, + "y_acceleration_default_increments": 35000, + "z_speed_default_increments": 17000, + "z_acceleration_default_increments": 80000, + "y_drive_speed_firmware_reported": null, + "y_drive_acceleration_firmware_reported": null, + "z_drive_speed_firmware_reported": null, + "z_drive_acceleration_firmware_reported": null, + "dispensing_drive_speed_firmware_reported": null, + "dispensing_drive_acceleration_firmware_reported": null, + "squeezer_drive_speed_firmware_reported": null, + "squeezer_drive_acceleration_firmware_reported": null, + "stop_disc_type": "core_ii", + "instrument_type": "legacy", + "dispensing_drive_uL_per_increment": 0.019340933, + "squeezer_drive_mm_per_increment": 0.0002086672009, + "tip_command_y_range": [ + 108.0, + 560.0 + ], + "tip_engage_correction_low_volume": 2.0, + "tip_engage_correction_other": -2.0, + "tip_drop_clearance": 1.45, + "dispensing_drive_position_before_rack_pickup": 218.19, + "y_increment_floor": 6528 + }, + "iswap": { + "firmware_version": "4.1S 2011-12-19", + "firmware_date": null, + "rotation_drive_x_offset": 32.8, + "rotation_drive_predefined_increments": { + "home": 13000, + "left": -29007, + "front": 156, + "right": 29068, + "parking": 29500, + "extra_1": 29068, + "extra_2": 29068, + "extra_3": 29068, + "extra_4": 29068 + }, + "link_1_length": 137.8, + "wrist_drive_predefined_increments": { + "home": -26577, + "right": -26577, + "straight": -8860, + "left": 9044, + "reverse": 26858, + "parking": -26577, + "extra_1": -26577, + "extra_2": -26577, + "extra_3": -26577 + }, + "tool_length": 137.7, + "y_range_increments": [ + 0, + 14000 + ], + "y_mm_per_increment": 0.046302083, + "y_speed_range_increments": [ + 50, + 8000 + ], + "rotation_drive_diameter": 30.5, + "rotation_drive_safety_radius": 90.0, + "rotation_drive_size_z": 120.0, + "z_range_increments": [ + -187, + 26661 + ], + "z_mm_per_increment": 0.01072765, + "z_speed_range_increments": [ + 50, + 15000 + ], + "z_acceleration_range_increments": [ + 5, + 999 + ], + "rotation_drive_z_offset_above_finger": 13.0, + "rotation_range_increments": [ + -30032, + 30032 + ], + "rotation_deg_per_increment": 0.00309619077, + "wrist_range_increments": [ + -30000, + 30000 + ], + "wrist_deg_per_increment": 0.00507968798, + "gripper_range_increments": [ + 12780, + 24120 + ], + "gripper_mm_per_increment": 0.00554337, + "rotation_drive_predefined_y_positions_increments": { + "home": 9855, + "lower_limit": 7000, + "upper_limit": 9000, + "parking": 13550, + "pre_parking": 12600, + "extra_1": 9855, + "extra_2": 9855, + "extra_3": 9855, + "extra_4": 9855, + "extra_5": 9855 + }, + "rotation_drive_predefined_z_positions_increments": { + "home": 25600, + "parking": 25400, + "extra_1": 24600, + "extra_2": 24600, + "extra_3": 24600, + "extra_4": 24600, + "extra_5": 24600, + "extra_6": 24600, + "extra_7": 24600, + "extra_8": 24600 + }, + "gripper_drive_predefined_increments": { + "home": 13100, + "extra_1": 24120, + "closed": 12960, + "plate_type_1": 12780, + "plate_type_2": 13100, + "plate_type_3": 13100, + "plate_type_4": 13100, + "plate_type_5": 13100, + "plate_type_6": 13100, + "plate_type_7": 13100 + } + } + } + }, + "autoload": { + "firmware_version": "3.4S f 2017-01-09", + "firmware_date": null, + "autoload_type": "1D barcode scanner", + "y_positions": { + "loading_tray": 0, + "carrier_identification": 1, + "deck": 2 + }, + "z_positions": { + "below": 0, + "above": 1 + }, + "scanner_rotations": { + "vertical": 0, + "horizontal": 1, + "undefined": 2 + }, + "barcode_reading_directions": { + "vertical": 0, + "horizontal": 1 + }, + "barcode_symbologies": null, + "barcode_2d_symbologies": null, + "scan_directions": { + "vertical": 0, + "horizontal": 1, + "omnidirectional": 2, + "vertical and horizontal": 3 + }, + "x_drive_mm_per_increment": 0.1, + "loading_indicators_installed": null, + "initialization_track": 1, + "adjustment_date": "2025-06-11", + "adjusted": true, + "drive_zero_on_the_deck": 100.0, + "reference_point_from_sled_left_edge": 109.0, + "x_drive_range_increments": [ + 0, + 12500 + ], + "x_drive_speed_range_increments": [ + 20, + 3000 + ], + "x_drive_speed_default": 2500, + "x_drive_acceleration_ramp_range": [ + 1, + 3 + ], + "x_drive_acceleration_ramp_default": 3, + "z_drive_mm_per_increment": 0.004166666666666667, + "z_drive_range_increments": [ + 0, + 3000 + ], + "z_drive_speed_range_increments": [ + 20, + 2000 + ], + "z_drive_speed_default": 1750, + "z_drive_acceleration_ramp_range": [ + 1, + 4 + ], + "z_drive_acceleration_ramp_default": 4, + "y_drive_mm_per_increment": 0.06404424, + "y_drive_range_increments": [ + 0, + 9999 + ], + "y_drive_speed_range_increments": [ + 20, + 2500 + ], + "y_drive_speed_default": 2000, + "y_drive_acceleration_ramp_range": [ + 1, + 6 + ], + "y_drive_acceleration_ramp_default": 6, + "motor_current_limit_range": [ + 0, + 7 + ], + "motor_current_limit_default": 7, + "acceleration_ramp_increments_per_second_squared": 2500 + } +} diff --git a/pylabrobot/hamilton/star/driver/recordings/starplus_legacy_2021_8ch_head96.json b/pylabrobot/hamilton/star/driver/recordings/starplus_legacy_2021_8ch_head96.json new file mode 100644 index 00000000000..43b89bb5788 --- /dev/null +++ b/pylabrobot/hamilton/star/driver/recordings/starplus_legacy_2021_8ch_head96.json @@ -0,0 +1,408 @@ +{ + "device": { + "serial_number": "STARplus1", + "firmware_version": "7.6S 25 2021_11_05 (GRU C0)", + "firmware_date": "2021-11-05", + "pip_type_1000ul": true, + "kb_iswap_installed": true, + "main_front_cover_monitoring_installed": false, + "autoload_installed": false, + "wash_station_1_installed": false, + "wash_station_2_installed": false, + "temp_controlled_carrier_1_installed": false, + "temp_controlled_carrier_2_installed": false, + "num_pip_channels": 8, + "left_x_drive_large": true, + "right_x_drive_large": false, + "pump_station_1_installed": false, + "pump_station_2_installed": false, + "wash_station_1_type_cr": false, + "wash_station_2_type_cr": false, + "left_cover_installed": false, + "right_cover_installed": false, + "additional_front_cover_monitoring_installed": false, + "pump_station_3_installed": false, + "multi_channel_nano_pipettor_installed": false, + "xl_channels_installed": false, + "tube_gripper_installed": false, + "waste_direction_left": false, + "iswap_gripper_wide": true, + "additional_channel_nano_pipettor_installed": false, + "imaging_channel_installed": false, + "robotic_channel_installed": false, + "channel_order_ox_first": false, + "x0_interface_ham_can": false, + "park_heads_with_iswap_off": false, + "configuration_data_3": 0, + "instrument_size_slots": 76, + "autoload_size_slots": 0, + "tip_waste_x_position": 1835.0, + "left_arm": { + "pip_installed": true, + "iswap_installed": true, + "head96_installed": true, + "nano_pipettor_installed": false, + "head384_installed": false, + "xl_channels_installed": false, + "tube_gripper_installed": false, + "imaging_channel_installed": false, + "robotic_channel_installed": false, + "gel_card_gripper_installed": false, + "puncher_handler_installed": false, + "width": 354.0, + "x_range": [ + 95.0, + 1835.2 + ], + "workspace_x_range": [ + -323.2, + 2012.2 + ], + "wrap_size": 595.2, + "firmware_version": "1.4S 2012-04-25", + "x_mm_per_increment": 0.1, + "x_range_increments": [ + 0, + 30000 + ], + "acceleration_level_range": [ + 1, + 5 + ], + "acceleration_level_default": 4, + "current_limit_range": [ + 0, + 7 + ], + "current_limit_default": 7 + }, + "right_arm": null, + "min_iswap_collision_free_position": 350.0, + "max_iswap_collision_free_position": 1635.0, + "left_x_arm_width": 354.0, + "right_x_arm_width": 370.0, + "num_xl_channels": 0, + "num_robotic_channels": 0, + "min_raster_pitch_pip_channels": 9.0, + "min_raster_pitch_xl_channels": 36.0, + "min_raster_pitch_robotic_channels": 36.0, + "pip_maximal_y_position": 606.5, + "left_arm_min_y_position": 6.0, + "right_arm_min_y_position": 6.0, + "head96_installed": true, + "head384_installed": false + }, + "arms": { + "left": { + "pipettes": { + "hardware_query_first_year": 2017, + "initialize_y_range": [ + 217.5, + 405.0 + ], + "initialize_begin_of_tip_deposit": 245.0, + "initialize_end_of_tip_deposit": 122.0, + "initialize_z_position_at_end": 360.0, + "initialize_tip_type": 4, + "initialize_discarding_method": 0, + "initialize_read_timeout": 120, + "x_reference_anchor": "c", + "y_reference_anchor": "c", + "z_reference_anchor": "b", + "y_drive_mm_per_increment": 0.046302083, + "z_drive_mm_per_increment": 0.01072765, + "z_range_increments": [ + 9320, + 31200 + ], + "z_range": null, + "dispensing_drive_mm_per_increment": 0.002734375, + "dispensing_drive_uL_per_increment": 0.046876, + "channel_size_z": 140.0, + "channels": [ + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + }, + { + "channel_type": "ML_STAR", + "head_type": "ML_STAR", + "stop_disc_type": "core_ii", + "pressure_adc": "Renesas_X9268", + "firmware_version": "4.0S j 2022-03-16", + "width": 8.98 + } + ] + }, + "head96": { + "module": "H0", + "retract_command": "EV", + "initialize_command": "EI", + "tip_presence_command": "QH", + "position_command": "QI", + "y_parameter": "yh", + "z_parameter": "za", + "z_end_parameter": "ze", + "x_offset_parameter": "kf", + "head_types": { + "0": "Low volume head", + "1": "High volume head", + "2": "96 head II", + "3": "96 head TADM" + }, + "drive_parameters": { + "yv": 5, + "yr": 5, + "zv": 5, + "zr": 6, + "dv": 5, + "dr": 6, + "sv": 5, + "sr": 6 + }, + "first_documented_firmware_year": 2010, + "firmware_version": "5.0S i 2021-10-22 (H0 XE167)", + "firmware_date": "2021-10-22", + "x_offset": 368.2, + "channel_pitch": 9.0, + "channel_columns": 12, + "channel_rows": 8, + "body_size_z": 140.0, + "min_x_clear_of_left_side_panel": -100.0, + "min_tool_bottom_z": 99.98, + "supports_clot_monitoring_clld": false, + "head_type": "96 head II", + "tip_discard_location": null, + "z_drive_mm_per_increment": 0.005, + "y_drive_mm_per_increment": 0.015625, + "y_drive_acceleration_mm_per_increment": 0.015625, + "z_drive_acceleration_mm_per_increment": 0.005, + "dispensing_drive_mm_per_increment": 0.001025641026, + "z_speed_range_increments": [ + 50, + 20000 + ], + "z_acceleration_range_increments": [ + 5000, + 100000 + ], + "predefined_y_position_origin": 0, + "predefined_z_position_origin": 0, + "predefined_y_positions_increments": { + "home": 35485, + "predefined_1": 35000, + "predefined_2": 35000, + "predefined_3": 35000, + "predefined_4": 35000, + "predefined_5": 35000, + "predefined_6": 35000, + "predefined_7": 35000, + "predefined_8": 35000, + "predefined_9": 35000 + }, + "predefined_z_positions_increments": { + "home": 67100, + "predefined_1": 67100, + "predefined_2": 67100, + "predefined_3": 67100, + "predefined_4": 67100, + "predefined_5": 67100, + "predefined_6": 67100, + "predefined_7": 67100, + "predefined_8": 67100, + "predefined_9": 67100 + }, + "traversal_z_position": 245.0, + "y_drive_current_limit_default": 15, + "z_drive_current_limit_default": 15, + "current_limit_range": [ + 0, + 15 + ], + "y_speed_default_increments": 25000, + "y_acceleration_default_increments": 35000, + "z_speed_default_increments": 17000, + "z_acceleration_default_increments": 80000, + "y_drive_speed_firmware_reported": null, + "y_drive_acceleration_firmware_reported": null, + "z_drive_speed_firmware_reported": null, + "z_drive_acceleration_firmware_reported": null, + "dispensing_drive_speed_firmware_reported": null, + "dispensing_drive_acceleration_firmware_reported": null, + "squeezer_drive_speed_firmware_reported": null, + "squeezer_drive_acceleration_firmware_reported": null, + "stop_disc_type": "core_ii", + "instrument_type": "legacy", + "dispensing_drive_uL_per_increment": 0.019340933, + "squeezer_drive_mm_per_increment": 0.0002086672009, + "tip_command_y_range": [ + 108.0, + 560.0 + ], + "tip_engage_correction_low_volume": 2.0, + "tip_engage_correction_other": -2.0, + "tip_drop_clearance": 1.45, + "dispensing_drive_position_before_rack_pickup": 218.19, + "y_increment_floor": 6528 + }, + "iswap": { + "firmware_version": "4.1S 2011-12-19", + "firmware_date": null, + "rotation_drive_x_offset": 32.8, + "rotation_drive_predefined_increments": { + "home": 13000, + "left": -29007, + "front": 156, + "right": 29068, + "parking": 29500, + "extra_1": 29068, + "extra_2": 29068, + "extra_3": 29068, + "extra_4": 29068 + }, + "link_1_length": 137.8, + "wrist_drive_predefined_increments": { + "home": -26577, + "right": -26577, + "straight": -8860, + "left": 9044, + "reverse": 26858, + "parking": -26577, + "extra_1": -26577, + "extra_2": -26577, + "extra_3": -26577 + }, + "tool_length": 137.7, + "y_range_increments": [ + 0, + 14000 + ], + "y_mm_per_increment": 0.046302083, + "y_speed_range_increments": [ + 50, + 8000 + ], + "rotation_drive_diameter": 30.5, + "rotation_drive_safety_radius": 90.0, + "rotation_drive_size_z": 120.0, + "z_range_increments": [ + -187, + 26661 + ], + "z_mm_per_increment": 0.01072765, + "z_speed_range_increments": [ + 50, + 15000 + ], + "z_acceleration_range_increments": [ + 5, + 999 + ], + "rotation_drive_z_offset_above_finger": 13.0, + "rotation_range_increments": [ + -30032, + 30032 + ], + "rotation_deg_per_increment": 0.00309619077, + "wrist_range_increments": [ + -30000, + 30000 + ], + "wrist_deg_per_increment": 0.00507968798, + "gripper_range_increments": [ + 12780, + 24120 + ], + "gripper_mm_per_increment": 0.00554337, + "rotation_drive_predefined_y_positions_increments": { + "home": 9855, + "lower_limit": 7000, + "upper_limit": 9000, + "parking": 13550, + "pre_parking": 12600, + "extra_1": 9855, + "extra_2": 9855, + "extra_3": 9855, + "extra_4": 9855, + "extra_5": 9855 + }, + "rotation_drive_predefined_z_positions_increments": { + "home": 25600, + "parking": 25400, + "extra_1": 24600, + "extra_2": 24600, + "extra_3": 24600, + "extra_4": 24600, + "extra_5": 24600, + "extra_6": 24600, + "extra_7": 24600, + "extra_8": 24600 + }, + "gripper_drive_predefined_increments": { + "home": 13100, + "extra_1": 24120, + "closed": 12960, + "plate_type_1": 12780, + "plate_type_2": 13100, + "plate_type_3": 13100, + "plate_type_4": 13100, + "plate_type_5": 13100, + "plate_type_6": 13100, + "plate_type_7": 13100 + } + } + } + } +} diff --git a/pylabrobot/hamilton/star/driver/simulator.py b/pylabrobot/hamilton/star/driver/simulator.py new file mode 100644 index 00000000000..ca1cc880b56 --- /dev/null +++ b/pylabrobot/hamilton/star/driver/simulator.py @@ -0,0 +1,1203 @@ +"""A STAR that answers without being plugged in. + +Each feature has a small subclass here that overrides the handful of methods which would +otherwise talk to a device, returning what one would have said. `STARSimulationDriver` swaps +those in, so everything above them - discovery, the initialization order, the configuration each +feature resolves - runs exactly as it does against hardware. + +Nothing reaches the wire. `send_command` raises, which is how a command that has not been +simulated makes itself known: override the method that sends it, on the feature that owns it. +""" + +import copy +import datetime +import logging +from typing import Any, Dict, List, Literal, Optional, Tuple, cast + +from pylabrobot.hamilton.protocol.text.framing import ( + assemble_channel_command, + parse_firmware_version_date, +) +from pylabrobot.hamilton.star.driver.configuration import DeviceConfiguration +from pylabrobot.hamilton.star.driver.features.autoload import ( + AUTOLOAD_TYPES, + Autoload, + AutoloadConfiguration, +) +from pylabrobot.hamilton.star.driver.features.cover import CoverPosition, FrontCover +from pylabrobot.hamilton.star.driver.features.head import ( + HEAD_REFERENCE_SHAFT, + Head, + HeadConfiguration, +) +from pylabrobot.hamilton.star.driver.features.head96 import Head96, Head96Configuration +from pylabrobot.hamilton.star.driver.features.head384 import Head384, Head384Configuration +from pylabrobot.hamilton.star.driver.features.iswap import iSWAP, iSWAPConfiguration +from pylabrobot.hamilton.star.driver.features.pipettes import ( + PipetteConfiguration, + Pipettes, + PipettesConfiguration, +) +from pylabrobot.hamilton.star.driver.features.x_arm import XArm +from pylabrobot.hamilton.star.driver.master import STARDriver +from pylabrobot.io.io import IOBase +from pylabrobot.io.validation_utils import LOG_LEVEL_IO +from pylabrobot.resources.carrier import Carrier +from pylabrobot.resources.hamilton.hamilton_decks import ( + HamiltonDeck, +) + +logger = logging.getLogger(__name__) + + +# What stands where a transport's identity would be in the log, so simulated and recorded runs read +# the same way. +SIMULATED_LINK = "[simulation]" + +# Where its two undriven drives report themselves, in mm. Where they actually are is not modelled: +# each answers from its zero. X is not among them - it answers from the deck. +SIMULATED_AUTOLOAD_Y_POSITION = 0.0 +SIMULATED_AUTOLOAD_Z_POSITION = 0.0 + +# The two diagnostic reads that exist to show what a real unit holds. A simulated one holds nothing, +# and says so rather than inventing a block for a caller to read meaning into. +SIMULATED_AUTOLOAD_ADJUSTMENT_VALUES = "[simulation] no adjustment values" +SIMULATED_AUTOLOAD_PARAMETER_VALUE = "[simulation]" + +# Whether the front cover is shut. A simulated device is not being reached into. +SIMULATED_COVER_POSITION: CoverPosition = "closed" + +# The three inputs on the cover connector: the cover input, and two whose meaning is not known. +SIMULATED_COVER_INPUTS = (True, False, False) + +# What its scanner reads. A simulated deck holds no carriers, so nothing. +SIMULATED_BARCODE: Optional[str] = None + + +class _UnusedTransport(IOBase): + """Stands where the transport would be. Nothing should reach it.""" + + async def setup(self, *args, **kwargs): + pass + + async def stop(self): + pass + + async def write(self, data: bytes, *args, **kwargs): + raise RuntimeError(f"the simulator tried to write to a transport: {data!r}") + + async def read(self, *args, **kwargs) -> bytes: + raise RuntimeError("the simulator tried to read from a transport") + + +class _Simulated: + """Reaches the device behind a feature, which for a simulated one is the simulator.""" + + _driver: STARDriver + + @property + def device(self) -> "STARSimulationDriver": + return cast("STARSimulationDriver", self._driver) + + async def recorded(self, module: str, command: str, **kwargs: Any) -> None: + """Put a command on the link so it is recorded, without an answer. + + For a read the model can answer outright. The command is assembled and logged exactly as a + move is, and the value is returned by the caller rather than written into a reply for the + caller to take apart again: a reply invented here would only ever be read by the parser that + invented it. + + Args: + module: the module to address. + command: the two-letter command code. + kwargs: the command's own parameters, so the bytes logged are the bytes that would go. + """ + await self._driver.send_command(module=module, command=command, **kwargs) + + async def answer(self, module: str, command: str, **kwargs: Any) -> Optional[Tuple[Any, str]]: + """What this feature would answer the command with, taken from the model. + + The link asks each feature in turn, so a read assembles and logs its command exactly as a + move does, rather than being intercepted before it becomes one. The answer is in the shape + the caller's format implies - the parsed reply, or the raw one where no format was given - + because that is what the real read is about to work on. + + Args: + module: the module the command was addressed to. + command: the two-letter command code. + kwargs: the command's own parameters, for the reads that vary by one. + + Returns: + The answer, and where in the model it came from. None when this feature does not answer + that command, which is every command that only moves. + """ + return None + + +class SimulatedPipettes(_Simulated, Pipettes): + """The pipetting channels, answering for themselves.""" + + def _declared_channel(self, channel: int) -> PipetteConfiguration: + """What this device was told sits on a channel, or the channel this frame documents. + + Args: + channel: which channel, 0-indexed from the back. + + Returns: + The channel to answer from. + """ + declared = self.device.simulated_pipettes + if declared is not None and channel < len(declared.channels): + return declared.channels[channel] + return PipetteConfiguration() + + async def initialize(self, *args, **kwargs): + """Whatever was mounted on the channels comes off. Goes through the command path, so it is + coordinated like the real one.""" + await super().initialize(*args, **kwargs) + self.device.tips_mounted = [False] * len(self.device.tips_mounted) + + def _modelled_y(self, channel: int) -> float: + """Where the model has one channel along Y, in mm. + + Args: + channel: which channel, 0-indexed from the back. + + Returns: + Where the model has it, or where initialization spreads it when nothing models it yet. + """ + point = self.get_reference_point_location(channel) + return self.default_initialize_y_positions()[channel] if point is None else point.y + + def _modelled_z(self, channel: int) -> float: + """Where the model has one channel's stop disc along Z, in mm. + + Args: + channel: which channel, 0-indexed from the back. + + Returns: + Where the model has it, or the top of the drive's window when nothing models it yet, which + is where Z safety leaves a device that has just been switched on. + """ + point = self.get_reference_point_location(channel) + return self.configuration.z_range[1] if point is None else point.z + + async def answer(self, module: str, command: str, **kwargs: Any) -> Optional[Tuple[Any, str]]: + """What a read of the channels answers, taken from the model. + + The channels keep no positions of their own here: the model is where a channel is, and a read + finds it there. What puts it there is the move, which records as it does on a device. + """ + c = self.configuration + if module == "C0": + if command == "RY": + return ( + {"ry": [round(self._modelled_y(channel) * 10) for channel in range(self.num_channels)]}, + "where the model has the channels along Y", + ) + + if command == "RT": + return {"rt": [int(mounted) for mounted in self.device.tips_mounted]}, "what is mounted" + + if command == "RZ": + # The master reports the bottom of whatever a channel carries. A simulated channel has no + # tip geometry, so that is its stop disc, which is what the model holds; on a device the + # two part company the moment a tip goes on, which is why both reads exist. + return ( + {"rz": [round(self._modelled_z(channel) * 10) for channel in range(self.num_channels)]}, + "where the model has the channels along Z", + ) + + return None + + channel = self.channel_from_module(module) + if channel is None or channel >= self.num_channels: + return None + + if command == "VY": + width = self._declared_channel(channel).width + if width is None: + raise RuntimeError( + f"the simulated channel {channel} has no width; set it on its configuration" + ) + # The drive answers twice; the read takes the second. + increments = c.y_drive_mm_to_increments(width) + return {"yc": [increments, increments]}, f"channel {channel}'s declared width" + + if command == "RZ": + return ( + {"rz": c.z_drive_mm_to_increments(self._modelled_z(channel))}, + f"where the model has channel {channel}'s stop disc", + ) + + return None + + async def probe_z_max(self) -> Dict[int, float]: + # The firmware retract inside the probe is what puts the channels at their ceiling, and the + # probe reads them back before it returns, so the model is written before the read rather + # than after. `move_to_safe_z` needs no override: it is an ordinary move, recorded below. + for channel in range(self.num_channels): + self.update_location_by_reference_point(channel, z=self.configuration.z_range[1]) + return await super().probe_z_max() + + async def _unchecked_fw_move_lowest_point_to_z_positions(self, zs: Dict[int, float]): + # A move is what puts a channel somewhere. Written after the move, not before: one the real + # method refuses never happened. A simulated channel carries no tip, so the lowest point it + # has is its stop disc, which is what the model holds. + resp = await super()._unchecked_fw_move_lowest_point_to_z_positions(zs) + for channel, z in zs.items(): + self.update_location_by_reference_point(channel, z=z) + return resp + + async def move_stop_disc_to_z_position(self, channel: int, z: float, *args: Any, **kwargs: Any): + resp = await super().move_stop_disc_to_z_position(channel, z, *args, **kwargs) + self.update_location_by_reference_point(channel, z=z) + return resp + + async def request_firmware_version(self, channel: int) -> Tuple[str, datetime.date]: + # What the channel was declared to run, where the declaration says: a recording taken with + # `save_configuration` carries no channel firmware, because it is a field discovery fills from + # this very read, so the simulator's own table stands in where it is not there. + await self.recorded(self.channel_id(channel), "RF") + declared = self._declared_channel(channel).firmware_version + if declared is None: + raise RuntimeError( + f"the simulated channel {channel} has no firmware version; set it on its configuration" + ) + return declared, parse_firmware_version_date(declared) + + async def request_pipette_configuration(self, channel: int) -> PipetteConfiguration: + # Only what the read reports: the rest of a channel's configuration is filled by discovery + # from other reads, as it is on a device. + await self.recorded(self.channel_id(channel), "VW") + declared = self._declared_channel(channel) + return PipetteConfiguration( + channel_type=declared.channel_type, + head_type=declared.head_type, + stop_disc_type=declared.stop_disc_type, + pressure_adc=declared.pressure_adc, + ) + + +# Where the left arm has come to rest when a simulated device is switched on, in mm: far enough +# along the rail to sit within reach of any STAR deck. The right arm rests at the far end of its +# own travel instead, so the two do not overlap on a device that has both. Setup reads this once +# to seat each arm on the deck; every read after that answers from the deck. +SIMULATED_LEFT_X_ARM_POSITION = 362.9 + + +class SimulatedXArm(_Simulated, XArm): + """An X-arm, answering for itself.""" + + async def answer(self, module: str, command: str, **kwargs: Any) -> Optional[Tuple[Any, str]]: + arm_number = "1" if self.side == "left" else "2" + if module == "X0" and command == "QW" and kwargs.get("mn") == arm_number: + return {"qw": 1}, f"the {self.side} X-arm, which a simulated device keeps initialized" + # Both arms are answered in one reply, so the first arm answers it for the device. + if module != "C0" or command not in ("RU", "UA") or self is not self.device.arms[0]: + return None + declared = self.device.simulated_configuration + arms = (declared.left_arm, declared.right_arm) + + def tenths(value: Optional[float]) -> int: + return 0 if value is None else round(value * 10) + + if command == "RU": + values = [tenths(v) for arm in arms for v in (arm.x_range if arm and arm.x_range else (0, 0))] + return "ru" + " ".join(str(v) for v in values), "the declared arms' X ranges" + wraps = [tenths(arm.wrap_size if arm else None) for arm in arms] + workspaces = [ + tenths(v) + for arm in arms + for v in (arm.workspace_x_range if arm and arm.workspace_x_range else (0, 0)) + ] + return "ua" + " ".join(str(v) for v in wraps + workspaces), "the declared arms' envelopes" + + async def request_firmware_version(self) -> Tuple[str, datetime.date]: + declared = self.configuration.firmware_version + if declared is None: + raise RuntimeError( + f"the simulated {self.side} X-arm has no firmware version; set it on its configuration" + ) + return declared, parse_firmware_version_date(declared) + + async def request_position(self) -> float: + # Where the arm is is what the model says: a simulated device has no drive to ask. Until setup + # has put it on the deck there is nothing to read, and it answers where it powered up. + if self.resource is not None and self.resource.location is not None: + return self.resource.location.x + self.configuration.reference_point_from_left + if self.side == "left" or self.configuration.x_range is None: + return SIMULATED_LEFT_X_ARM_POSITION + return self.configuration.x_range[1] + + +class _SimulatedHead(_Simulated, Head): + """What a head answers when there is no head: the same for either of them. + + Each head says which simulated configuration it answers from and where a retract leaves it; what + its configuration bytes mean is its own, as on a device. + """ + + _firmware_key: str + _label: str + + @property + def _z_safety(self) -> float: + """Where a retract leaves this head, in mm: the top of the window its configuration states. + + Returns: + The Z its drive comes to rest at. + """ + return self.configuration.z_range[1] + + @property + def _declared(self) -> HeadConfiguration: + """The head this device was told it has. + + Distinct from `configuration`, which discovery fills from these answers exactly as it would + off an device. + """ + raise NotImplementedError("a simulated head says which configuration it answers from") + + def _stored(self, table: Literal["py", "pz"]) -> Dict[str, int]: + """One of the head's stored position tables, as the ten slots the device holds. + + Args: + table: which table, `py` along Y or `pz` along Z. + + Returns: + Each slot in increments, keyed as `configuration.predefined_y_slots` names them. + + Raises: + RuntimeError: If the head was declared without that table, so there is nothing to answer. + """ + axis = "y" if table == "py" else "z" + stored = getattr(self._declared, f"predefined_{axis}_positions_increments") + if stored is None: + raise RuntimeError( + f"the simulated {self._label} has no {table} table; set it on its configuration" + ) + return cast(Dict[str, int], stored) + + async def answer(self, module: str, command: str, **kwargs: Any) -> Optional[Tuple[Any, str]]: + c = self.configuration + if module == "C0" and command == "RA" and kwargs.get("ra") == c.x_offset_parameter: + x_offset = self._declared.x_offset + if x_offset is None: + raise RuntimeError( + f"the simulated {self._label} has no X offset; set it on its configuration" + ) + return {c.x_offset_parameter: round(x_offset * 10)}, f"the {self._label}'s declared X offset" + + if module == "C0" and command in (c.tip_presence_command, c.position_command): + deck = self.device.deck + if self.resource is None or self.resource.location is None or deck is None: + raise RuntimeError(f"the simulated {self._label} is not modelled, so it has nothing to say") + if command == c.tip_presence_command: + carries = any(shaft.has_tip() for shaft in self.resource.get_all_items()) + return {command.lower(): int(carries)}, f"whether the {self._label} is modelled with tips" + # Channel A1, at the bottom of whatever it carries, as the master reports it. + shaft = self.resource.get_item(HEAD_REFERENCE_SHAFT) + a1 = shaft.get_location_wrt(deck) + z = a1.z + shaft.tip_bottom().z + return ( + { + "xs": abs(round(a1.x * 10)), + "xd": 0 if a1.x >= 0 else 1, + c.y_parameter: round(a1.y * 10), + c.z_parameter: round(z * 10), + }, + f"where the {self._label}'s channel A1 is modelled", + ) + + if module != c.module: + return None + + if command == "QG": + head_type = self._declared.head_type + if head_type is None: + raise RuntimeError(f"the simulated {self._label} has no type; set it on its configuration") + code = next((k for k, name in c.head_types.items() if name == head_type), None) + if code is None: + raise RuntimeError(f"{head_type!r} is not one of the types {self._label} reports") + return {"qg": code}, f"the {self._label}'s declared type" + + if command == "RY": + # From the model where there is one, as the real read reports the drive. The drive answers + # in the deck's frame, so the model is read in the deck's too - the resource hangs off the + # arm, whose own position would otherwise come through. Before setup has put the head on the + # arm there is nothing to read, and it answers from the middle of its travel. + deck = self.device.deck + if self.resource is not None and self.resource.location is not None and deck is not None: + y = round(self.resource.get_item(HEAD_REFERENCE_SHAFT).get_location_wrt(deck).y, 2) + else: + # Nothing models it yet, so where it parks: the home slot of its own stored table. + y = c.y_drive_increments_to_mm(self._stored("py")["home"] + c.predefined_y_position_origin) + increments = c.y_drive_mm_to_increments(y) + # The drive answers twice; the read takes the second. + return {"ry": [increments, increments]}, f"where the {self._label} is modelled along Y" + + if command == "RZ": + deck = self.device.deck + if self.resource is not None and self.resource.location is not None and deck is not None: + z = round(self.resource.get_item(HEAD_REFERENCE_SHAFT).get_location_wrt(deck).z, 2) + else: + z = self._z_safety + increments = c.z_drive_mm_to_increments(z) + return {"rz": [increments, increments]}, f"where the {self._label} is modelled along Z" + + if command == "RA": + parameter = cast(str, kwargs.get("ra")) + if parameter in ("py", "pz"): + # As a head holds them: the position it parks at first, then nine slots nothing here + # commands against. Answered as the drive stores them, offsets from its own origin + # included, which is how the read converts them back. + stored = self._stored(cast(Literal["py", "pz"], parameter)) + declared = self._declared + names = declared.predefined_y_slots if parameter == "py" else declared.predefined_z_slots + return ( + {parameter: [stored[name] for name in names]}, + f"the {self._label}'s stored {parameter} table", + ) + value = self._simulated_drive_parameter(parameter) + return ( + {parameter: self._drive_parameter_to_increments(parameter, value)}, + f"the {self._label}'s {parameter} default", + ) + return None + + async def request_firmware_version(self) -> Tuple[str, datetime.date]: + await self.recorded(self.configuration.module, "RF") + version = self._declared.firmware_version + if version is None: + raise RuntimeError(f"the simulated {self._label} has no firmware version declared") + return version, parse_firmware_version_date(version) + + def _simulated_drive_parameter(self, parameter: str) -> float: + """What this head holds in a drive register, for the link to answer with. + + Guarded as the real read guards it: a name the head does not store is a caller's mistake, and + should say so here as it would there rather than raising a lookup error. + + Args: + parameter: the two-letter register name. + + Returns: + The value in mm/s or mm/s2. + + Raises: + RuntimeError: If this head was declared without a default for it. + """ + self.require_drive_parameter(parameter) + head = self._declared + default = { + "yv": head.y_drive_speed_default, + "yr": head.y_drive_acceleration_default, + "zv": head.z_drive_speed_default, + "zr": head.z_drive_acceleration_default, + }.get(parameter) + if default is None: + raise RuntimeError( + f"the simulated {self._label} has no {parameter} default; set it on its config" + ) + return default + + async def probe_z_max(self, *args: Any, **kwargs: Any) -> float: + # The firmware retract inside the probe is what puts the head at its safety height. Its own + # `move_to_safe_z` needs no such override: it is an ordinary move, which this already records. + self.update_location_by_reference_point(z=self._z_safety) + return await super().probe_z_max(*args, **kwargs) + + async def move_to_y_position(self, y: float, *args: Any, **kwargs: Any): + # A move is what puts the head somewhere. On the device the drive holds that and the read + # reports it; here the model holds it, so the move writes it and the read finds it there. + # Written after the move, not before: one the real method refuses never happened, and a model + # updated first would put the head where it was told to go rather than where it is. + resp = await super().move_to_y_position(y, *args, **kwargs) + self.update_location_by_reference_point(y=y) + return resp + + async def move_stop_disc_to_z_position(self, z: float, *args: Any, **kwargs: Any): + resp = await super().move_stop_disc_to_z_position(z, *args, **kwargs) + self.update_location_by_reference_point(z=z) + return resp + + async def initialize(self, *args, **kwargs): + """Whatever was mounted on the head comes off, and it reports itself up. Goes through the + command path, so it is coordinated like the real one.""" + await super().initialize(*args, **kwargs) + self.device.initialized[self.configuration.module] = True + + +class SimulatedHead96(_SimulatedHead, Head96): + """The 96-head, answering for itself.""" + + _firmware_key = "head96" + _label = "96-head" + + @property + def _declared(self) -> Head96Configuration: + return self.device.simulated_head96 + + async def request_hardware(self) -> List[str]: + # Rendered from what this head is, rather than written out separately: a head configured + # differently answers differently. + await self.recorded(self.configuration.module, "QU") + head = self._declared + return [ + "1" if head.supports_clot_monitoring_clld else "0", + "0" if head.stop_disc_type == "core_i" else "1", + "0" if head.instrument_type == "legacy" else "1", + ] + ["0"] * 7 + + def _simulated_drive_parameter(self, parameter: str) -> float: + # The dispensing and squeezer drives have no register to read, so they answer with what this + # head's firmware documents. + head = self._declared + documented = { + "dv": head.dispensing_drive_speed_default, + "dr": head.dispensing_drive_acceleration_default, + "sv": head.squeezer_drive_speed_default, + "sr": head.squeezer_drive_acceleration_default, + } + if parameter in documented: + return documented[parameter] + return super()._simulated_drive_parameter(parameter) + + +class SimulatedHead384(_SimulatedHead, Head384): + """The 384-head, answering for itself.""" + + _firmware_key = "head384" + _label = "384-head" + + @property + def _declared(self) -> Head384Configuration: + return self.device.simulated_head384 + + async def request_hardware(self) -> List[str]: + # Rendered as the 96-head's is, from the two flags this head reports. + await self.recorded(self.configuration.module, "QU") + head = self._declared + return [ + "1" if head.supports_clot_monitoring_clld else "0", + "1" if head.supports_lld_absolute_threshold_check else "0", + ] + ["0"] * 8 + + +class SimulatedISWAP(_Simulated, iSWAP): + """The iSWAP, answering for itself.""" + + async def answer(self, module: str, command: str, **kwargs: Any) -> Optional[Tuple[Any, str]]: + """Answer a read from the model. + + A simulated iSWAP keeps no positions of its own. Its moves record where they were sent once + the command is away, so a read finds the model already holding it; until one has, the answers + are where a device that has just been switched on is parked. + """ + c = self.configuration + if module == "R0": + if command == "RW": + angle = self.rotation_drive_get_angle() + if angle is not None: + return ( + {"rw": c.rotation_drive_angle_to_increments(angle)}, + "which way the model has the arm pointing", + ) + stops = (await self._request_slots("pw"))[: len(c.rotation_drive_slots)] + parked = dict(zip(c.rotation_drive_slots, stops))["parking"] + return {"rw": parked}, "the rotation drive's parking stop" + + if command == "RY": + point = self.rotation_drive_get_reference_point_location() + if point is None: + # Nothing models it yet, so where an initialized device leaves it: its parking stop, out + # of the stored table rather than a position written down here. + stops = (await self._request_slots("py"))[: len(c.rotation_drive_y_slots)] + parked = dict(zip(c.rotation_drive_y_slots, stops))["parking"] + return {"ry": [parked, parked]}, "the rotation drive's parking stop" + increments = c.y_mm_to_increments(point.y) + # Two counters come back, the firmware's and the hardware's; the read takes the hardware. + return {"ry": [increments, increments]}, "where the model has the rotation drive along Y" + + if command == "RZ": + point = self.rotation_drive_get_reference_point_location() + if point is None: + # Nothing models it yet, so where an initialized device leaves it: its parking stop, out + # of the stored table rather than a height written down here. + stops = (await self._request_slots("pz"))[: len(c.rotation_drive_z_slots)] + parked = dict(zip(c.rotation_drive_z_slots, stops))["parking"] + return {"rz": [parked, parked]}, "the rotation drive's parking stop" + increments = c.z_mm_to_increments(point.z - c.rotation_drive_z_offset_above_finger) + return {"rz": [increments, increments]}, "where the model has the rotation drive along Z" + if command == "RT": + angle = self.wrist_drive_get_angle() + if angle is not None: + return ( + {"rt": c.wrist_deg_to_increments(angle)}, + "which way the model has the wrist turned", + ) + # Nothing models it yet, so where an initialized arm leaves it: its parking stop, out of + # the stored table rather than an angle written down here. + stops = (await self._request_slots("pt"))[: len(c.wrist_drive_slots)] + parked = dict(zip(c.wrist_drive_slots, stops))["parking"] + return {"rt": parked}, "the wrist drive's parking stop" + if command == "RH": + # Nothing models the force sensor: a simulated arm meets nothing, so its peaks are its + # idle reading and its last measurement is nothing at all. + return {"rh": [0, 0, 0, 0, 0]}, "a simulated arm meets nothing, so it feels nothing" + + if command == "RG": + # The drive answers twice, a target and an actual; the read takes the second. + gripper = self.gripper + if gripper is None: + # Nothing models the jaws, so where an initialized gripper leaves them: the width it + # homes and parks at, out of the stored table rather than written down here. + stops = (await self._request_slots("pg"))[: len(c.gripper_drive_slots)] + home = dict(zip(c.gripper_drive_slots, stops))["home"] + return {"rg": [home, home]}, "the gripper's home and parking width" + width = c.gripper_mm_to_increments(gripper.jaw_width) + return {"rg": [width, width]}, "how far the model has the jaws open" + if (module, command) == ("C0", "QP"): + # Whether the arm holds something is whether the model has anything hanging off the gripper + # that is not part of the gripper: its body and its two fingers are its own. + gripper = self.gripper + held = False + if gripper is not None: + own = {gripper.body, *gripper.fingers} + held = any(child not in own for child in gripper.children) + return {"ph": int(held)}, "whether the model has anything in the gripper" + + if (module, command) == ("C0", "RA") and kwargs.get("ra") == "kg": + offset = self._declared.rotation_drive_x_offset + if offset is None: + raise RuntimeError("the simulated iSWAP has no X offset; set it on its configuration") + return {"kg": round(offset * 10)}, "the X offset it was declared with" + return None + + async def _unchecked_fw_position_components_for_free_y_range(self): + # The master packs the channels as far forward as they fit. Written before the command, as + # the retract inside a probe is. + pipettes = self.arm.pipettes + widths = [] if pipettes is None else [c.width for c in pipettes.configuration.channels] + if pipettes is not None and not any(w is None for w in widths): + floor = cast(DeviceConfiguration, self.device.configuration).left_arm_min_y_position + for channel in range(len(widths)): + packed = floor + sum(cast(List[float], widths[channel + 1 :])) + pipettes.update_location_by_reference_point(channel, y=packed) + return await super()._unchecked_fw_position_components_for_free_y_range() + + async def request_firmware_version(self) -> Tuple[str, datetime.date]: + await self.recorded("R0", "RF") + version = self._declared.firmware_version + if version is None: + raise RuntimeError("the simulated iSWAP has no firmware version declared") + return version, parse_firmware_version_date(version) + + @property + def _declared(self) -> iSWAPConfiguration: + """The iSWAP this device was told it has. + + Distinct from `configuration`, which discovery fills from these answers exactly as it would + off an device. + """ + return self.device.simulated_iswap + + async def _request_slots(self, table: str) -> List[int]: + # Rendered from what this iSWAP is, rather than written out separately: discovery reads these + # tables back into the stops and link lengths, so an iSWAP configured differently answers + # differently. Each table carries its drive's stops, then that link's length in tenths. + declared = self._declared + if table == "py": + stops, length = declared.rotation_drive_predefined_y_positions_increments, None + names: Tuple[str, ...] = declared.rotation_drive_y_slots + elif table == "pz": + stops, length = declared.rotation_drive_predefined_z_positions_increments, None + names = declared.rotation_drive_z_slots + elif table == "pg": + stops, length = declared.gripper_drive_predefined_increments, None + names = declared.gripper_drive_slots + elif table == "pw": + stops, length = declared.rotation_drive_predefined_increments, declared.link_1_length + names = declared.rotation_drive_slots + else: + stops, length = declared.wrist_drive_predefined_increments, declared.tool_length + names = declared.wrist_drive_slots + if stops is None: + raise RuntimeError(f"the simulated iSWAP has no {table} table; set it on its configuration") + rendered = [stops[name] for name in names] + return rendered if length is None else rendered + [round(length * 10)] + + async def initialize(self): + """Goes through the command path, so it is coordinated like the real one.""" + await super().initialize() + self.device.initialized["R0"] = True + + +class SimulatedFrontCover(_Simulated, FrontCover): + """The front cover, answering for itself: it is shut.""" + + async def answer(self, module: str, command: str, **kwargs: Any) -> Optional[Tuple[Any, str]]: + if (module, command) == ("C0", "QC"): + codes = self.configuration.position_codes + return {"qc": codes[SIMULATED_COVER_POSITION]}, "the cover it is simulated at" + return None + + +class SimulatedAutoload(_Simulated, Autoload): + """The autoload, answering for itself. Its deck and its loading tray are empty.""" + + def _modelled_track(self) -> int: + """Which track the sled is modelled on. + + Worked out from where the model has it rather than kept alongside: the sled's resource is + what moves, and a track is a place on the deck. The nearest track to where it sits is the one + it is on, since a move puts it on one exactly. Before setup has placed it there is nothing to + read, and it answers the track it homes against. + + Returns: + The track, counted from 1. + """ + deck = cast(HamiltonDeck, self.device.deck) + if self.resource is None or self.resource.location is None: + # Nothing models it yet, so where an initialized autoload leaves it: parked, at the last + # track. Initialization parks the sled and only then is the model built from what this + # answers, so answering the first track puts the sled at the wrong end of the deck. + return self.track_range[-1] + x = self.resource.location.x + self.configuration.reference_point_from_sled_left_edge + return min(self.track_range, key=lambda t: abs(deck.track_to_location(t).x - x)) + + async def answer(self, module: str, command: str, **kwargs: Any) -> Optional[Tuple[Any, str]]: + declared = self.device.simulated_autoload + c = self.configuration + + if module == "C0": + if command == "CQ": + autoload_type = declared.autoload_type + if autoload_type is None: + raise RuntimeError("the simulated autoload has no type; set it on its configuration") + code = next((k for k, name in AUTOLOAD_TYPES.items() if name == autoload_type), None) + if code is None: + raise RuntimeError(f"{autoload_type!r} is not a type the autoload reports") + return {"cq": code}, "the autoload it was declared to be" + if command == "QA": + return {"qa": self._modelled_track()}, "the track the sled is modelled on" + return None + + if module != "I0": + return None + + if command == "QW": + return {"qw": int(self.device.initialized["I0"])}, "whether it has been initialized" + + if command == "QX": + if declared.initialization_track is None: + raise RuntimeError( + "the simulated autoload has no initialization_track; set it on its configuration" + ) + return {"bx": declared.initialization_track}, "the track it was declared to home against" + + if command == "RJ": + if declared.adjustment_date is None or declared.adjusted is None: + raise RuntimeError( + "the simulated autoload does not say whether it is adjusted; set adjustment_date and " + "adjusted on its configuration" + ) + return ( + {"jd": declared.adjustment_date.isoformat(), "js": int(declared.adjusted)}, + "the adjustment it was declared with", + ) + + if command == "RA": + parameter = cast(str, kwargs.get("ra")) + if parameter == "au": + # What this device's own autoload answered: the 0.1 mm scanner, indicators fitted. + return ( + {"au": [0 if declared.x_drive_mm_per_increment == 0.1 else 1, 0, 0, 0, 0]}, + "the module configuration it was declared with", + ) + return ( + f"I0RA{parameter}{SIMULATED_AUTOLOAD_PARAMETER_VALUE}", + f"the {parameter} parameter it stores", + ) + + # The drives answer twice, the firmware counter then the hardware one, and the read takes the + # second. Where each is is what the model says: a simulated device has no drive to ask. + if command == "RX": + # The model is placed around the carrier-handling wheel, so the wheel stands that far right + # of its left edge. Before setup has put the sled on the deck there is no model to read, and + # the track it is on is what it has instead - which is how a park during initialization + # survives long enough to reach the resource created after it. + if self.resource is not None and self.resource.location is not None: + x = self.resource.location.x + c.reference_point_from_sled_left_edge + else: + x = cast(HamiltonDeck, self.device.deck).track_to_location(self._modelled_track()).x + # The X drive is the one drive here that does not count in the deck's coordinates, so the + # deck position the model holds is put back into the drive's frame before it is encoded. + # Answered in the deck's, the read comes back a drive zero too far right - past the end of + # the drive's own travel, so the next absolute move is refused. + increments = c.x_drive_mm_to_increments(c.from_deck_frame(x)) + return {"rx": [increments, increments]}, "where the sled is modelled" + if command == "RS": + # Nothing models which way the scanner faces, and the drive has a code for exactly that: it + # sits at neither of its two stops. Answering one of them would be inventing a facing. + return {"rs": c.scanner_rotations["undefined"]}, "no modelled scanner facing" + if command == "RY": + increments = c.y_drive_mm_to_increments(SIMULATED_AUTOLOAD_Y_POSITION) + return {"ry": [increments, increments]}, "where the wheel is modelled along Y" + if command == "RZ": + increments = c.z_drive_mm_to_increments(SIMULATED_AUTOLOAD_Z_POSITION) + return {"rz": [increments, increments]}, "where the wheel is modelled along Z" + return None + + async def request_firmware_version(self) -> Tuple[str, datetime.date]: + await self.recorded("I0", "RF") + version = self.device.simulated_autoload.firmware_version + if version is None: + raise RuntimeError( + "the simulated autoload has no firmware version; set it on its configuration" + ) + return version, parse_firmware_version_date(version) + + async def request_adjustment_values(self) -> str: + """Answer the adjustment block, which a simulated unit does not hold.""" + await self.recorded("I0", "RK") + return SIMULATED_AUTOLOAD_ADJUSTMENT_VALUES + + async def request_parameter(self, parameter: str) -> str: + """Answer a stored parameter by name. + + Args: + parameter: the name to read. + + Returns: + What the module holds for it. + """ + await self.recorded("I0", "RA", ra=parameter) + return SIMULATED_AUTOLOAD_PARAMETER_VALUE + + async def request_latest_barcode_read(self) -> Optional[str]: + await self.recorded("I0", "RB") + return SIMULATED_BARCODE + + def _carrier_tracks(self) -> List[int]: + """Which tracks hold a carrier, from the deck rather than from a sensor. + + A simulated device has no sensor to read, so what is on the deck is what the resource model + says is on it. Each carrier is reported by the track its right rail sits over, which is the + track every autoload command addresses it by. + """ + deck = self.device.deck + if deck is None: + return [] + carriers = [child for child in deck.children if isinstance(child, Carrier)] + return sorted(deck.compute_right_track_of_carrier(carrier) for carrier in carriers) + + async def sense_carrier_presence_on_deck(self) -> List[int]: + await self.recorded("C0", "RC") + return self._carrier_tracks() + + async def sense_carrier_presence_on_loading_tray(self) -> List[int]: + # Nothing is on the tray: a carrier the model holds is on the deck, which is where it was + # assigned. Moving one there is a move, and moves are modelled where they happen. + await self.recorded("C0", "CS", subsystem="I0") + return [] + + async def sense_carrier_presence_on_single_loading_tray_track( + self, track: int, park_after: bool = True + ) -> bool: + await self.recorded("C0", "CT", subsystem="I0", cp=f"{track:02}") + return False + + async def move_x( + self, + x: float, + speed: Optional[float] = None, + acceleration_ramp: Optional[int] = None, + current_limit: Optional[int] = None, + ) -> Any: + # A simulated drive goes exactly where it is told. The real one is read back afterwards, which + # is what `Autoload` relies on, so the position has to be true here before that read happens or + # the read returns the position the sled started at and it never moves. + resp = await super().move_x( + x, speed=speed, acceleration_ramp=acceleration_ramp, current_limit=current_limit + ) + self.update_location_by_reference_point(x) + return resp + + async def load_carrier_from_tray_and_scan_carrier_barcode( + self, track: int, *args, **kwargs + ) -> Optional[str]: + return SIMULATED_BARCODE + + async def load_carrier_from_autoload_belt( + self, barcode_reading: bool = False, *args, **kwargs + ) -> Dict[int, Optional[str]]: + """The containers read nothing, and there are as many as were asked for.""" + if not barcode_reading: + return {} + containers = kwargs.get("containers_per_carrier", 5) + return {position: SIMULATED_BARCODE for position in range(containers)} + + async def initialize(self, park_after: bool = True): + await super().initialize(park_after=park_after) + self.device.initialized["I0"] = True + + async def move_to_track(self, track: int, *args, **kwargs): + # As `move_x` records where a position move put the sled, so this records where a track move + # did. The deck is what knows where a track is. + await super().move_to_track(track, *args, **kwargs) + # A simulated device is built with a deck or refuses to be built at all, so there is one. + deck = cast(HamiltonDeck, self.device.deck) + self.update_location_by_reference_point(deck.track_to_location(track).x) + + +class STARSimulationDriver(STARDriver): + """A simulated STAR, driven exactly like the real one.""" + + def __init__( + self, + tips_mounted: Optional[List[bool]] = None, + deck: Optional[HamiltonDeck] = None, + initialized: bool = False, + left_side_panel_installed: bool = False, + declared_configuration_json: Optional[str] = None, + ): + """ + Args: + tips_mounted: one entry per channel, `True` where a tip sits on the channel. Defaults to no + tips on any of them. + deck: the deck to reflect this device into. Required: a simulated device has no firmware + to ask, so the resource model is the only thing it can answer from. + initialized: whether the device and its modules report themselves already initialized. One + that has just been switched on does not. + left_side_panel_installed: whether this device has its left side panel on. Declared rather + than discovered, as on a real one: the panel comes off in seconds. + declared_configuration_json: path to a declared configuration, which this device then answers + as. What the arguments above name takes precedence over it, and what neither names falls + back to what this frame documents. + + Raises: + ValueError: If no deck is given, or `tips_mounted` does not have one entry per channel. + """ + if deck is None: + raise ValueError("a simulated STAR answers from its resource model, so it needs a deck") + super().__init__( + io=_UnusedTransport(), + deck=deck, + left_side_panel_installed=left_side_panel_installed, + declared_configuration_json=declared_configuration_json, + ) + + # What the declaration says this device carries, whichever arm carries it. A simulated device + # stands in for one of each, so the same feature on two arms is refused rather than half-read. + carried: Dict[str, Any] = {} + for side, features in self.declared.get("arms", {}).items(): + for name, feature in features.items(): + if name in carried: + raise ValueError( + f"the declared configuration has a {name} on more than one arm; a simulated device " + f"stands in for one of each, so it cannot answer as this one (seen again on {side})" + ) + carried[name] = feature + + configuration = self.declared.get("device") + if configuration is None: + raise ValueError( + "a simulated device has to be told what it is simulating: pass " + "`declared_configuration_json`, naming a file that records one" + ) + self.simulated_configuration: DeviceConfiguration = configuration + + self.simulated_autoload: AutoloadConfiguration = ( + self.declared.get("autoload") or AutoloadConfiguration() + ) + self.simulated_head96: Head96Configuration = carried.get("head96") or Head96Configuration() + self.simulated_head384: Head384Configuration = carried.get("head384") or Head384Configuration() + self.simulated_pipettes: Optional[PipettesConfiguration] = carried.get("pipettes") + self.simulated_iswap: iSWAPConfiguration = carried.get("iswap") or iSWAPConfiguration() + + channels = self.simulated_configuration.num_pip_channels + if tips_mounted is None: + tips_mounted = [False] * channels + if len(tips_mounted) != channels: + raise ValueError(f"tips_mounted has {len(tips_mounted)} entries, expected {channels}") + self.tips_mounted = list(tips_mounted) + + # What each module says when asked whether it is initialized, and where things are. + self.initialized = {module: initialized for module in ("C0", "I0", "R0", "H0")} + + # The features this device has, each answering for itself. Discovery builds only the ones + # that are not already there, so these stand in for the real ones throughout. + c = self.simulated_configuration + if c.main_front_cover_monitoring_installed: + self.front_cover = SimulatedFrontCover(self) + if c.left_arm is not None: + self.left_x_arm = SimulatedXArm(self, side="left") + if c.right_arm is not None: + self.right_x_arm = SimulatedXArm(self, side="right") + if c.autoload_installed: + self.autoload = SimulatedAutoload(self) + + # On the arm whose bits claim them, as discovery would put them. Read off the simulated + # configuration rather than the arm's own: nothing has been discovered yet at this point. + for arm, a in ((self.left_x_arm, c.left_arm), (self.right_x_arm, c.right_arm)): + if arm is None or a is None: + continue + if a.pip_installed and c.num_pip_channels > 0: + arm.pipettes = SimulatedPipettes(self) + if a.head96_installed: + arm.head96 = SimulatedHead96(self) + if a.head384_installed: + arm.head384 = SimulatedHead384(self) + if a.iswap_installed: + arm.iswap = SimulatedISWAP(self) + + # -- the device itself ---------------------------------------------------- + + async def _open(self): + """There is no link to open, and no replies to read.""" + + async def _close(self): + pass + + async def request_device_configuration(self) -> DeviceConfiguration: + """What the device reports it carries, answered from what it was declared to be. + + As the channels answer: the reads a device answers this with go on the link, and what comes back + is a configuration of its own rather than the declared one, so discovery records it and the + cross-check compares the two as it does on a physical device. An arm keeps the device facts + written to it across a re-read, as a physical device's discovery keeps them. + """ + for command in ("RM", "QM", "RU", "UA"): + await self.send_command(module="C0", command=command) + answered = copy.deepcopy(self.simulated_configuration) + if self.configuration is not None: + for side in ("left_arm", "right_arm"): + carried, arm = getattr(self.configuration, side), getattr(answered, side) + if carried is not None and arm is not None: + setattr(answered, side, arm.with_device_facts_of(carried)) + return answered + + async def request_cover_input_status(self) -> Tuple[bool, bool, bool]: + return SIMULATED_COVER_INPUTS + + async def request_device_serial_number(self) -> str: + await self.send_command(module="C0", command="RI") + # What it was told it is, or what it was told to call itself when the recording did not say. + declared = self.simulated_configuration.serial_number + if declared is None: + raise RuntimeError("the simulated device has no serial number; set it on its configuration") + return declared + + async def request_firmware_version(self) -> Tuple[str, datetime.date]: + await self.send_command(module="C0", command="RF") + declared = self.simulated_configuration.firmware_version + if declared is None: + raise RuntimeError( + "the simulated device has no firmware version; set it on its configuration" + ) + return declared, parse_firmware_version_date(declared) + + async def request_initialization_status(self, module: str = "C0") -> bool: + return self.initialized.get(module, False) + + async def _pre_initialize(self, read_timeout: int = 300): + """Home every drive. The modules it de-initializes then need their own. + + Goes through the command path, so it is coordinated like the real one. + + Args: + read_timeout: how long the real procedure would be given, in seconds. Nothing waits here. + """ + await super()._pre_initialize(read_timeout=read_timeout) + self.initialized["C0"] = True + + def _describe_link(self) -> str: + return "simulation (no link)" + + async def _answer(self, module: str, command: str, **kwargs: Any) -> Optional[Tuple[Any, str]]: + """What the device would answer, asked of the feature the command is about. + + Each feature answers for its own model, so the logic stays where the model is; this only + decides who is asked. A command addressed to a module names its feature; one addressed to C0 + does not, so every feature is offered it and the first that answers has it. + + Args: + module: the module the command was addressed to. + command: the two-letter command code. + kwargs: the command's own parameters, for the reads that vary by one. + + Returns: + The answer and where it came from, or None when nothing here answers - every command that + only moves. + """ + for feature in self._simulated_features(): + answered = await feature.answer(module, command, **kwargs) + if answered is not None: + return answered + return None + + def _simulated_features(self) -> List[_Simulated]: + """Every feature that can answer for itself, arms first.""" + candidates: List[Any] = [] + for arm in self.arms: + candidates += [arm, arm.pipettes, arm.head96, arm.head384, arm.iswap] + candidates += [self.autoload, self.front_cover] + return [feature for feature in candidates if isinstance(feature, _Simulated)] + + async def _send( + self, + module: str, + command: str, + auto_id=True, + tip_pattern: Optional[List[bool]] = None, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + fmt: Optional[Any] = None, + **kwargs: Any, + ) -> Any: + """Say what would have been sent, and answer from the model where there is an answer. + + A command that only moves is logged and answered with nothing. One whose answer is read is + logged the same way and then answered by `_answer`, so a read puts its command on the link + exactly as a move does. + + What it logs is what a real link logs: the assembled command as a write, and the answer as a + read, so a simulated run reads like a recorded one. + + Replacing `_send` rather than `send_command` leaves the coordination in place, so a simulated + run serializes what a real one serializes. + """ + # The count is only read when there is a list to terminate, as the real assembler reads it: + # discovery sends commands before it knows the count, and asking for it there would refuse + # the very reads that establish it. + carries_a_list = any(isinstance(value, list) for value in kwargs.values()) + cmd = assemble_channel_command( + module=module, + command=command, + id_=None, + tip_pattern=tip_pattern, + num_channels=self.num_channels if carries_a_list else 0, + **kwargs, + ) + answered = await self._answer(module, command, **kwargs) + if answered is None: + self._log_exchange(cmd, None) + return None + value, source = answered + self._log_exchange(cmd, f"simulation: {value} from model {source}") + return value + + async def send_raw_command(self, command: str, *args: Any, **kwargs: Any) -> None: + self._log_exchange(command, None) + return None + + def _log_exchange(self, written: str, read: Optional[str]) -> None: + """Log a command, and its answer where there is one, as the transport logs a real exchange. + + Nothing answers in simulation unless a feature says so, so most commands log a write alone. + """ + logger.log(LOG_LEVEL_IO, "%s write: %s", SIMULATED_LINK, written) + if read is not None: + logger.log(LOG_LEVEL_IO, "%s read: %s", SIMULATED_LINK, read) diff --git a/pylabrobot/hamilton/star/lock.py b/pylabrobot/hamilton/star/lock.py deleted file mode 100644 index 9fec9ad836e..00000000000 --- a/pylabrobot/hamilton/star/lock.py +++ /dev/null @@ -1,76 +0,0 @@ -import asyncio -import logging -from contextlib import AsyncExitStack, asynccontextmanager -from typing import Optional - -logger = logging.getLogger(__name__) - - -class _FirmwareLock: - """Coordinates firmware commands across modules. - - Two layers, both handled by ``slave_module(module)`` / ``c0()`` so callers never touch the - internals: - - * A readers/writer gate between the C0 master and the slave modules. A slave-module - command blocks the C0 master while in flight; a C0 master command (``c0()``) is - *exclusive*: it waits for all slave-module commands to drain, then runs alone. So no - slave-module command ever overlaps a C0 master command. - - * A per-module mutex so at most one command per module is in flight. Different modules - still overlap — an X0 arm move can run alongside an H0 head move and Px channel commands - — but you never have two X0, two H0, etc. at once. Modules without a dedicated mutex are - gated but not per-module serialized. - - Read-only request (``R*``) and query (``Q*``) commands are not coordinated here at all — - they take no lock and run fully in parallel. - - The first slave-module command acquires the exclusive lock and the last one releases it, - so a C0 command simply takes the same lock and automatically waits the slaves out. - """ - - # Slave modules that serialize against themselves: H0 (96-head), X0 (X-drives), - # R0 (iSWAP), I0 (autoload). All Px pipetting channels (P1..PG) share one mutex. - _SERIALIZED_MODULES = ("H0", "X0", "R0", "I0") - - def __init__(self): - # Per-module mutexes: at most one in-flight command per module. - self._px_lock = asyncio.Lock() # shared by all P1..PG pipetting channels - self._module_locks = {module: asyncio.Lock() for module in self._SERIALIZED_MODULES} - - # Readers/writer gate: slave-module commands vs the exclusive C0 master. - self._slave_count = 0 - self._slave_count_lock = asyncio.Lock() - self._exclusive_lock = asyncio.Lock() - - def _module_lock(self, module: str) -> Optional[asyncio.Lock]: - """The per-module mutex for ``module``, or None if it needs no per-module serialization.""" - if module.startswith("P"): - return self._px_lock # P1..PG pipetting channels - return self._module_locks.get(module) - - @asynccontextmanager - async def slave_module(self, module: str): - """Run a slave-module command: serialize on its module mutex and block the C0 master.""" - module_lock = self._module_lock(module) - async with AsyncExitStack() as stack: - if module_lock is not None: - await stack.enter_async_context(module_lock) - # Join the slave group; first in acquires the exclusive lock, last out releases it. - async with self._slave_count_lock: - self._slave_count += 1 - if self._slave_count == 1: - await self._exclusive_lock.acquire() - try: - yield - finally: - async with self._slave_count_lock: - self._slave_count -= 1 - if self._slave_count == 0: - self._exclusive_lock.release() - - @asynccontextmanager - async def c0(self): - """Run an exclusive C0 master command. Waits for all slave commands, then runs alone.""" - async with self._exclusive_lock: - yield diff --git a/pylabrobot/hamilton/star/resource_model/__init__.py b/pylabrobot/hamilton/star/resource_model/__init__.py new file mode 100644 index 00000000000..ab9d6f93579 --- /dev/null +++ b/pylabrobot/hamilton/star/resource_model/__init__.py @@ -0,0 +1,18 @@ +from pylabrobot.hamilton.star.resource_model.heads import head96, head384 +from pylabrobot.hamilton.star.resource_model.iswap import ( + ROTATION_DRIVE_COLUMN_ABOVE_REPORTED_Z, + iswap_gripper, + iswap_head, + iswap_link_1, + iSWAPHead, +) + +__all__ = [ + "ROTATION_DRIVE_COLUMN_ABOVE_REPORTED_Z", + "iSWAPHead", + "iswap_head", + "iswap_link_1", + "iswap_gripper", + "head96", + "head384", +] diff --git a/pylabrobot/hamilton/star/resource_model/heads.py b/pylabrobot/hamilton/star/resource_model/heads.py new file mode 100644 index 00000000000..075ffb1d12a --- /dev/null +++ b/pylabrobot/hamilton/star/resource_model/heads.py @@ -0,0 +1,142 @@ +"""Hamilton STAR's rigid heads: grids of pipetting channels that move as one.""" + +from typing import Optional + +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.n_channel_pipettes import ( + SHAFT_DIAMETER, + SHAFT_LENGTH, + NChannelPipette, + TipMountingShaft, +) +from pylabrobot.resources.utils import create_ordered_items_2d + + +def _rigid_head( + name: str, + columns: int, + rows: int, + pitch: float, + model: str, + size_x: Optional[float], + size_y: Optional[float], + size_z: float, + dx: Optional[float], + dy: Optional[float], + dz: float, +) -> NChannelPipette: + """One of the rigid heads: a grid the device knows, in a body it does not. + + What the device tells us is the grid - how many channels, at what pitch. The body around it + is a measurement of a particular head, never derived from the pitch. Left unmeasured, the resource + spans the array and nothing more. + + Args: + name: what to call this one. + columns: how many channels across. + rows: how many channels deep. + pitch: their centre-to-centre spacing, in mm. + model: which head this is. + size_x: how wide the head's body is, in mm. None spans the channel array instead. + size_y: how deep the body is, in mm. None spans the channel array instead. + size_z: how tall it is, in mm. Zero models it as its channel plane. + dx: how far channel A1 sits from the body's left edge, in mm. None centres the array. + dy: how far channel A1 sits from its front edge, in mm. None centres the array. + dz: how far channel A1 sits above its bottom, in mm. + + Returns: + The pipette. + """ + array_x, array_y = (columns - 1) * pitch, (rows - 1) * pitch + size_x = array_x if size_x is None else size_x + size_y = array_y if size_y is None else size_y + # Centred in the body unless placed explicitly. A1 is the back row, so it sits a full array + # depth behind the front margin. + dx = (size_x - array_x) / 2 if dx is None else dx + dy = (size_y - array_y) / 2 + array_y if dy is None else dy + return NChannelPipette( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + # Where a tip is picked up: the axis of shaft A1, at the end of it. That end is a shaft's length + # below the plane the body starts at, which is what the shafts hang from. + reference_point=Coordinate(dx, dy, dz - SHAFT_LENGTH), + ordered_items=create_ordered_items_2d( + TipMountingShaft, + name_prefix=name, + num_items_x=columns, + num_items_y=rows, + # A channel position is an axis, and these place a corner, so each shaft is set back by its + # own radius to leave its axis where the grid says the channel is. + dx=dx - SHAFT_DIAMETER / 2, + dy=dy - array_y - SHAFT_DIAMETER / 2, + # The shafts are what reaches lowest, so they hang below the plane the body starts at rather + # than standing on it: their own length below `dz`, which puts their ends at the bottom of + # everything and the body a shaft's length clear of it. + dz=dz - SHAFT_LENGTH, + item_dx=pitch, + item_dy=pitch, + tip_pickup_mode="core", + ), + category=model, + model=model, + ) + + +def head96( + name: str, + size_x: Optional[float] = 160.0, + size_y: Optional[float] = 120.0, + size_z: float = 0.0, + dx: Optional[float] = None, + dy: Optional[float] = None, + dz: float = 0.0, +) -> NChannelPipette: + """The 96-head: 96 channels on a 12 by 8 grid at 9 mm, moving as one. + + The drives report channel A1, so that is what `reference_point` is. The body is measured, and the + channel array sits centred in it - which is where the margins on every side come from. + + Args: + name: what to call this one. + size_x: how wide the head's body is, in mm. None spans the channel array instead. + size_y: how deep the body is, in mm. None spans the channel array instead. + size_z: how tall it is, from its stop disc to its top, in mm. Zero leaves it unmodelled. + dx: how far channel A1 sits from the body's left edge, in mm. None centres the array. + dy: how far channel A1 sits from its front edge, in mm. None centres the array. + dz: how far channel A1 sits above its bottom, in mm. + + Returns: + The pipette. + """ + return _rigid_head(name, 12, 8, 9.0, "head96", size_x, size_y, size_z, dx, dy, dz) + + +def head384( + name: str, + size_x: Optional[float] = 160.0, + size_y: Optional[float] = 120.0, + size_z: float = 0.0, + dx: Optional[float] = None, + dy: Optional[float] = None, + dz: float = 0.0, +) -> NChannelPipette: + """The 384-head: 384 channels on a 24 by 16 grid at 4.5 mm, moving as one. + + Measured from channel A1, and sharing the 96-head's body, which the two are built around. Its + array is denser, so it leaves wider margins in the same envelope. + + Args: + name: what to call this one. + size_x: how wide the head's body is, in mm. None spans the channel array instead. + size_y: how deep the body is, in mm. None spans the channel array instead. + size_z: how tall it is, from its collar bearing to its top, in mm. Zero leaves it unmodelled. + dx: how far channel A1 sits from the body's left edge, in mm. None centres the array. + dy: how far channel A1 sits from its front edge, in mm. None centres the array. + dz: how far channel A1 sits above its bottom, in mm. + + Returns: + The pipette. + """ + return _rigid_head(name, 24, 16, 4.5, "head384", size_x, size_y, size_z, dx, dy, dz) diff --git a/pylabrobot/hamilton/star/resource_model/iswap.py b/pylabrobot/hamilton/star/resource_model/iswap.py new file mode 100644 index 00000000000..9393db5f479 --- /dev/null +++ b/pylabrobot/hamilton/star/resource_model/iswap.py @@ -0,0 +1,225 @@ +"""The iSWAP: the head its arm turns on, and the links that arm is made of.""" + +from typing import Optional, Tuple + +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.end_effector import MechanicalGripper +from pylabrobot.resources.manipulator import LinkBody +from pylabrobot.resources.resource import Resource + + +class iSWAPHead(Resource): + """The head the iSWAP's arm hangs from: the column its Y and Z drives ride. + + The drives position this, not the gripper: `reference_point` is the point they report, and where + the gripper ends up follows from it through the two links and the joint angles. A resource is + located by its left front bottom corner, so the drives' readings are offset by this point before + being recorded. + + The arm is its child, so it travels with the head, and where the gripper ends up within that + follows from the joint angles rather than from where this sits. + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + reference_point: Coordinate, + category: str = "iswap_head", + model: Optional[str] = None, + rotation_drive_angle: Optional[float] = None, + wrist_drive_angle: Optional[float] = None, + ): + """ + Args: + name: what to call this one. + size_x: how wide the drive is, in mm. + size_y: how deep it is, in mm. + size_z: how tall it is, in mm. + reference_point: the point the drives report, from the left front bottom corner. + category: what kind of resource this is. + model: which drive this is. + rotation_drive_angle: the rotation drive's angle as last read, as `serialize` writes it. + wrist_drive_angle: the wrist drive's angle as last read, as `serialize` writes it. + """ + super().__init__( + name=name, size_x=size_x, size_y=size_y, size_z=size_z, category=category, model=model + ) + self.reference_point = reference_point + self.rotation_drive_angle: Optional[float] = rotation_drive_angle + self.wrist_drive_angle: Optional[float] = wrist_drive_angle + """Which way the rotation drive reports the arm points, in degrees, or None until it is read. + + Kept in the drive's own terms, as it reports them. `rotation` carries the same fact rendered + for the deck, which is neither the same reference nor the same axis: degrees there are the + deck angle link 1 lies along, and a resource turns about its own corner while the arm turns + about `reference_point`. Anything needing the angle a drive would report reads this rather + than converting `rotation` back.""" + + def serialize(self) -> dict: + return { + **super().serialize(), + "reference_point": self.reference_point.serialize(), + "rotation_drive_angle": self.rotation_drive_angle, + "wrist_drive_angle": self.wrist_drive_angle, + } + + +# The material each part of the arm is made of, measured on the manufacturer's own model: its +# size, and where the joint it turns on sits inside it. A member's origin is a corner, as any +# resource's is, so the joint is somewhere within it rather than at the corner, and the arm turns +# about the joint rather than about the corner. +# +# The heights are what makes the arm an arm rather than a flat plate: it steps down from the drive +# to the plate it holds. They are measured against the height the Z drive reports, which is the +# same plane `rotation_drive_z_offset_above_finger` is measured from - and the model agrees with +# it independently, since the pads' underside comes out exactly that far below. Link 1's joint is +# below its member because the rotation drive's column stands under the arm. +LINK_1_BODY_SIZE = (163.4, 25.5, 15.3) +LINK_1_JOINT = Coordinate(12.7, 12.75, -20.3) +GRIPPER_BODY_SIZE = (59.0, 90.0, 20.3) +GRIPPER_JOINT = Coordinate(13.0, 45.0, 0.0) +# A finger has no Y of its own: the jaw width stands it where it stands. +GRIPPER_FINGER_SIZE = (135.0, 7.0, 8.0) +GRIPPER_FINGER_LOCATION = GRIPPER_JOINT + Coordinate(6.5, 0.0, 4.0) +# From the finger it is fixed to, as a child's location always is. +GRIPPER_PAD_SIZE = (37.0, 4.0, 17.0) +GRIPPER_PAD_LOCATION = Coordinate(109.0, 1.5, -17.0) + +# How far the rotation drive's own column stands above the height the Z drive reports, in mm. The +# arm hangs below that: the drive reports where the material it carries is, not where its column +# begins. The column stands on link 1 with nothing between them, so this follows link 1's own top +# rather than being stated again - the two cannot drift apart. +ROTATION_DRIVE_COLUMN_ABOVE_REPORTED_Z = -LINK_1_JOINT.z + LINK_1_BODY_SIZE[2] + + +def iswap_head( + name: str, + diameter: float, + size_z: float, +) -> iSWAPHead: + """The head, modelled as the column standing above the arm. + + Square in plan, spanning the column's diameter, because a resource is a box. The drives report + its centre in X and Y, and a point below its base in Z, which is what `reference_point` states. + + Args: + name: what to call this one. + diameter: how wide the column is, in mm. + size_z: how tall to model it, in mm. + + Returns: + The head. + """ + return iSWAPHead( + name=name, + size_x=diameter, + size_y=diameter, + size_z=size_z, + # The Z drive reports a point below the column's own base - the arm it carries hangs there - + # so the reference point states that, and the resource lands that far above what is read. + reference_point=Coordinate(diameter / 2, diameter / 2, -ROTATION_DRIVE_COLUMN_ABOVE_REPORTED_Z), + model="hamilton_star_iswap_head", + ) + + +def iswap_gripper( + name: str, + tool_center_point: Coordinate, + jaw_range: Tuple[float, float], + jaw_width: Optional[float] = None, +) -> MechanicalGripper: + """The iSWAP's hand: the wrist joint to the centre the clamps hold a rack at. + + Args: + name: what to call this one. + tool_center_point: the wrist joint to the grip centre, in mm. Its reach is + `iSWAPConfiguration.tool_length`; it grips below the wrist, so its z is negative. + jaw_range: how far apart the jaws stand, closed and open, in mm, as the gripper drive's own + travel gives it. + jaw_width: how far apart they stand to begin with, in mm. The width the drive homes and parks + at, where the stored table has been read. + + Returns: + The gripper. + """ + model = "hamilton_star_iswap_gripper" + fingers = tuple( + Resource( + name=f"{name}_finger_{side}", + size_x=GRIPPER_FINGER_SIZE[0], + size_y=GRIPPER_FINGER_SIZE[1], + size_z=GRIPPER_FINGER_SIZE[2], + category="finger", + model=f"{model}_finger", + ) + for side in ("left", "right") + ) + pads = tuple( + Resource( + name=f"{jaw.name}_pad", + size_x=GRIPPER_PAD_SIZE[0], + size_y=GRIPPER_PAD_SIZE[1], + size_z=GRIPPER_PAD_SIZE[2], + category="pad", + model=f"{jaw.model}_pad", + ) + for jaw in fingers + ) + return MechanicalGripper( + name=name, + proximal_joint=GRIPPER_JOINT, + # From the wrist joint, as the arm reports it and as a tool centre point is stated. + tool_center_point=tool_center_point, + body=Resource( + name=f"{name}_body", + size_x=GRIPPER_BODY_SIZE[0], + size_y=GRIPPER_BODY_SIZE[1], + size_z=GRIPPER_BODY_SIZE[2], + category="body", + model=f"{model}_body", + ), + body_location=Coordinate.zero(), + fingers=fingers, + finger_location=GRIPPER_FINGER_LOCATION, + pads=pads, + pad_location=GRIPPER_PAD_LOCATION, + jaw_range=jaw_range, + jaw_width=jaw_width, + model=model, + ) + + +def iswap_link_1(name: str, length: float) -> LinkBody: + """The first member: the rotation joint to the wrist joint, with the arm bolted to it. + + Args: + name: what to call this one. + length: joint to joint, in mm, as `iSWAPConfiguration.link_1_length` reports it. + + Returns: + The member. + """ + member = LinkBody( + name=name, + size_x=LINK_1_BODY_SIZE[0], + size_y=LINK_1_BODY_SIZE[1], + size_z=LINK_1_BODY_SIZE[2], + proximal_joint=LINK_1_JOINT, + # The arm reports the link joint to joint, and this member's frame starts at its corner. + distal_joint=LINK_1_JOINT + Coordinate(length, 0.0, 0.0), + category="iswap_link", + model="hamilton_star_iswap_link_1", + ) + body = Resource( + name=f"{member.name}_body", + size_x=LINK_1_BODY_SIZE[0], + size_y=LINK_1_BODY_SIZE[1], + size_z=LINK_1_BODY_SIZE[2], + category="body", + model=f"{member.model}_body" if member.model else None, + ) + member.assign_child_resource(body, location=Coordinate.zero()) + return member diff --git a/pylabrobot/io/io.py b/pylabrobot/io/io.py index 599399a251f..f43d600865b 100644 --- a/pylabrobot/io/io.py +++ b/pylabrobot/io/io.py @@ -4,6 +4,14 @@ class IOBase(SerializableMixin, ABC): + @abstractmethod + async def setup(self, *args, **kwargs): + """Open the link. Called before any read or write.""" + + @abstractmethod + async def stop(self): + """Close the link.""" + @abstractmethod async def write(self, data: bytes, *args, **kwargs): pass diff --git a/pylabrobot/resources/__init__.py b/pylabrobot/resources/__init__.py index 288f7e2a995..eb8d6d2b930 100644 --- a/pylabrobot/resources/__init__.py +++ b/pylabrobot/resources/__init__.py @@ -32,6 +32,7 @@ from .lid import Lid, Liddable from .liquid import Liquid from .manipulator import LinkBody +from .n_channel_pipettes import NChannelPipette, TipMountingShaft, TipPickupMode from .nest import * from .opentrons import * from .perkin_elmer import * diff --git a/pylabrobot/resources/barcode.py b/pylabrobot/resources/barcode.py index 7f06252b146..ed7fa2c034c 100644 --- a/pylabrobot/resources/barcode.py +++ b/pylabrobot/resources/barcode.py @@ -22,6 +22,18 @@ "ANY 1D", # wildcard for any 1D symbology available, depends on scanner capabilities ] +Barcode2DSymbology = Literal[ + "Data Matrix", + "QR Code", + "Maxi Code", + "Aztec", + "PDF 417", + "Micro PDF 417", + "GS1 DataBar", + "EAN/UCC Comp", + "ANY 2D", # wildcard for any 2D symbology available, depends on reader capabilities +] + @dataclass class Barcode(SerializableMixin): diff --git a/pylabrobot/resources/hamilton/__init__.py b/pylabrobot/resources/hamilton/__init__.py index 8bcc28e3f86..b849e96b262 100644 --- a/pylabrobot/resources/hamilton/__init__.py +++ b/pylabrobot/resources/hamilton/__init__.py @@ -1,14 +1,16 @@ -from .hamilton_decks import ( - HamiltonDeck, - HamiltonSTARDeck, - STARDeck, - STARLetDeck, -) +from .core_grippers import HamiltonCoreGrippers +from .hamilton_decks import HamiltonDeck from .mfx_carriers import * from .mfx_modules import * from .nimbus_decks import NimbusDeck from .plate_adapters import * from .plate_carriers import * +from .star_decks import ( + HamiltonSTARDeck, + STARDeck, + STARLetDeck, + STARPlusDeck, +) from .tip_carriers import * from .tip_creators import * from .tip_racks import * diff --git a/pylabrobot/resources/hamilton/core_grippers.py b/pylabrobot/resources/hamilton/core_grippers.py new file mode 100644 index 00000000000..6c72b3c3a83 --- /dev/null +++ b/pylabrobot/resources/hamilton/core_grippers.py @@ -0,0 +1,74 @@ +"""The CO-RE grippers a Hamilton STAR parks on its waste block.""" + +# TODO: add new quad-core gripper definitions when they are released by Hamilton. + +from pylabrobot.resources.resource import Resource + + +class HamiltonCoreGrippers(Resource): + def __init__( + self, + name: str, + back_channel_y_center: float, + front_channel_y_center: float, + size_x: float, + size_y: float, + size_z: float, + model, + rotation=None, + category="core_grippers", + barcode=None, + ): + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + barcode=barcode, + ) + self.back_channel_y_center = back_channel_y_center + self.front_channel_y_center = front_channel_y_center + + def serialize(self): + return { + **super().serialize(), + "back_channel_y_center": self.back_channel_y_center, + "front_channel_y_center": self.front_channel_y_center, + } + + +def hamilton_core_gripper_1000ul_at_waste() -> HamiltonCoreGrippers: + # inner hole diameter is 8.6mm + # distance from base of rack to outer base of containers: -7mm + # left outer edge of rack is 22.5mm + # front outer edge of rack is 9.5mm + + return HamiltonCoreGrippers( + name="core_grippers", + size_x=45, # from venus + size_y=45, # from venus + size_z=24, # from venus + back_channel_y_center=26 + 9.5, + front_channel_y_center=0 + 9.5, + model=hamilton_core_gripper_1000ul_at_waste.__name__, + ) + + +def hamilton_core_gripper_1000ul_5ml_on_waste() -> HamiltonCoreGrippers: + # distance from base of rack to outer base of containers: 0mm + # inner hole diameter is 8.6mm + # left outer edge of rack is 19.5mm + # front outer edge of rack is 39.5mm + + return HamiltonCoreGrippers( + name="core_grippers", + size_x=39, # from venus + size_y=61, # from venus + size_z=24, # from venus + back_channel_y_center=18 + 21.5, + front_channel_y_center=0 + 21.5, + model=hamilton_core_gripper_1000ul_5ml_on_waste.__name__, + ) diff --git a/pylabrobot/resources/hamilton/hamilton_deck_tests.py b/pylabrobot/resources/hamilton/hamilton_deck_tests.py index 9aa0a8b68ed..2242ae6bbad 100644 --- a/pylabrobot/resources/hamilton/hamilton_deck_tests.py +++ b/pylabrobot/resources/hamilton/hamilton_deck_tests.py @@ -65,6 +65,17 @@ def test_rails_is_bounded_as_it_was_and_track_by_the_tracks(self): with self.assertRaises(ValueError): deck.assign_child_resource(Resource("front_2", size_x=20, size_y=20, size_z=20), track=32) + def test_names_the_module_had_still_import(self): + from pylabrobot.resources.hamilton import core_grippers, hamilton_decks, star_decks + + for name in ("HamiltonSTARDeck", "STARDeck", "STARLetDeck"): + self.assertIs(getattr(hamilton_decks, name), getattr(star_decks, name)) + self.assertIs( + hamilton_decks.hamilton_core_gripper_1000ul_at_waste, + core_grippers.hamilton_core_gripper_1000ul_at_waste, + ) + self.assertEqual((hamilton_decks.STARLET_NUM_RAILS, hamilton_decks.STAR_NUM_RAILS), (32, 56)) + def test_hamilton_deck_takes_its_track_count_first_as_it_took_rails(self): class OwnDeck(HamiltonDeck): def track_to_location(self, track: int) -> Coordinate: diff --git a/pylabrobot/resources/hamilton/hamilton_decks.py b/pylabrobot/resources/hamilton/hamilton_decks.py index 61e03530540..275bb3e45dd 100644 --- a/pylabrobot/resources/hamilton/hamilton_decks.py +++ b/pylabrobot/resources/hamilton/hamilton_decks.py @@ -1,36 +1,64 @@ from __future__ import annotations +import importlib import logging import warnings from abc import ABCMeta -from typing import Literal, Optional, cast +from typing import Optional, cast -from pylabrobot.resources.carrier import ResourceHolder +from pylabrobot.resources.carrier import Carrier, ResourceHolder from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.deck import Deck from pylabrobot.resources.errors import NoLocationError -from pylabrobot.resources.hamilton.tip_creators import hamilton_teaching_needle_300uL +from pylabrobot.resources.hamilton.core_grippers import HamiltonCoreGrippers from pylabrobot.resources.resource import Resource -from pylabrobot.resources.tip_rack import TipRack, TipSpot from pylabrobot.resources.trash import Trash logger = logging.getLogger(__name__) STARLET_NUM_TRACKS = 30 STAR_NUM_TRACKS = 54 +STARPLUS_NUM_TRACKS = 76 + +# Which frame a deck belongs to, from how many tracks it has. The three are the same deck at three +# lengths, built by three factories rather than three classes, so the track count is the only thing +# that tells them apart - and parts that are cut to the frame's length differ between them and need +# saying which one they are. A count nobody has named leaves those parts unnamed rather than +# claiming to be a frame they are not. +FRAME_BY_NUM_TRACKS = { + STARLET_NUM_TRACKS: "starlet", + STAR_NUM_TRACKS: "star", + STARPLUS_NUM_TRACKS: "starplus", +} + -_RAILS_WIDTH = 22.5 # space between rails (mm) _TRACK_WIDTH = 22.5 # space between rails (mm) -STARLET_NUM_RAILS = 32 -STARLET_SIZE_X = 1005 -STARLET_SIZE_Y = 653.5 -STARLET_SIZE_Z = 900 +# How far in front of the back of the device the X-arm's own back edge stands, in mm. Measured on +# the manufacturer's model: the chassis reaches to 51.06 and the arm's carriage to 35.56, and the +# chassis's depth there - 785.79 - is what the device resource says to the decimal, so the two +# frames line up and the difference is the arm's own setback. +ARM_BACK_FROM_DEVICE_BACK = 15.5 + +# How often a track gets a number on a part that states its own grid. The same rule the derived +# grids use: the first, and then every fifth. +DEFAULT_TRACK_LABEL_EVERY = 5 -STAR_NUM_RAILS = 56 -STAR_SIZE_X = 1545 -STAR_SIZE_Y = 653.5 -STAR_SIZE_Z = 900 +# Where a carrier's own front edge sits on any Hamilton deck, in mm. +_CARRIER_Y = 63.0 + +# What closes the front of the deck, in front of the first carrier row. A deck has one or the other: +# the panel is what is fitted where there is no autoload, and an autoload's belt frame stands in the +# same band and takes its place. A resource carries one mesh, so which of the two a deck shows is a +# fact about the deck rather than a part standing on it. +FRONT_PANEL_MODEL = "{frame}_deck_front_top_cover" +AUTOLOAD_BELT_MODEL = "{frame}_autoload_tray_belt_frame" + +# Parts of the DEVICE that happen to hang off the deck, as opposed to things placed ON it. They are +# fitted where the instrument puts them, not assigned to rails, so they cannot occupy a rail and +# must not be treated as though they do - a fitted autoload otherwise makes rail 1 unassignable, +# because the sled's box reaches over the deck's front edge and up past a carrier's height. +_DEVICE_PARTS = frozenset({"autoload_sled", "autoload_loading_tray"}) def track_for_x_coordinate(x: float) -> int: @@ -105,6 +133,7 @@ def __init__( category: str = "deck", origin: Coordinate = Coordinate.zero(), num_rails: Optional[int] = None, + model: Optional[str] = None, ): # What `@abstractmethod` refused before either could be left to the other: a deck with neither. if ( @@ -125,9 +154,30 @@ def __init__( category=category, origin=origin, ) + # `Deck` takes no model, so it is set here rather than passed up. + self.model = model self.num_tracks = _resolve_num_tracks(num_tracks, num_rails) + # A deck restored from a serialization arrives with the panel it was saved with; one built from + # scratch works out which it is. Either way, fitting an autoload swaps it. + if model is None: + self._declare_front_panel() + self.register_did_assign_resource_callback(self._check_safe_z_height) + def _declare_front_panel(self) -> None: + """Say which mesh closes the front of this deck: the panel, or the belt that replaces it. + + Called when the deck is built and again when an autoload is fitted to it, since fitting one is + what swaps the two. A frame nobody has named leaves the deck without a model, as it leaves any + other part cut to a frame's length unnamed. + """ + frame = FRAME_BY_NUM_TRACKS.get(self.num_tracks) + if frame is None: + self.model = None + return + fitted = any(child.category in _DEVICE_PARTS for child in self.children) + self.model = (AUTOLOAD_BELT_MODEL if fitted else FRONT_PANEL_MODEL).format(frame=frame) + def track_to_location(self, track: int) -> Coordinate: """Where a track starts on this deck. @@ -173,10 +223,226 @@ def num_rails(self) -> int: ) return self.num_tracks + self._rails_beyond_tracks + def compute_right_track_of_carrier(self, carrier: Carrier) -> int: + """The last track a carrier covers, from where it sits on this deck. + + Args: + carrier: the carrier, which must be on this deck. + + Returns: + The track, counted from 1. + """ + end_x = carrier.get_location_wrt(self).x + carrier.get_absolute_size_x() + return track_for_x_coordinate(end_x) - 1 + + def get_carrier_at_track(self, track: int) -> Carrier: + """The carrier covering a track, from where the carriers sit on this deck. + + A carrier covers every track from the one it is placed at to + `compute_right_track_of_carrier`, so a six-track carrier at track 15 answers for 15 to 20. This + finds it from any of them, which is what lets a caller name a carrier by the track it was put + at while the autoload addresses it by its rightmost. + + Args: + track: any track the carrier covers, counted from 1. + + Returns: + The carrier there. + + Raises: + ValueError: If no carrier on this deck covers that track. + """ + for child in self.children: + if not isinstance(child, Carrier): + continue + left = track_for_x_coordinate(child.get_location_wrt(self).x) + if left <= track <= self.compute_right_track_of_carrier(child): + return child + raise ValueError(f"no carrier on this deck covers track {track}") + + def get_or_create_x_arm( + self, + name: str, + x: float, + size_x: float, + reference_point_from_left: float, + model: str, + ) -> Resource: + """Get, or create once, the deck-owned X-arm resource called `name`. + + The deck owns it: created as a child the first time and reused thereafter, so repeated setups + do not duplicate it. It is placed so its reference point sits at the arm's current x. + + The arm is wider than the width its drive reports, which begins at the arm's left edge and + stops short of its right end. So the two are given separately: how much room the part takes, + and where along it the drive's position refers to. + + Args: + name: what to call it, e.g. "left_x_arm". + x: where the arm is now, in mm, at its reference point. + size_x: how wide the arm is, in mm, end to end. + reference_point_from_left: how far along it, from its left edge in mm, the drive's position + refers to - the middle of the reported width on a large arm, its right end on a small one. + model: which arm this is. + + Returns: + The arm resource, whether it was just created or already there. + """ + if self.has_resource(name): + return self.get_resource(name) + # The arm rides at the channel stop-disk safety height, level with the raised stop discs so it + # clears them as it travels. + arm_z, size_z, size_y = 334.7, 140.0, 712.0 + x_arm = Resource( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category="x_arm", + model=model, + ) + # What the drive's x actually refers to. Stated on the resource so anything reading it - the + # placement below, and a viewer drawing where the arm is reported to be - works from the arm's + # own frame rather than assuming the middle of the box. + x_arm.reference_point = {"x": reference_point_from_left} # type: ignore[attr-defined] + # Place it so its reference point lands at the arm's current x, and its back edge where the arm + # actually stands: a fixed distance in front of the back of the device carrying the deck. It + # used to line up with the back of the DECK, which is not the same thing - the deck resource is + # 653.5 mm deep where the deck it models is 773 - and that put the arm 19 mm too far forward. + # Being deeper than the deck, the arm reaches in front of the deck's front edge, which is why y + # comes out negative. It sits above the deck plane, so it does not occupy the footprint of the + # carriers beneath it. + # + # A deck standing on its own has no device to measure from, and keeps its own back edge. + device = self.parent + if device is None: + y = self.get_absolute_size_y() - size_y + else: + back_of_device = (device.get_absolute_size_y() - self.location.y) if self.location else 0.0 + y = back_of_device - ARM_BACK_FROM_DEVICE_BACK - size_y + self.assign_child_resource(x_arm, location=Coordinate(x - reference_point_from_left, y, arm_z)) + return x_arm + + def get_or_create_autoload_sled( + self, name: str, x: float, reference_point_from_left: float + ) -> Resource: + """Get, or create once, the deck-owned autoload sled. + + The deck owns it: created as a child the first time and reused thereafter, so repeated setups + do not duplicate it. + + Args: + name: where the carrier-handling wheel is, in mm, on this deck. The wheel is the point the + drive reports, so the sled is placed around it. + x: where the wheel is, in mm, on this deck. + reference_point_from_left: how far the point the drive reports - the carrier-handling + wheel - sits from the sled's left edge, in mm. + + Returns: + The sled resource, whether it was just created or already there. + """ + if self.has_resource(name): + return self.get_resource(name) + # The whole part, transport and barcode reader. The 316.2 this replaces came off the + # manufacturer's model, whose left end carried a thin tab that the sled does not have; the + # extra 35.3 mm put the part's own corner that far left of where it stands, and everything + # measured from that corner with it. + size_x, size_y, size_z = 280.9, 109.5, 215.3 + # Against a carrier's own front edge, and the deck's work surface. + ahead_of_carrier_y, above_deck_z = 92.7, 0.5 + sled = Resource( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category="autoload_sled", + model="hamilton_star_autoload_sled", + ) + # What the drive's x actually refers to. The sled is placed around the carrier-handling wheel, + # so its own origin is not what the device reports - saying where the wheel sits within it is + # what lets anything reading this resource put the two together, a viewer included. + sled.reference_point = { # type: ignore[attr-defined] + "x": reference_point_from_left + } + self.assign_child_resource( + sled, + location=Coordinate( + x - reference_point_from_left, + _CARRIER_Y - ahead_of_carrier_y, + above_deck_z, + ), + ) + self._declare_front_panel() + return sled + + def get_or_create_autoload_loading_tray(self, name: str) -> Resource: + """Get, or create once, the deck-owned loading tray the autoload draws carriers from. + + It is placed against the deck features it lines up with: its left edge sits 104 mm left of the + first carrier, and its front edge 380 mm in front of a carrier's. It reaches the same 104 mm + short of the deck's right edge, so its width follows from the deck. Created as a child the + first time and reused thereafter, so repeated setups do not duplicate it. + + Its own track markings line up with the deck's, so a carrier put on the tray at a track goes to + that same track on the deck. + + Args: + name: what to call it. + + Returns: + The tray resource, whether it was just created or already there. + """ + if self.has_resource(name): + return self.get_resource(name) + # Measured against the two things on the deck it lines up with: where the first carrier starts, + # and a carrier's front edge. It insets the same amount from the deck's right edge as from its + # left, which is what sizes it. + # The height is the tray plate's own top - what a carrier stands on, and what its track + # markings are cut into. Read off the part: the plate tops out 98.0 mm above the tray's floor + # on all three frames, and the track guides stand on it from there. It is 2 mm below the deck's + # own work surface, which is what lets a carrier come off the tray and onto the deck. + from_first_carrier_x, front_ahead_y, back_ahead_y, size_z = 104.0, 380.0, 132.0, 98.0 + left = self.track_to_location(1).x - from_first_carrier_x + # The tray runs the length of the deck, so it is a different part on each frame rather than one + # part fitted to all three, and it says which frame it is. The sled that runs along it is one + # part everywhere and does not. + frame = FRAME_BY_NUM_TRACKS.get(self.num_tracks) + tray = Resource( + name=name, + size_x=self.get_absolute_size_x() - from_first_carrier_x - left, + size_y=front_ahead_y - back_ahead_y, + size_z=size_z, + category="autoload_loading_tray", + model=f"hamilton_{frame}_autoload_loading_tray" if frame else None, + ) + # The tray's own track markings, stated rather than derived: they line up with the deck's, so + # they are the deck's tracks read in the tray's frame. Nothing about the tray itself says where + # they are, which is why it has to be told. + # + # The marks run the tray's full depth and sit on its top surface, since a carrier is placed on + # the tray by the same marks it is placed on the deck by. + first, second = self.track_to_location(1), self.track_to_location(2) + tray.position_grid = { # type: ignore[attr-defined] + "axis": "x", + "count": self.num_tracks, + "spacing": round(second.x - first.x, 4), + "origin": [round(first.x - left, 4), 0.0, size_z], + "extent": round(front_ahead_y - back_ahead_y, 4), + "label_every": DEFAULT_TRACK_LABEL_EVERY, + "label": "track", + } + self.assign_child_resource(tray, location=Coordinate(left, _CARRIER_Y - front_ahead_y, 0.0)) + self._declare_front_panel() + return tray + def serialize(self) -> dict: """Serialize this deck.""" return { **super().serialize(), + # `Deck` drops the model on the way past, on the grounds that a deck does not usually have + # one. This one does: the strip that closes the front of it is a part like any other, and + # which part it is depends on whether an autoload is fitted. + **({"model": self.model} if self.model is not None else {}), "num_tracks": self.num_tracks, "with_trash": False, # data encoded as child. (not very pretty to have this key though...) "with_trash96": False, @@ -191,6 +457,11 @@ def _check_safe_z_height(self, resource: Resource): Z_GRAB_LIMIT = 285 def check_z_height(resource: Resource): + # What the device carries belongs up there: it rides above the deck by design, and nothing + # traverses or grabs it, so the warnings below say nothing about it. + if resource.category in ("x_arm", "head96"): + return + try: z_top = resource.get_location_wrt(self, z="top").z except NoLocationError: @@ -294,6 +565,12 @@ def should_check_collision(res: Resource) -> bool: """Determine if collision detection should be performed for this resource.""" if isinstance(res, (HamiltonCoreGrippers, Trash)): return False + # A part of the deck itself takes no part in the check, in either direction: it is already + # skipped as something to collide with, and it is where it is whatever stands on the deck. + # The autoload's sled reaches into the front of a carrier's footprint, which is how it pulls + # one in, so a deck with carriers on it would otherwise refuse to place its own sled. + if res.category in _DEVICE_PARTS: + return False return True if not ignore_collision and should_check_collision(resource): @@ -309,26 +586,36 @@ def should_check_collision(res: Resource) -> bool: # Check if there is space for this new resource. for og_resource in self.children: + if og_resource.category in _DEVICE_PARTS: + continue og_x = cast(Coordinate, og_resource.location).x og_y = cast(Coordinate, og_resource.location).y + og_z = cast(Coordinate, og_resource.location).z - # A resource is not allowed to overlap with another resource. Resources overlap when a - # corner of one resource is inside the boundaries of another resource. - if any( + # A resource is not allowed to overlap with another resource. Resources overlap when + # their bounding boxes intersect on all three axes. The z axis is included so a resource + # above the deck plane does not block placement beneath it. + x_overlap = any( [ og_x <= resource_location.x < og_x + og_resource.get_absolute_size_x(), og_x < resource_location.x + resource.get_absolute_size_x() < og_x + og_resource.get_absolute_size_x(), ] - ) and any( + ) + y_overlap = any( [ og_y <= resource_location.y < og_y + og_resource.get_absolute_size_y(), og_y < resource_location.y + resource.get_absolute_size_y() < og_y + og_resource.get_absolute_size_y(), ] - ): + ) + z_overlap = ( + og_z < resource_location.z + resource.get_absolute_size_z() + and resource_location.z < og_z + og_resource.get_absolute_size_z() + ) + if x_overlap and y_overlap and z_overlap: raise ValueError( f"Location {resource_location} is already occupied by resource '{og_resource.name}'." ) @@ -477,275 +764,31 @@ def print_tree(resource: Resource, depth=0): return summary_ -class HamiltonCoreGrippers(Resource): - def __init__( - self, - name: str, - back_channel_y_center: float, - front_channel_y_center: float, - size_x: float, - size_y: float, - size_z: float, - model, - rotation=None, - category="core_grippers", - barcode=None, - ): - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - rotation=rotation, - category=category, - model=model, - barcode=barcode, - ) - self.back_channel_y_center = back_channel_y_center - self.front_channel_y_center = front_channel_y_center - - def serialize(self): - return { - **super().serialize(), - "back_channel_y_center": self.back_channel_y_center, - "front_channel_y_center": self.front_channel_y_center, - } - - -def hamilton_core_gripper_1000ul_at_waste() -> HamiltonCoreGrippers: - # inner hole diameter is 8.6mm - # distance from base of rack to outer base of containers: -7mm - # left outer edge of rack is 22.5mm - # front outer edge of rack is 9.5mm - - return HamiltonCoreGrippers( - name="core_grippers", - size_x=45, # from venus - size_y=45, # from venus - size_z=24, # from venus - back_channel_y_center=26 + 9.5, - front_channel_y_center=0 + 9.5, - model=hamilton_core_gripper_1000ul_at_waste.__name__, - ) - - -def hamilton_core_gripper_1000ul_5ml_on_waste() -> HamiltonCoreGrippers: - # distance from base of rack to outer base of containers: 0mm - # inner hole diameter is 8.6mm - # left outer edge of rack is 19.5mm - # front outer edge of rack is 39.5mm - - return HamiltonCoreGrippers( - name="core_grippers", - size_x=39, # from venus - size_y=61, # from venus - size_z=24, # from venus - back_channel_y_center=18 + 21.5, - front_channel_y_center=0 + 21.5, - model=hamilton_core_gripper_1000ul_5ml_on_waste.__name__, - ) - - -class HamiltonSTARDeck(HamiltonDeck): - """Base class for a Hamilton STAR(let) deck.""" - - _rails_beyond_tracks = 2 - - def __init__( - self, - num_tracks: Optional[int] = None, - size_x: Optional[float] = None, - size_y: Optional[float] = None, - size_z: Optional[float] = None, - name="deck", - category: str = "deck", - origin: Coordinate = Coordinate.zero(), - with_waste_block: bool = True, - with_trash: bool = True, - with_trash96: bool = True, - with_teaching_rack: bool = True, - core_grippers: Optional[ - Literal["1000uL-at-waste", "1000uL-5mL-on-waste"] - ] = "1000uL-5mL-on-waste", - num_rails: Optional[int] = None, - ) -> None: - """Create a new STAR(let) deck of the given size. - - `with_trash` and `with_teaching_rack` require `with_waste_block` to be true. `num_rails` is - deprecated: it counted two more than `num_tracks`. - """ - - # Defaulted only so a deck saved with `num_rails` can leave out `num_tracks`, which comes first. - if size_x is None or size_y is None or size_z is None: - raise TypeError("size_x, size_y and size_z are required") - - super().__init__( - num_tracks=num_tracks, - num_rails=None if num_rails is None else num_rails - self._rails_beyond_tracks, - size_x=size_x, - size_y=size_y, - size_z=size_z, - name=name, - category=category, - origin=origin, - ) - - if with_trash96: - # got this location from a .lay file, but will probably need to be adjusted by the user. - trash96 = Trash("trash_core96", size_x=122.4, size_y=82.6, size_z=0) # size of tiprack - self.assign_child_resource( - resource=trash96, - location=Coordinate(x=-42.0 - 16.2, y=120.3 - 14.3, z=216.4), - ) - - if with_waste_block: - waste_block = Resource(name="waste_block", size_x=30, size_y=445.2, size_z=100) - self.assign_child_resource( - waste_block, - location=Coordinate(x=self.track_to_location(self.num_tracks + 1).x, y=115.0, z=100), - ) - - # assign trash area, positioned 25mm to the right of the waste block - # only run if the waste block is actually assigned. - if with_trash: - if with_waste_block: - waste_block_x = self.get_resource("waste_block").get_location_wrt(self).x - else: - # Fallback: anchor to the rightmost rail when no waste block is present. - waste_block_x = self.track_to_location(self.num_tracks + 1).x - - trash_x = waste_block_x + 25 - - self.assign_child_resource( - resource=Trash("trash", size_x=0, size_y=241.2, size_z=0), - location=Coordinate(x=trash_x, y=190.6, z=137.1), - ) - - if with_teaching_rack: - tip_spots = [ - TipSpot( - name=f"teaching_tip_rack_tip_spot_{i}", - size_x=9.0, - size_y=9.0, - size_z=0, - make_tip=hamilton_teaching_needle_300uL, - ) - for i in range(8) - ] - for i, ts in enumerate(tip_spots): - ts.location = Coordinate(x=0, y=7 * 9 - 9 * i, z=23.1) # A1 == index 0, topmost tip - - teaching_tip_rack = TipRack( - name="teaching_tip_rack", - size_x=9, - size_y=9 * 8, - size_z=50.4, - ordered_items={f"{letter}1": tip_spots[idx] for idx, letter in enumerate("ABCDEFGH")}, - with_tips=True, - model="hamilton_teaching_tip_rack", - ) - waste_block.assign_child_resource( - teaching_tip_rack, location=Coordinate(x=5.9, y=346.1, z=0) - ) - else: - if with_trash: - raise RuntimeError("Trash area cannot be created when no waste block is present.") - if with_teaching_rack: - raise RuntimeError("Teaching rack cannot be created when no waste block is present.") - - if core_grippers == "1000uL-at-waste": # "at waste" - x: float = 1338 if self.num_tracks == STAR_NUM_TRACKS else 798 - waste_block.assign_child_resource( - hamilton_core_gripper_1000ul_at_waste(), - location=Coordinate(x=x, y=105.550 - 26 - 9.5, z=205) - waste_block.location, - ) - elif core_grippers == "1000uL-5mL-on-waste": # "on waste" - x = 1337.5 if self.num_tracks == STAR_NUM_TRACKS else 797.5 - waste_block.assign_child_resource( - hamilton_core_gripper_1000ul_5ml_on_waste(), - location=Coordinate(x=x, y=125 - 18 - 21.5, z=205) - waste_block.location, - ) - - def serialize(self) -> dict: - return { - **super().serialize(), - "with_waste_block": False, # data encoded as child. (not very pretty to have this key though...) - "with_teaching_rack": False, # data encoded as child. (not very pretty to have this key though...) - "core_grippers": None, # data encoded as child. (not very pretty to have this key though...) - } - - def track_to_location(self, track: int) -> Coordinate: - x = 100.0 + (track - 1) * _TRACK_WIDTH - return Coordinate(x=x, y=63, z=100) - - def get_trash_area96(self) -> Trash: - if not self.has_resource("trash_core96"): - raise RuntimeError( - "Trash area for 96-well plates was not created. Initialize with `with_trash96=True`." - ) - return cast(Trash, self.get_resource("trash_core96")) - - def clear(self, include_trash: bool = False): - """Clear the deck, removing all resources except the trash areas and the waste block.""" - children_names = [child.name for child in self.children] - for resource_name in children_names: - resource = self.get_resource(resource_name) - if isinstance(resource, Trash) and not include_trash: - continue - if resource.name == "waste_block": - continue - resource.unassign() - - -def STARLetDeck( - origin: Coordinate = Coordinate.zero(), - with_trash: bool = True, - with_trash96: bool = True, - with_teaching_rack: bool = True, - core_grippers: Optional[ - Literal["1000uL-at-waste", "1000uL-5mL-on-waste"] - ] = "1000uL-5mL-on-waste", -) -> HamiltonSTARDeck: - """Create a new STARLet deck. - - Sizes from `HAMILTON\\Config\\ML_Starlet.dck` - """ - - return HamiltonSTARDeck( - num_tracks=30, - size_x=STARLET_SIZE_X, - size_y=STARLET_SIZE_Y, - size_z=STARLET_SIZE_Z, - origin=origin, - with_trash=with_trash, - with_trash96=with_trash96, - with_teaching_rack=with_teaching_rack, - core_grippers=core_grippers, - ) - - -def STARDeck( - origin: Coordinate = Coordinate.zero(), - with_trash: bool = True, - with_trash96: bool = True, - with_teaching_rack: bool = True, - core_grippers: Optional[ - Literal["1000uL-at-waste", "1000uL-5mL-on-waste"] - ] = "1000uL-5mL-on-waste", -) -> HamiltonSTARDeck: - """Create a new STAR deck. - - Sizes from `HAMILTON\\Config\\ML_STAR2.dck` - """ - - return HamiltonSTARDeck( - num_tracks=54, - size_x=STAR_SIZE_X, - size_y=STAR_SIZE_Y, - size_z=STAR_SIZE_Z, - origin=origin, - with_trash=with_trash, - with_trash96=with_trash96, - with_teaching_rack=with_teaching_rack, - core_grippers=core_grippers, - ) +# Names this module had before the STAR decks moved to `star_decks` and rails became tracks. Kept +# importable, with the values they had, so code written against them keeps working. +_MOVED = { + "HamiltonSTARDeck": "star_decks", + "STARDeck": "star_decks", + "STARLetDeck": "star_decks", + "hamilton_core_gripper_1000ul_at_waste": "core_grippers", + "hamilton_core_gripper_1000ul_5ml_on_waste": "core_grippers", +} +_OLD_CONSTANTS = { + "_RAILS_WIDTH": 22.5, + "STARLET_NUM_RAILS": 32, + "STARLET_SIZE_X": 1005, + "STARLET_SIZE_Y": 653.5, + "STARLET_SIZE_Z": 900, + "STAR_NUM_RAILS": 56, + "STAR_SIZE_X": 1545, + "STAR_SIZE_Y": 653.5, + "STAR_SIZE_Z": 900, +} + + +def __getattr__(name: str): + if name in _MOVED: + return getattr(importlib.import_module(f"pylabrobot.resources.hamilton.{_MOVED[name]}"), name) + if name in _OLD_CONSTANTS: + return _OLD_CONSTANTS[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/pylabrobot/resources/hamilton/star_decks.py b/pylabrobot/resources/hamilton/star_decks.py new file mode 100644 index 00000000000..5086c5291dc --- /dev/null +++ b/pylabrobot/resources/hamilton/star_decks.py @@ -0,0 +1,254 @@ +"""Hamilton STAR, STARlet and STARplus decks.""" + +from typing import Literal, Optional, cast + +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.hamilton.core_grippers import ( + hamilton_core_gripper_1000ul_5ml_on_waste, + hamilton_core_gripper_1000ul_at_waste, +) +from pylabrobot.resources.hamilton.hamilton_decks import ( + _TRACK_WIDTH, + STAR_NUM_TRACKS, + HamiltonDeck, +) +from pylabrobot.resources.hamilton.tip_creators import hamilton_teaching_needle_300uL +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.tip_rack import TipRack, TipSpot +from pylabrobot.resources.trash import Trash + + +class HamiltonSTARDeck(HamiltonDeck): + """Base class for a Hamilton STAR(let) deck.""" + + _rails_beyond_tracks = 2 + + def __init__( + self, + num_tracks: Optional[int] = None, + size_x: Optional[float] = None, + size_y: Optional[float] = None, + size_z: Optional[float] = None, + name="deck", + category: str = "deck", + origin: Coordinate = Coordinate.zero(), + with_waste_block: bool = True, + with_trash: bool = True, + with_trash96: bool = True, + with_teaching_rack: bool = True, + core_grippers: Optional[ + Literal["1000uL-at-waste", "1000uL-5mL-on-waste"] + ] = "1000uL-5mL-on-waste", + model: Optional[str] = None, + num_rails: Optional[int] = None, + ) -> None: + """Create a new STAR(let) deck of the given size. + + `with_trash` and `with_teaching_rack` require `with_waste_block` to be true. `num_rails` is + deprecated: it counted two more than `num_tracks`. + """ + + # Defaulted only so a deck saved with `num_rails` can leave out `num_tracks`, which comes first. + if size_x is None or size_y is None or size_z is None: + raise TypeError("size_x, size_y and size_z are required") + + super().__init__( + num_tracks=num_tracks, + num_rails=None if num_rails is None else num_rails - self._rails_beyond_tracks, + size_x=size_x, + size_y=size_y, + size_z=size_z, + name=name, + category=category, + origin=origin, + model=model, + ) + + if with_trash96: + # got this location from a .lay file, but will probably need to be adjusted by the user. + trash96 = Trash("trash_core96", size_x=122.4, size_y=82.6, size_z=0) # size of tiprack + self.assign_child_resource( + resource=trash96, + location=Coordinate(x=-42.0 - 16.2, y=120.3 - 14.3, z=216.4), + ) + + if with_waste_block: + waste_block = Resource(name="waste_block", size_x=30, size_y=445.2, size_z=100) + self.assign_child_resource( + waste_block, + location=Coordinate(x=self.track_to_location(self.num_tracks + 1).x, y=115.0, z=100), + ) + + # assign trash area, positioned 25mm to the right of the waste block + # only run if the waste block is actually assigned. + if with_trash: + if with_waste_block: + waste_block_x = self.get_resource("waste_block").get_location_wrt(self).x + else: + # Fallback: anchor to the rightmost rail when no waste block is present. + waste_block_x = self.track_to_location(self.num_tracks + 1).x + + trash_x = waste_block_x + 25 + + self.assign_child_resource( + resource=Trash("trash", size_x=0, size_y=241.2, size_z=0), + location=Coordinate(x=trash_x, y=190.6, z=137.1), + ) + + if with_teaching_rack: + tip_spots = [ + TipSpot( + name=f"teaching_tip_rack_tip_spot_{i}", + size_x=9.0, + size_y=9.0, + size_z=0, + make_tip=hamilton_teaching_needle_300uL, + ) + for i in range(8) + ] + for i, ts in enumerate(tip_spots): + ts.location = Coordinate(x=0, y=7 * 9 - 9 * i, z=23.1) # A1 == index 0, topmost tip + + teaching_tip_rack = TipRack( + name="teaching_tip_rack", + size_x=9, + size_y=9 * 8, + size_z=50.4, + ordered_items={f"{letter}1": tip_spots[idx] for idx, letter in enumerate("ABCDEFGH")}, + with_tips=True, + model="hamilton_teaching_tip_rack", + ) + waste_block.assign_child_resource( + teaching_tip_rack, location=Coordinate(x=5.9, y=346.1, z=0) + ) + else: + if with_trash: + raise RuntimeError("Trash area cannot be created when no waste block is present.") + if with_teaching_rack: + raise RuntimeError("Teaching rack cannot be created when no waste block is present.") + + if core_grippers == "1000uL-at-waste": # "at waste" + x: float = 1338 if self.num_tracks == STAR_NUM_TRACKS else 798 + waste_block.assign_child_resource( + hamilton_core_gripper_1000ul_at_waste(), + location=Coordinate(x=x, y=105.550 - 26 - 9.5, z=205) - waste_block.location, + ) + elif core_grippers == "1000uL-5mL-on-waste": # "on waste" + x = 1337.5 if self.num_tracks == STAR_NUM_TRACKS else 797.5 + waste_block.assign_child_resource( + hamilton_core_gripper_1000ul_5ml_on_waste(), + location=Coordinate(x=x, y=125 - 18 - 21.5, z=205) - waste_block.location, + ) + + def serialize(self) -> dict: + return { + **super().serialize(), + "with_waste_block": False, # data encoded as child. (not very pretty to have this key though...) + "with_teaching_rack": False, # data encoded as child. (not very pretty to have this key though...) + "core_grippers": None, # data encoded as child. (not very pretty to have this key though...) + } + + def track_to_location(self, track: int) -> Coordinate: + x = 100.0 + (track - 1) * _TRACK_WIDTH + return Coordinate(x=x, y=63, z=100) + + def get_trash_area96(self) -> Trash: + if not self.has_resource("trash_core96"): + raise RuntimeError( + "Trash area for 96-well plates was not created. Initialize with `with_trash96=True`." + ) + return cast(Trash, self.get_resource("trash_core96")) + + def clear(self, include_trash: bool = False): + """Clear the deck, removing all resources except the trash areas and the waste block.""" + children_names = [child.name for child in self.children] + for resource_name in children_names: + resource = self.get_resource(resource_name) + if isinstance(resource, Trash) and not include_trash: + continue + if resource.name == "waste_block": + continue + resource.unassign() + + +def STARLetDeck( + origin: Coordinate = Coordinate.zero(), + with_trash: bool = True, + with_trash96: bool = True, + with_teaching_rack: bool = True, + core_grippers: Optional[ + Literal["1000uL-at-waste", "1000uL-5mL-on-waste"] + ] = "1000uL-5mL-on-waste", +) -> HamiltonSTARDeck: + """Create a new STARLet deck.""" + + return HamiltonSTARDeck( + num_tracks=30, + size_x=1005.0, + size_y=653.5, + size_z=334.7, + origin=origin, + with_trash=with_trash, + with_trash96=with_trash96, + with_teaching_rack=with_teaching_rack, + core_grippers=core_grippers, + ) + + +def STARDeck( + origin: Coordinate = Coordinate.zero(), + with_trash: bool = True, + with_trash96: bool = True, + with_teaching_rack: bool = True, + core_grippers: Optional[ + Literal["1000uL-at-waste", "1000uL-5mL-on-waste"] + ] = "1000uL-5mL-on-waste", +) -> HamiltonSTARDeck: + """Create a new STAR deck.""" + + return HamiltonSTARDeck( + num_tracks=54, + size_x=1545.0, + size_y=653.5, + size_z=334.7, + origin=origin, + with_trash=with_trash, + with_trash96=with_trash96, + with_teaching_rack=with_teaching_rack, + core_grippers=core_grippers, + ) + + +# The STARplus deck. Derived, because we have no STARplus to measure - but the two decks above fix +# it between them. They differ by 24 rails and 540.0 mm, +# which is exactly the 22.5 mm track pitch, so a deck's width and its rail count are the same fact. +# The manufacturer's own models measure the three machines at 1130.0, 1667.0 and 2163.5 mm wide, and +# a deck sits 125.0 and 122.0 mm inside the first two. Taking the same margin for the third gives +# 2040.0 mm, which is 78.00 rails - a whole number, which the neighbouring margins are not. + + +def STARPlusDeck( + origin: Coordinate = Coordinate.zero(), + with_trash: bool = True, + with_trash96: bool = True, + with_teaching_rack: bool = True, + core_grippers: Optional[ + Literal["1000uL-at-waste", "1000uL-5mL-on-waste"] + ] = "1000uL-5mL-on-waste", +) -> HamiltonSTARDeck: + """Create a new STARplus deck. + + Sizes derived from the STARlet and STAR decks and the manufacturer's machine widths. + """ + + return HamiltonSTARDeck( + num_tracks=76, + size_x=2040.0, + size_y=653.5, + size_z=334.7, + origin=origin, + with_trash=with_trash, + with_trash96=with_trash96, + with_teaching_rack=with_teaching_rack, + core_grippers=core_grippers, + ) diff --git a/pylabrobot/resources/n_channel_pipettes.py b/pylabrobot/resources/n_channel_pipettes.py new file mode 100644 index 00000000000..aa61b2eb273 --- /dev/null +++ b/pylabrobot/resources/n_channel_pipettes.py @@ -0,0 +1,247 @@ +"""Pipetting channels, and the rigid grids some devices carry them in.""" + +from collections import OrderedDict +from typing import Any, Dict, Literal, Mapping, Optional, get_args + +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.itemized_resource import ItemizedResource +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.well import CrossSectionType + +TipPickupMode = Literal["friction", "core"] +"""How a channel holds onto a tip. + +`friction` presses the channel's cone into the tip and relies on the interference fit, so a tip is +seated by pushing down onto it and shed by pushing it off against something. `core` seats the +channel inside the tip and expands a compressed o-ring into its collar, so the grip is made and +released mechanically rather than by force - which is why a pipette that engages this way has a +squeezer drive, and why it can put tips back on a rack rather than only discarding them.""" + +SHAFT_DIAMETER = 7.0 +"""How wide a tip mounting shaft is, in mm.""" + +SHAFT_LENGTH = 8.0 +"""How far a tip mounting shaft reaches below the pipette carrying it, in mm.""" + + +class TipMountingShaft(Resource): + """The end of one pipetting channel, where a tip is mounted and sealed. + + Named as the patent literature names it. Vendors do not agree: Hamilton's firmware names only the + stop disc, the collar its drive positions by, and reserves "tip cone" for the tip's own geometry. + What is invariant is that the shaft carries its channel through to the tip. + + A device whose channels move independently carries these one each. A device whose channels move + as one carries them inside an `NChannelPipette`. + + Round, and modelled as a cylinder: it is a shaft, and a tip is sealed onto it by turning around + its axis. A collected tip is a child of the shaft carrying it, which keeps the two together as + the shaft moves. + """ + + def __init__( + self, + name: str, + tip_pickup_mode: TipPickupMode, + size_x: float = SHAFT_DIAMETER, + size_y: float = SHAFT_DIAMETER, + size_z: float = SHAFT_LENGTH, + category: str = "tip_mounting_shaft", + model: Optional[str] = None, + cross_section_type: str = CrossSectionType.CIRCLE.value, + ): + """ + Args: + name: what to call this one. + tip_pickup_mode: how it holds onto a tip. + size_x: how wide it is across, in mm. Its diameter, since it is round. + size_y: how deep it is, in mm. Its diameter again, for the same reason. + size_z: how far it reaches below whatever carries it, in mm. + category: what kind of resource this is. + model: which channel this is. + cross_section_type: its shape across, as `serialize` writes it. Always a circle; taken so a + serialized shaft deserializes. + + Raises: + ValueError: If the tip pickup mode is not one this models, or the cross section is not a + circle. + """ + if tip_pickup_mode not in get_args(TipPickupMode): + raise ValueError( + f"unknown tip_pickup_mode {tip_pickup_mode!r}, expected one of {get_args(TipPickupMode)}" + ) + if cross_section_type != CrossSectionType.CIRCLE.value: + raise ValueError(f"a tip mounting shaft is round, not {cross_section_type!r}") + self.tip_pickup_mode = tip_pickup_mode + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category=category, + model=model, + ) + + @property + def tip(self) -> Optional[Resource]: + """The tip this shaft is carrying, or None if it is empty.""" + return self.children[0] if self.children else None + + def has_tip(self) -> bool: + """Whether this shaft is carrying a tip.""" + return len(self.children) > 0 + + def mount_tip(self, tip: Resource) -> None: + """Take a tip onto this shaft, hanging below it by however far it stands proud. + + Call this once the device has confirmed the pickup: a shaft that is given a tip it did not + manage to collect reports one it is not holding. + + Args: + tip: the tip that was collected. It is reparented here, so it leaves wherever it was. + + Raises: + RuntimeError: If this shaft is already carrying a tip. + """ + if self.has_tip(): + raise RuntimeError(f"{self.name} is already carrying {self.children[0].name}") + self.assign_child_resource(tip, location=Coordinate(0.0, 0.0, -tip.get_absolute_size_z())) + + def release_tip(self) -> Resource: + """Let go of the tip this shaft is carrying. + + Reparenting it is the caller's: a tip put back on a rack belongs to its spot, and one dropped + in the waste belongs nowhere. + + Returns: + The tip that was released. + + Raises: + RuntimeError: If this shaft is not carrying one. + """ + tip = self.tip + if tip is None: + raise RuntimeError(f"{self.name} is not carrying a tip") + self.unassign_child_resource(tip) + return tip + + def tip_bottom(self) -> Coordinate: + """Where the bottom of what this shaft carries is, relative to the channel. + + The shaft's own reference point when it is empty, and the end of the tip when it is not, + which is what has to clear the deck. + + Returns: + The offset from the channel to the bottom of what it carries. + """ + tip = self.tip + return Coordinate.zero() if tip is None else Coordinate(0.0, 0.0, -tip.get_absolute_size_z()) + + def serialize(self) -> dict: + """What its size does not say: how it holds a tip, and that it is round rather than a box.""" + return { + **super().serialize(), + "tip_pickup_mode": self.tip_pickup_mode, + "cross_section_type": CrossSectionType.CIRCLE.value, + } + + +class NChannelPipette(ItemizedResource[TipMountingShaft]): + """A rigid grid of pipetting channels that move as one. + + Only for channels that share their drives: where each moves on its own, it is a + `TipMountingShaft` in its own right and there is nothing for this to wrap. + + The channels are the items, so where any one of them is follows from where the pipette is and how + the grid is spaced - `item_dx` and `item_dy` are that spacing, and `channel_pitch` is the word the + drivers use for it. Located like any resource, by its left front bottom corner; where the drives + report it - which need not be a channel at all - is `reference_point`. + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + reference_point: Coordinate, + ordered_items: Optional[Dict[str, TipMountingShaft]] = None, + ordering: Optional[OrderedDict[str, str]] = None, + independent_channel_actuation: bool = False, + category: str = "n_channel_pipette", + model: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + ): + """ + Args: + name: what to call this one. + size_x: how wide the pipette is, in mm. + size_y: how deep it is, in mm. + size_z: how tall it is, from its lowest fixed feature to its top, in mm. + reference_point: the point the drives report and commands name, from the left front bottom + corner. Usually where a tip is picked up - the axis of the first shaft, at the end of it - + but a pipette is free to be measured from anywhere. + ordered_items: its channels, keyed by identifier. + ordering: the channels it already has, when one is being rebuilt rather than built. + independent_channel_actuation: whether its channels can be worked one at a time rather than + only all together. False unless each has its own actuation. + category: what kind of resource this is. + model: which pipette this is. + metadata: anything else worth keeping with it. + """ + super().__init__( + name, + size_x, + size_y, + size_z, + ordered_items=ordered_items, + ordering=ordering, + category=category, + model=model, + metadata=metadata, + ) + self.reference_point = reference_point + self.independent_channel_actuation = independent_channel_actuation + + @property + def num_channels(self) -> int: + """How many channels this pipette has.""" + return self.num_items + + @property + def channel_pitch(self) -> float: + """The centre-to-centre spacing of the channels, in mm. + + Returns: + The spacing, in mm. + + Raises: + ValueError: If the pipette has a single row or column, which has nothing to be spaced from. + """ + return self.item_dx if self.num_items_x > 1 else self.item_dy + + @property + def tip_pickup_mode(self) -> TipPickupMode: + """How its channels hold onto a tip. + + Read from the channels rather than kept alongside them, so there is nothing to disagree with. + + Returns: + The mode its channels use. + + Raises: + ValueError: If the pipette has no channels to read it from. + """ + return self.get_item(0).tip_pickup_mode + + def serialize(self) -> dict: + """What its size and its channels do not say: where it is measured from, and whether they can + + Returns: + The serialized resource, with those two fields added. + be worked one at a time.""" + return { + **super().serialize(), + "reference_point": self.reference_point.serialize(), + "independent_channel_actuation": self.independent_channel_actuation, + } diff --git a/pylabrobot/resources/n_channel_pipettes_tests.py b/pylabrobot/resources/n_channel_pipettes_tests.py new file mode 100644 index 00000000000..6c734e930b1 --- /dev/null +++ b/pylabrobot/resources/n_channel_pipettes_tests.py @@ -0,0 +1,20 @@ +import unittest + +from pylabrobot.resources.n_channel_pipettes import TipMountingShaft +from pylabrobot.resources.resource import Resource + + +class TestTipMountingShaft(unittest.TestCase): + """A shaft is saved with what its size does not say, and read back from it.""" + + def test_a_serialized_shaft_deserializes(self): + shaft = TipMountingShaft(name="shaft", tip_pickup_mode="core") + self.assertEqual(Resource.deserialize(shaft.serialize()).serialize(), shaft.serialize()) + + def test_a_shaft_is_round(self): + with self.assertRaises(ValueError): + TipMountingShaft(name="shaft", tip_pickup_mode="core", cross_section_type="rectangle") + + +if __name__ == "__main__": + unittest.main() diff --git a/pyproject.toml b/pyproject.toml index de14549cd74..9e80ea4036c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,12 @@ namespaces = false exclude = ["tools*", "docs*"] [tool.setuptools.package-data] -pylabrobot = ["visualizer/*", "visualizer/img/*", "version.txt"] +pylabrobot = [ + "visualizer/*", + "visualizer/img/*", + "version.txt", + "hamilton/star/driver/recordings/*.json", +] [tool.ruff] line-length = 100 From 5a374efe47c81bcab47a56dbc37ac786f4b52724 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 15 Sep 2026 08:52:50 +0100 Subject: [PATCH 2/2] STAR autoload: discovery reads the initialization track and the adjustment A saved configuration now carries them, so a device can be simulated from it. An autoload that will not answer keeps nothing and setup continues with a warning. Co-Authored-By: Claude Opus 5 --- .../hamilton/star/driver/features/autoload.py | 13 ++++++++ .../star/driver/features/autoload_tests.py | 33 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/pylabrobot/hamilton/star/driver/features/autoload.py b/pylabrobot/hamilton/star/driver/features/autoload.py index f0e4455fafd..7bbc551164a 100644 --- a/pylabrobot/hamilton/star/driver/features/autoload.py +++ b/pylabrobot/hamilton/star/driver/features/autoload.py @@ -372,6 +372,19 @@ async def discover(self): c.x_drive_mm_per_increment, c.loading_indicators_installed, ) = await self.request_module_configuration() + # Which track the X drive homes against, and whether this unit was adjusted: read here so a + # saved configuration carries them. One that will not say keeps nothing rather than failing + # setup, as the device's own identity reads do. + try: + await self.request_init_slot() + except Exception: + logger.warning( + "the autoload did not say which track it initializes against; leaving it unrecorded" + ) + try: + await self.request_adjustment_status() + except Exception: + logger.warning("the autoload did not say whether it is adjusted; leaving it unrecorded") # Both scanners read the 1D symbologies; only the 2D one also reads the 2D ones. An autoload # that is neither has no scanner, and its symbologies stay unset. if c.autoload_type in ("1D barcode scanner", "2D barcode scanner"): diff --git a/pylabrobot/hamilton/star/driver/features/autoload_tests.py b/pylabrobot/hamilton/star/driver/features/autoload_tests.py index 13b5e1e9d6f..ac798df0edf 100644 --- a/pylabrobot/hamilton/star/driver/features/autoload_tests.py +++ b/pylabrobot/hamilton/star/driver/features/autoload_tests.py @@ -127,6 +127,39 @@ async def test_a_park_raises_a_wheel_below_safe_z_before_moving(self): self.assertLess(sent.index("C0IV"), sent.index("I0XP")) +class TestDiscovery(unittest.IsolatedAsyncioTestCase): + """Discovery reads what a saved configuration has to carry for a device to be simulated from it.""" + + async def test_it_reads_the_initialization_track_and_the_adjustment(self): + driver = STARSimulationDriver(deck=STARDeck(), declared_configuration_json=RECORDING_STAR) + sent: List[str] = [] + answer = driver.send_command + + async def recorded(module: str, command: str, **kwargs: Any): + sent.append(module + command) + return await answer(module=module, command=command, **kwargs) + + driver.send_command = recorded # type: ignore[assignment] + await driver.setup() + + self.assertIn("I0QX", sent) + self.assertIn("I0RJ", sent) + + async def test_a_device_declared_without_them_still_sets_up(self): + driver = STARSimulationDriver(deck=STARDeck(), declared_configuration_json=RECORDING_STAR) + declared = driver.simulated_autoload + declared.initialization_track = None + declared.adjustment_date = None + declared.adjusted = None + + await driver.setup() + + feature = driver.autoload + assert feature is not None + self.assertIsNone(feature.configuration.initialization_track) + self.assertIsNone(feature.configuration.adjusted) + + class TestLoadCarrier(unittest.IsolatedAsyncioTestCase): """The command that reads a carrier's barcode is the one that pulls it in off the tray."""