From 735e9ddf227bbda5920672aec7fc9225e97bf515 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 9 Nov 2026 18:00:31 +0000 Subject: [PATCH] v1 `Mantis`: connect, identify, read the device, clamp the plate and read the chip tags - the link on the FTDI's interface B: framing and check word, one command in flight, replies matched on packet number and board, events to waiters registered before the command is sent; a negative status is a refusal and raises `CommandRefusedError` with the device's message - discovery reads each board's firmware: only board 0 must answer, board 11 answering means v4 - reads for firmware version, name and motor limits - the declared configuration check, a declared board that does not answer refuses setup, and confirmed firmware for the v4 set seen on a device - the unit's own values: read from its vendor folder (`oem_config_directory`) or a saved configuration, which must agree where both hold a value; else the generation's recording, with a warning; `save_configuration` keeps the unit's values so the folder is needed once - recordings of a v4 and a v3 - errors by kind under `MantisError`, each also its built-in, with tables for the motor, sensor alarm and clamp state codes - the plate clamp on the LC3 board: `lock(*, level)` at the unit's levels waits for clamping done, raises at once on a failure state and releases on failure; `unlock()`, `request_state()`; stop releases a clamp not reading unclamping done - the RFID reader on interface A: `request_tag(*, slot)`, `request_tags()`; the antenna switch through WritePPI at the multiplexer's address; built only when the reader answers - `MantisLiquidClass` and `mantis_mapping`, the v4 liquid classes by viscosity band and chip type - `MantisDevice` with the `Mantis()` factory, `mantis.plate_clamp` and `mantis.rfid` - simulation above the wire, from a recording - the resource model in mm: the main body, the plate holder and both chip racks - design.md, the hello-world notebook and the registry entry Co-Authored-By: xbtu2 <228601186+xbtu2@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- docs/_static/devices.json | 13 + docs/api/pylabrobot.formulatrix.rst | 38 ++ docs/api/pylabrobot.rst | 1 + docs/user_guide/formulatrix/index.md | 7 + .../formulatrix/mantis/hello-world.ipynb | 412 ++++++++++++ docs/user_guide/index.md | 1 + pylabrobot/formulatrix/__init__.py | 3 + pylabrobot/formulatrix/mantis/__init__.py | 34 + pylabrobot/formulatrix/mantis/design.md | 208 ++++++ pylabrobot/formulatrix/mantis/device.py | 181 +++++ pylabrobot/formulatrix/mantis/device_tests.py | 31 + .../formulatrix/mantis/driver/__init__.py | 29 + .../mantis/driver/configuration.py | 72 ++ .../mantis/driver/configuration_tests.py | 23 + .../driver/confirmed_firmware_versions.py | 7 + .../formulatrix/mantis/driver/errors.py | 189 ++++++ .../mantis/driver/features/__init__.py | 0 .../mantis/driver/features/plate_clamp.py | 162 +++++ .../driver/features/plate_clamp_tests.py | 145 ++++ .../mantis/driver/features/rfid.py | 277 ++++++++ .../mantis/driver/features/rfid_tests.py | 138 ++++ .../formulatrix/mantis/driver/framing.py | 125 ++++ .../mantis/driver/framing_tests.py | 93 +++ .../formulatrix/mantis/driver/master.py | 619 ++++++++++++++++++ .../formulatrix/mantis/driver/master_tests.py | 322 +++++++++ .../formulatrix/mantis/driver/oem_config.py | 107 +++ .../mantis/driver/oem_config_tests.py | 154 +++++ .../mantis/driver/recordings/README.md | 44 ++ .../mantis/driver/recordings/mantis_v3.json | 5 + .../mantis/driver/recordings/mantis_v4.json | 52 ++ .../formulatrix/mantis/driver/simulator.py | 175 +++++ .../mantis/driver/simulator_tests.py | 71 ++ .../mantis/liquid_classes/__init__.py | 6 + .../formulatrix/mantis/liquid_classes/base.py | 61 ++ .../mantis/liquid_classes/mapping.py | 474 ++++++++++++++ .../mantis/liquid_classes/mapping_tests.py | 27 + .../mantis/resource_model/Mantis.glb | Bin 0 -> 90424 bytes .../mantis/resource_model/__init__.py | 45 ++ .../formulatrix_mantis_left_chip_rack.glb | Bin 0 -> 14920 bytes .../formulatrix_mantis_right_chip_rack.glb | Bin 0 -> 11456 bytes .../mantis/resource_model/manifest.json | 43 ++ 41 files changed, 4394 insertions(+) create mode 100644 docs/api/pylabrobot.formulatrix.rst create mode 100644 docs/user_guide/formulatrix/index.md create mode 100644 docs/user_guide/formulatrix/mantis/hello-world.ipynb create mode 100644 pylabrobot/formulatrix/__init__.py create mode 100644 pylabrobot/formulatrix/mantis/__init__.py create mode 100644 pylabrobot/formulatrix/mantis/design.md create mode 100644 pylabrobot/formulatrix/mantis/device.py create mode 100644 pylabrobot/formulatrix/mantis/device_tests.py create mode 100644 pylabrobot/formulatrix/mantis/driver/__init__.py create mode 100644 pylabrobot/formulatrix/mantis/driver/configuration.py create mode 100644 pylabrobot/formulatrix/mantis/driver/configuration_tests.py create mode 100644 pylabrobot/formulatrix/mantis/driver/confirmed_firmware_versions.py create mode 100644 pylabrobot/formulatrix/mantis/driver/errors.py create mode 100644 pylabrobot/formulatrix/mantis/driver/features/__init__.py create mode 100644 pylabrobot/formulatrix/mantis/driver/features/plate_clamp.py create mode 100644 pylabrobot/formulatrix/mantis/driver/features/plate_clamp_tests.py create mode 100644 pylabrobot/formulatrix/mantis/driver/features/rfid.py create mode 100644 pylabrobot/formulatrix/mantis/driver/features/rfid_tests.py create mode 100644 pylabrobot/formulatrix/mantis/driver/framing.py create mode 100644 pylabrobot/formulatrix/mantis/driver/framing_tests.py create mode 100644 pylabrobot/formulatrix/mantis/driver/master.py create mode 100644 pylabrobot/formulatrix/mantis/driver/master_tests.py create mode 100644 pylabrobot/formulatrix/mantis/driver/oem_config.py create mode 100644 pylabrobot/formulatrix/mantis/driver/oem_config_tests.py create mode 100644 pylabrobot/formulatrix/mantis/driver/recordings/README.md create mode 100644 pylabrobot/formulatrix/mantis/driver/recordings/mantis_v3.json create mode 100644 pylabrobot/formulatrix/mantis/driver/recordings/mantis_v4.json create mode 100644 pylabrobot/formulatrix/mantis/driver/simulator.py create mode 100644 pylabrobot/formulatrix/mantis/driver/simulator_tests.py create mode 100644 pylabrobot/formulatrix/mantis/liquid_classes/__init__.py create mode 100644 pylabrobot/formulatrix/mantis/liquid_classes/base.py create mode 100644 pylabrobot/formulatrix/mantis/liquid_classes/mapping.py create mode 100644 pylabrobot/formulatrix/mantis/liquid_classes/mapping_tests.py create mode 100644 pylabrobot/formulatrix/mantis/resource_model/Mantis.glb create mode 100644 pylabrobot/formulatrix/mantis/resource_model/__init__.py create mode 100644 pylabrobot/formulatrix/mantis/resource_model/formulatrix_mantis_left_chip_rack.glb create mode 100644 pylabrobot/formulatrix/mantis/resource_model/formulatrix_mantis_right_chip_rack.glb create mode 100644 pylabrobot/formulatrix/mantis/resource_model/manifest.json diff --git a/docs/_static/devices.json b/docs/_static/devices.json index 901e2ca823c..cf77dc99ea3 100644 --- a/docs/_static/devices.json +++ b/docs/_static/devices.json @@ -428,13 +428,26 @@ }, { "id": "formulatrix-mantis", + "needs_hardware_testing": true, "vendor": "Formulatrix", "name": "Mantis", + "models": [ + { + "name": "v3" + }, + { + "name": "v4" + } + ], "kind": "bulk dispenser", "capabilities": [ "dispensing" ], "status": "wip", + "api": "pylabrobot.formulatrix.Mantis", + "api_version": "v1", + "code_slug": "formulatrix/mantis", + "doc_slug": "formulatrix/mantis/hello-world", "manager": "https://discuss.pylabrobot.org/u/rickwierenga", "oem": "https://formulatrix.com/liquid-handling-systems/mantis-liquid-handler/" }, diff --git a/docs/api/pylabrobot.formulatrix.rst b/docs/api/pylabrobot.formulatrix.rst new file mode 100644 index 00000000000..b0c31326126 --- /dev/null +++ b/docs/api/pylabrobot.formulatrix.rst @@ -0,0 +1,38 @@ +.. currentmodule:: pylabrobot.formulatrix.mantis + +pylabrobot.formulatrix package +============================== + +Mantis +------ + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Mantis + MantisDevice + driver.master.MantisDriver + driver.simulator.MantisSimulationDriver + driver.simulator.MantisSimulationPlateClamp + driver.simulator.MantisSimulationRfid + driver.features.plate_clamp.PlateClamp + driver.features.rfid.Rfid + driver.configuration.DeviceConfiguration + driver.errors.MantisError + driver.errors.FrameError + driver.errors.ChecksumError + driver.errors.MantisNotConnectedError + driver.errors.MantisConnectionLostError + driver.errors.MantisTimeoutError + driver.errors.MantisConfigurationError + driver.errors.PlateClampError + driver.errors.MantisMotionError + driver.errors.MantisPressureError + driver.errors.InvalidQueueItemError + driver.errors.MantisKinematicsError + driver.errors.MantisCoordinateError + driver.errors.MantisPrimeError + resource_model.chip_rack + liquid_classes.MantisLiquidClass diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst index ed2eaa11d2c..93a83ba709e 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -29,6 +29,7 @@ Manufacturers pylabrobot.byonoy pylabrobot.cole_parmer pylabrobot.curiox + pylabrobot.formulatrix pylabrobot.hamilton pylabrobot.high_res pylabrobot.inheco diff --git a/docs/user_guide/formulatrix/index.md b/docs/user_guide/formulatrix/index.md new file mode 100644 index 00000000000..f117dc96cec --- /dev/null +++ b/docs/user_guide/formulatrix/index.md @@ -0,0 +1,7 @@ +# Formulatrix + +```{toctree} +:maxdepth: 1 + +mantis/hello-world +``` diff --git a/docs/user_guide/formulatrix/mantis/hello-world.ipynb b/docs/user_guide/formulatrix/mantis/hello-world.ipynb new file mode 100644 index 00000000000..91cae7f86c5 --- /dev/null +++ b/docs/user_guide/formulatrix/mantis/hello-world.ipynb @@ -0,0 +1,412 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "mantis-00", + "metadata": {}, + "source": [ + "# Formulatrix Mantis\n", + "\n", + "The Mantis is a liquid dispenser from Formulatrix that dispenses through exchangeable chips. PyLabRobot connects to it, identifies it, reads its firmware versions, name and motor limits, locks and unlocks the plate clamp, and reads which chip sits in each input slot. Nothing else in this driver moves the device, switches the pump on or opens a valve." + ] + }, + { + "cell_type": "markdown", + "id": "mantis-01", + "metadata": {}, + "source": [ + "```{device-card} formulatrix-mantis\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "mantis-02", + "metadata": {}, + "source": [ + "```{warning}\n", + "This driver has run on one Mantis v4, on macOS and on Windows; no v3 has run it yet. `setup()` logs a warning on a firmware set not seen on a Mantis yet, which includes every v3. If you run this guide on a Mantis, please [report the result](../../needs-testing.md).\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "mantis-03", + "metadata": {}, + "source": [ + "## Properties\n", + "\n", + "| | |\n", + "|---|---|\n", + "| Generations | v3 and v4, told apart at setup |\n", + "| Reads | firmware versions, device name, motor limits |\n", + "| Interface | USB, an FTDI bridge (VID 0x0403 / PID 0x6010): interface B for commands, interface A for the RFID reader |\n", + "| Plate clamp | lock at one of the unit's levels, waiting for clamping done; unlock |\n", + "| RFID | the chip in each input slot: serial, chip type, date of manufacture |\n", + "| Unit configuration | the clamp's lock levels and each slot's RFID antenna, from the unit's vendor folder or a saved file; else a recording of its generation |\n", + "| Not supported yet | motion and the motor reads, pneumatics and the pressure reads, chips, dispensing |" + ] + }, + { + "cell_type": "markdown", + "id": "mantis-04", + "metadata": {}, + "source": [ + "## How it talks\n", + "\n", + "The Mantis connects over USB through a two-channel FTDI bridge. Interface B carries binary frames to the device's boards: 0 for motion, 10 for pressure, 11 for the absolute position sensors (v4) and 12 for the LC3 board. The driver sends one command at a time and logs every event the device sends while connected. Interface A carries the RFID reader's commands.\n", + "\n", + "At setup the driver reads each board's firmware version. A device whose board 11 answers is a v4; one whose board 11 does not is taken to be a v3." + ] + }, + { + "cell_type": "markdown", + "id": "mantis-05", + "metadata": {}, + "source": [ + "## Physical setup\n", + "\n", + "1. Connect the Mantis to the computer with its USB cable and switch it on.\n", + "2. Install the FTDI dependencies: `pip install \"pylabrobot[ftdi]\"` (see [Installation](../../getting-started/installation.md)).\n", + "3. Close any other software connected to the Mantis." + ] + }, + { + "cell_type": "markdown", + "id": "unit-configuration", + "metadata": {}, + "source": [ + "## Unit configuration\n", + "\n", + "Some of a Mantis's values are set per unit and cannot be read off the device; they sit in the `.config` files of its vendor install, in `Data/Device/Configs`. Give setup that folder once, then save the configuration: the saved file carries the unit's values from then on, and the folder is no longer needed. So far these are the plate clamp's lock levels and each input slot's RFID antenna.\n", + "\n", + "Without them, a recording of a unit of the same generation supplies them, with a warning: the Mantis runs, less accurately. Where the folder and a saved file both hold a value, they must agree, or setup refuses." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "unit-configuration-save", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.formulatrix import Mantis\n", + "\n", + "mantis = Mantis(name=\"mantis\", oem_config_directory=\"path/to/Data/Device/Configs\")" + ] + }, + { + "cell_type": "markdown", + "id": "mantis-08", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "`setup()` opens both links, reads the firmware versions and the motor limits, takes the unit's values and sets up the RFID reader. It does not move anything." + ] + }, + { + "cell_type": "code", + "id": "mantis-09", + "metadata": {}, + "source": [ + "await mantis.setup()\n", + "mantis.driver.configuration" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "mantis-10", + "metadata": {}, + "source": [ + "### Saved configuration\n", + "\n", + "Save what the device reported and the unit's values. Build the device with that file from then on, `Mantis(name=\"mantis\", declared_configuration_json=\"my_mantis.json\")`, with no folder: setup refuses a device that disagrees with it on the generation or the motor limits." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mantis-11", + "metadata": {}, + "outputs": [], + "source": [ + "mantis.driver.save_configuration(path=\"my_mantis.json\")" + ] + }, + { + "cell_type": "markdown", + "id": "mantis-simulation-text", + "metadata": {}, + "source": [ + "### Simulation\n", + "\n", + "`Mantis(simulation=True)` builds a Mantis with nothing behind it; nothing reaches a wire. It takes its configuration, lock levels and RFID antennas from a Mantis v4's recording (`RECORDING_MANTIS_V4`, or a file given as `declared_configuration_json`), its reads answer from it, its plate clamp's state changes at once, and every declared RFID slot reads empty. The device name is not simulated." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mantis-simulation-code", + "metadata": {}, + "outputs": [], + "source": [ + "simulated = Mantis(name=\"simulated_mantis\", simulation=True)\n", + "await simulated.setup()\n", + "simulated.driver.configuration" + ] + }, + { + "cell_type": "markdown", + "id": "mantis-simulation-stop-text", + "metadata": {}, + "source": [ + "Stop the simulated Mantis when done with it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mantis-simulation-stop-code", + "metadata": {}, + "outputs": [], + "source": [ + "await simulated.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "mantis-viewer-1", + "metadata": {}, + "source": [ + "## Visualizer\n", + "\n", + "Show the Mantis in the 3D visualizer. `start()` serves it and opens it in a browser." + ] + }, + { + "cell_type": "code", + "id": "mantis-viewer-2", + "metadata": {}, + "source": [ + "from pylabrobot.visualizer3D import Viewer3D\n", + "\n", + "viewer = Viewer3D(mantis, name=\"mantis_hello_world\")\n", + "await viewer.start()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "mantis-12", + "metadata": {}, + "source": [ + "## Firmware versions\n", + "\n", + "Each board's firmware version, by board, as setup read them; None for a board that did not answer." + ] + }, + { + "cell_type": "code", + "id": "mantis-13", + "metadata": {}, + "source": [ + "mantis.driver.configuration.firmware_versions" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "mantis-14", + "metadata": {}, + "source": [ + "## Name\n", + "\n", + "The name the motion firmware reports, `LiquidHandler` on the v4 we have run. Whether it differs between units is not known; it does not identify this Mantis." + ] + }, + { + "cell_type": "code", + "id": "mantis-15", + "metadata": {}, + "source": [ + "await mantis.driver.request_name()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "mantis-16", + "metadata": {}, + "source": [ + "## Motors\n", + "\n", + "Motors 0 and 1 drive the arm, motor 2 drives Z. Values are in the device's units. Each motor's limits, as setup read them:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mantis-17", + "metadata": {}, + "outputs": [], + "source": [ + "mantis.driver.configuration.motor_limits" + ] + }, + { + "cell_type": "markdown", + "id": "mantis-clamp-text", + "metadata": {}, + "source": [ + "## Plate clamp\n", + "\n", + "The clamp holds the plate on the deck. `lock` releases it, locks it at one of the unit's levels, 1 the weakest, and returns once the device reports clamping done; if it does not, the clamp is released again and an error is raised. `stop()` releases a clamp that does not read unclamping done.\n", + "\n", + "It exists only when the LC3 board (board 12) answered at setup. Its levels are the declared ones:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "plate-clamp-levels", + "metadata": {}, + "outputs": [], + "source": [ + "mantis.plate_clamp.levels" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mantis-clamp-lock-code", + "metadata": {}, + "outputs": [], + "source": [ + "await mantis.plate_clamp.lock(level=2)" + ] + }, + { + "cell_type": "markdown", + "id": "mantis-clamp-state-text", + "metadata": {}, + "source": [ + "The clamp's state: 0 idle, 1 stopped, 2 clamping, 3 clamping done, 4 clamping error timeout, 5 unclamping, 6 unclamping done, 7 unclamping error timeout, 8 motor fault. A lock that reads 4 or 8, or an unlock that reads 7 or 8, raises `PlateClampError` at once." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mantis-clamp-state-code", + "metadata": {}, + "outputs": [], + "source": [ + "await mantis.plate_clamp.request_state()" + ] + }, + { + "cell_type": "markdown", + "id": "mantis-clamp-unlock-text", + "metadata": {}, + "source": [ + "Release the clamp:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mantis-clamp-unlock-code", + "metadata": {}, + "outputs": [], + "source": [ + "await mantis.plate_clamp.unlock()" + ] + }, + { + "cell_type": "markdown", + "id": "rfid", + "metadata": {}, + "source": [ + "## RFID\n", + "\n", + "The RFID reader reads the tag of the chip in each input slot, through that slot's antenna. A slot with no chip reads None. It exists only when the reader answered on interface A at setup; a reader that does not answer leaves setup working, without it. Each slot's antenna, as declared:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "rfid-antennas", + "metadata": {}, + "outputs": [], + "source": [ + "mantis.rfid.slot_to_antenna" + ] + }, + { + "cell_type": "markdown", + "id": "rfid-read", + "metadata": {}, + "source": [ + "Read one slot, or every slot in turn:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "rfid-read-slot", + "metadata": {}, + "outputs": [], + "source": [ + "await mantis.rfid.request_tag(slot=1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "rfid-read-all", + "metadata": {}, + "outputs": [], + "source": [ + "await mantis.rfid.request_tags()" + ] + }, + { + "cell_type": "markdown", + "id": "mantis-20", + "metadata": {}, + "source": [ + "## Stop\n", + "\n", + "Stop the visualizer and close the link." + ] + }, + { + "cell_type": "code", + "id": "mantis-21", + "metadata": {}, + "source": [ + "await viewer.stop()\n", + "await mantis.stop()" + ], + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index b28b71212db..624d37f53eb 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -37,6 +37,7 @@ brooks/index byonoy/index cole_parmer/index curiox/index +formulatrix/index hamilton/index high_res/index inheco/index diff --git a/pylabrobot/formulatrix/__init__.py b/pylabrobot/formulatrix/__init__.py new file mode 100644 index 00000000000..48557383913 --- /dev/null +++ b/pylabrobot/formulatrix/__init__.py @@ -0,0 +1,3 @@ +"""Formulatrix devices.""" + +from .mantis import Mantis diff --git a/pylabrobot/formulatrix/mantis/__init__.py b/pylabrobot/formulatrix/mantis/__init__.py new file mode 100644 index 00000000000..de93b8450f3 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/__init__.py @@ -0,0 +1,34 @@ +"""Formulatrix Mantis liquid dispenser.""" + +from .device import RECORDING_MANTIS_V4, Mantis, MantisDevice +from .driver import ( + CLAMP_STATE_DESCRIPTIONS, + CONFIRMED_FIRMWARE_VERSIONS, + MOTOR_ERROR_CODE_DESCRIPTIONS, + MOTOR_STATUS_ERROR_BITS, + SENSOR_ALARM_CODE_DESCRIPTIONS, + ChecksumError, + CommandRefusedError, + DeviceConfiguration, + FrameError, + InvalidQueueItemError, + MantisConfigurationError, + MantisConnectionLostError, + MantisCoordinateError, + MantisDriver, + MantisError, + MantisKinematicsError, + MantisMotionError, + MantisNotConnectedError, + MantisPressureError, + MantisPrimeError, + MantisSimulationDriver, + MantisSimulationPlateClamp, + MantisSimulationRfid, + MantisTimeoutError, + PlateClamp, + PlateClampError, + Rfid, +) +from .driver.configuration import RECORDING_MANTIS_V3 +from .liquid_classes import MantisLiquidClass, mantis_mapping diff --git a/pylabrobot/formulatrix/mantis/design.md b/pylabrobot/formulatrix/mantis/design.md new file mode 100644 index 00000000000..6cec7758966 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/design.md @@ -0,0 +1,208 @@ +# How this integration is built + +The patterns the Mantis integration is written to, so that a new feature can follow them and an +existing one can be checked against them. Each is stated as a rule, with where it is followed. The +last section lists what is open and where the code does not follow a rule yet. + +## The layers + +``` +device.py MantisDevice, a Resource, and the Mantis() factory. setup() / stop(), + the driver, the parts. +driver/master.py MantisDriver. Owns the link, the reader, reply matching, events, + discovery and the saved configuration. +driver/simulator.py MantisSimulationDriver, MantisSimulationPlateClamp, + MantisSimulationRfid. Answer from the declared configuration; nothing + reaches a wire. +driver/framing.py The frame: header, check word, splitting, payload strings, boards. +driver/errors.py MantisError and one error per kind; what the device's codes mean. +driver/configuration.py DeviceConfiguration: what the device answered about itself. +driver/oem_config.py The unit's own values, read from its vendor + .config files, into a declared configuration. +driver/confirmed_firmware_versions.py + The firmware sets confirmed on a device, per generation. +driver/features/ One class per thing the device is fitted with: PlateClamp, Rfid. +driver/recordings/ One real Mantis of each generation: its configuration and unit values. +resource_model/ The parts the device is modelled as, their files and manifest. +liquid_classes/ MantisLiquidClass and mantis_mapping, by viscosity band and chip type. +``` + +## The patterns + +**M1. One driver. Features hang off it.** (STAR P1.) A feature is a plain class holding `_driver` +and sending through it. No per-feature backend or frontend. + +**M2. The generation comes from discovery.** `_request_configuration` reads the firmware version +on boards 0, 10, 11 and 12. Only board 0 must answer. Each other board gets one attempt, and one +that times out, refuses or answers a malformed version is absent, not a fault: it is logged and +recorded as `None`. Board 11 answering means v4; absent means v3. Board 0's version string and the +serial number decide nothing. + +**M3. Nothing exists until the device says it is fitted.** (STAR P2.) `discover` reads the +configuration (`_request_configuration`), checks it against a declared one, then +`_discover_features` builds the features it lists; a refused setup builds none. The RFID reader +is the exception: `discover` builds it when it answers its ping on interface A, after every +opening of the link. +Each feature is an `Optional` attribute of the driver, `None` until `_discover_features` builds +it: `plate_clamp` is built only when board 12 answered discovery, so a v3 without it has none. A +difference between v3 and v4 is expressed there, by which feature is built, never by a +branch on the generation inside a command. + +**M4. A configuration holds facts only, and is data.** (STAR P3, P13.) `DeviceConfiguration` holds +what the device answered: generation, firmware versions, motor limits. `save_configuration` writes +it; `declared_configuration_json` reads one back and setup refuses if the device disagrees on a +fitted field (`FITTED_FIELDS`). Each feature gets its own configuration when it is added, holding +its own facts; the device configuration does not grow per feature. Motor limits are compared as a +build fact: every v4 is taken to share them until a second device shows otherwise. + +**M4a. A unit's own values come from its folder or its declared file, else its generation's +recording.** What is set per unit and cannot be read off the device sits in the unit's vendor +`Data/Device/Configs` folder (`oem_config_directory`). Each value is a block (`UNIT_BLOCKS`): where +the folder and the declared file both hold it they must agree, or setup refuses listing the +differences; one source suffices; with neither, the generation's recording supplies it with a +warning, less accurate on this unit. `save_configuration` saves the unit's blocks, never a +recording's, so after one setup with the folder the saved file alone carries them. A block is +loaded only where its source applies: `plate_clamp.levels` for a clamp driven as a DC motor, +`rfid.slot_to_antenna` where `RFID.config` exists. + +Which vendor values are the unit's own, from a v3 and a v4 install side by side: + +| values | file | | +|---|---|---| +| deck transform and tilt: `MicroplateTransformationMatrix`, `MicroplateOffsetX/Y` | `Device.config` | the unit's | +| arm offsets `Motor.1/2.Offset`; homing `Motor.1/2.HomingPositionN`, `HomingPositionA` (v4) | `RobotArm.config` | the unit's | +| absolute-encoder correction (v4) | `System/Encoder Correction/*.csv` | the unit's | +| plate holder origin | `Device/Adapters/SBS Adapter.ad.txt` | the unit's | +| chip changer and sequence safe Z (`ChipChanger.MovementZ`, `*.seq.txt`) | `ChipChanger.config`, sequences | unit or generation, unsettled | +| clamp levels, RFID antennas | `Device.config`, `RFID.config` | one unit seen; read as the unit's | +| deck reference points and heights, arm geometry, drop verification | `StageTuning.config`, `RobotArm.config`, `DropVerification.config` | the same on both units | +| chip catalogue and liquid classes | `System/`, `Device/Others/` | the software's; liquid classes ship as code (M4c) | + +**M4b. Errors are one class per kind, under `MantisError`.** (STAR's errors by kind.) Each also +subclasses the built-in it corresponds to, so an `except RuntimeError`, `ValueError` or +`TimeoutError` still matches. A device code is carried on the error, and what it means is a table +(`MOTOR_ERROR_CODE_DESCRIPTIONS`, `CLAMP_STATE_DESCRIPTIONS`, ...), not a class per code. A state +the device reports as a failure raises at once, never after a full wait. + +**M4c. Liquid classes ship as code.** (STAR's `liquid_classes/mapping.py`.) A liquid class is plain +data, one per (viscosity band, chip type) in `mantis_mapping`, taken from a v4 install's table, +which follows the software's chip catalogue, not the unit. A dispense takes one per call; its +explicit arguments win. No lookup by viscosity: the bands leave gaps (5.5 cP is in none). + +**M5. One command in flight; replies match on packet number and board.** The reader runs from +setup to stop. A frame with a code from 0x0200 is an event: it goes to every waiter registered for +its board and code (`_expect_event`, before the command that causes it is sent, then +`_wait_for_event`), and is logged either way. A reply nobody is waiting for is logged and dropped. +A size field no frame can have (`MAXIMUM_FRAME_SIZE`) drops one byte at a time until a frame +starts. A read that times out is resent with the same packet number, up to `attempts`. A command +that writes or moves is sent once (`attempts=1`), never resent. A failed read ends the reader: the +command in flight and every event waiter fail with it, and every later command, including one +already waiting for the lock, raises until setup runs again. `stop` fails the command in flight and +every event waiter. + +**M6. The wire speaks the device's units; reads return them.** (STAR P4.) Every read returns what +the device sent, decoded but not converted, until the feature that owns it knows the conversion. +A feature then converts in one place, on its configuration, and speaks PyLabRobot's units. No unit +suffix on names in default units. + +**M7. A range is checked before anything is sent.** (STAR P5.) A read accepts only the indices +seen on a device (`MOTORS`, the boards); `_check_index` raises +`ValueError` before a frame is built. + +**M8. A command that changes the device has one raw call and one public method.** (STAR P7, P8, +P9.) The raw call is `_unchecked_fw_*` and sends once; the public method checks its arguments, then +confirms what the device reports. `lock` checks the level against the declared levels, calls +`_unchecked_fw_do_plate_clamping` and reads the clamp state until it reports clamping done. + +**M9. Names say what kind of thing they are.** (STAR P10.) + +| what it touches | prefix | here | +|---|---|---| +| the device, doing work | `move_` | | +| the device, a process left running | `start_`, `stop_` | | +| the device's sensors | `measure_`, `sense_` | | +| the device's memory, reading | `request_` | `request_motor_limits`, `request_name` | +| the device's memory, writing | `set_` | | +| the resource model, reading | `get_` | | +| the resource model, writing | `update_` | | + +Commands, boards and fields are named for what they do; no vendor class, method or opcode names. + +**M10. What is acquired is released on every path.** A feature that turns on or holds anything +(pump, pressure controllers, clamp, a chip) turns it off or releases it in a `finally`, each step +in its own `try`, and the first fault is the one raised. `stop` fails what waits on the link, then +releases each built feature, then closes the link. A `lock` that does not report clamping +done releases the clamp before raising, and `stop` releases a clamp that does not read released. +Failure behaviour is designed before coverage. + +**M11. Firmware is confirmed, never gated.** A version set outside `CONFIRMED_FIRMWARE_VERSIONS` +warns once at setup; nothing refuses. A set goes in once it has been seen on a device. + +**M12. Defaults are public `default_*` attributes on the feature.** (STAR P18.) Declared on the +class with a type, in PyLabRobot units, round, slightly below the firmware's own. A caller tunes +one device by assigning on the instance, or every device by assigning on the class; a command +checks the value it takes before anything is sent. `plate_clamp.default_lock_level` is the first. + +**M13. Tests assert the wire, against real replies.** (STAR P15.) A test pins the bytes we send. A +reply in a test is one a device sent, held as a named constant in the test, never rebuilt or +invented. The test link answers by board, command and payload and echoes the packet number; only +the framing tests pin whole recorded frames. A reply that changes with what was sent before is a +recorded list the test plays in order. A test exists where behaviour could regress. + +**M14. Comments say what this code does and what the device did.** (STAR P17.) No capture names, +serials or dates; a constant is referenced by name, not repeated. + +**M15. The simulator answers from the declared configuration; nothing reaches a wire.** (CLARIOstar +simulator.) `MantisSimulationDriver` builds no link and `_send_command` raises. Setup takes the +configuration from the file's `device` block, and the reads answer from it; a board it leaves +`None` does not answer, and the device name is not simulated. Each feature has a simulation +subclass that overrides its `_unchecked_fw_*` calls and reads with state above the wire: +`MantisSimulationPlateClamp` reads 0 until a command sets clamping done or unclamping done; +`MantisSimulationRfid` reads every slot empty. + +**M16. Locking is by subsystem.** (STAR P11.) One lock serializes the link today. When features +add commands that the device runs together, each subsystem gets its own lock, taken in declaration +order. + +**M17. The master holds every link to the device.** (STAR P21.) The FTDI has two interfaces; the +master opens both in `setup` and closes them in `stop`, and a feature sends only through the +master's links. Interface B carries the command frames; interface A, the RFID reader's commands. + +**M18. The resource model mirrors the device; it never decides for it.** (STAR P12.) `MantisDevice` +builds the parts and holds the driver; a feature reaches the parts through the device, and moves +keep them in step. A driver with no resource tree still drives. + +## Open, and where this is not consistent yet + +1. **`CommandRefusedError` is untested.** No reply seen has a non-zero status. +2. **The v3 board set is an inference.** No v3 has run discovery, so whether a v3 lacks board 11, + or answers board 10 or 12, is unproven. +3. **Three link values are ours, not the vendor's.** The latency timer (2 ms), the read poll + (10 ms) and the reply timeout (5 s). A frame whose reserved word is not zero is logged, not + refused. +4. **The arm and Z column are not modelled yet.** They come with the arm feature, placed from a + pose read from the device; which motor drives which link is not measured. +5. **Whether motor limits differ between devices of one generation** is unknown; one v4 has been + read. +6. **The plate clamp and the RFID reader have run on one v4 only,** on macOS and on Windows. No v3 + has run this driver. +7. **The release payload is fixed, not declared.** Every release sends (0.75, 0, 5000); how it + relates to `Device.config`'s `PlateClampUnclampPWM` (1000) is not known. +8. **A lock that times out releases (command 31).** Command 32 may stop a lock instead, but no + capture has a command 32 frame, so the release, seen on the wire, is kept. +9. **`MantisDevice` writes its base file's `mesh` itself** rather than through + `resource_model._mesh`, which is private to that package. +10. **A clamp failure state (4, 7 or 8) raises at once, untested.** No device has shown one, so + no test plays one. +11. **A v3's clamp is pneumatic, switched by aux output 2 on the pressure board.** A v3 install's + `Device.config` reads `Pneumatic`, `PressureRegulator`, aux 2, with no PWM levels; #987 switches + aux output 2 off at shutdown. PR 1 builds no clamp on a v3; the v3 clamp comes next. +12. **Capturing both interfaces at once is unproven.** They share one serial number, and a PLR + capture tells links apart by it. +13. **The antenna switch's address on a v3 is unknown.** The PPI write at 120 is seen only on our + v4; board 0's PPI also drives the valves. A v3 capture of an RFID scan settles it before a v3 + reads. +14. **The RFID multiplexer delay is taken as ms.** Every unit seen holds 0, so the unit of + `RfidMultiplexerDelay` is unconfirmed. +15. **The liquid classes' units are unconfirmed.** The delays may be ms or the valve sequence's time + steps, the pressures psi and the vacuum inHg; the dispensing PR settles them. diff --git a/pylabrobot/formulatrix/mantis/device.py b/pylabrobot/formulatrix/mantis/device.py new file mode 100644 index 00000000000..7cf92f725a2 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/device.py @@ -0,0 +1,181 @@ +"""Formulatrix Mantis liquid dispenser as a PyLabRobot resource.""" + +import os +from typing import Optional + +from pylabrobot.io.ftdi import FTDI +from pylabrobot.resources import Coordinate, Deck, Resource +from pylabrobot.resources.resource_holder import ResourceHolder + +from .driver.configuration import RECORDING_MANTIS_V4 +from .driver.features.plate_clamp import PlateClamp +from .driver.features.rfid import Rfid +from .driver.master import MantisDriver +from .driver.simulator import MantisSimulationDriver +from .resource_model import MANIFEST, chip_rack + +# The main box's size, in mm, from the manufacturer's model: up to the top plate's surface. +SIZE_X, SIZE_Y, SIZE_Z = MANIFEST["size"] +# The plate holder on the deck: an SBS footprint pushed into the back-left of the pin nest, in mm. +PLATE_HOLDER_LOCATION = Coordinate(23.62, 15.02, 0.0) +PLATE_HOLDER_SIZE_X, PLATE_HOLDER_SIZE_Y = 127.76, 85.48 + + +class MantisDevice(Resource): + """A Formulatrix Mantis, v3 or v4: its main box, the parts on it and the driver that runs it. + + The main box carries the deck with its plate holder and the two chip racks. The generation is + read at setup (``driver.configuration.generation``). + """ + + def __init__( + self, + name: str = "Mantis", + *, + device_id: Optional[str] = None, + io: Optional[FTDI] = None, + rfid_io: Optional[FTDI] = None, + size_x: float = SIZE_X, + size_y: float = SIZE_Y, + size_z: float = SIZE_Z, + declared_configuration_json: Optional[str] = None, + oem_config_directory: Optional[str] = None, + simulation: bool = False, + ): + """ + Args: + name: the resource name. + device_id: the FTDI serial number. None uses the first Mantis found. + io: an already-built ``FTDI`` for interface B, used instead of building one. + rfid_io: an already-built ``FTDI`` for interface A, the RFID reader. + size_x: width, in mm. + size_y: depth, in mm. + size_z: height, in mm. + declared_configuration_json: a file ``save_configuration`` wrote; setup refuses a device that + disagrees on what is fitted. Simulated, the configuration; None uses + ``RECORDING_MANTIS_V4``. + oem_config_directory: the unit's vendor ``Data/Device/Configs`` folder; where it and the + declared file both hold a unit value, they must agree. + simulation: build a simulated Mantis, with no link. + + Raises: + ValueError: if ``simulation`` is given with ``device_id``, ``io`` or ``rfid_io``. + """ + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category="device", + model="Mantis", + ) + self.driver: MantisDriver + if simulation: + if device_id is not None or io is not None or rfid_io is not None: + raise ValueError("a simulated Mantis has no link: no device_id, io or rfid_io") + self.driver = MantisSimulationDriver( + declared_configuration_json=declared_configuration_json or RECORDING_MANTIS_V4, + name=name, + oem_config_directory=oem_config_directory, + ) + else: + self.driver = MantisDriver( + device_id=device_id, + io=io, + rfid_io=rfid_io, + name=name, + declared_configuration_json=declared_configuration_json, + oem_config_directory=oem_config_directory, + ) + self.mesh = { + "path": os.path.join(os.path.dirname(__file__), "resource_model", MANIFEST["base"]), + "units": "mm", + "up": "Z", + } + measured_at = { + part: Coordinate(*module["measured_at"]) for part, module in MANIFEST["modules"].items() + } + + self.deck = Deck(name=f"{name}_deck", size_x=SIZE_X, size_y=SIZE_Y, size_z=0.0) + self.assign_child_resource(self.deck, location=Coordinate(0.0, 0.0, SIZE_Z)) + self.plate_holder = ResourceHolder( + name=f"{name}_plate_holder", + size_x=PLATE_HOLDER_SIZE_X, + size_y=PLATE_HOLDER_SIZE_Y, + size_z=0.0, + model="mantis_plate_holder", + ) + # The viewer draws the holder in its colour: a step darker than the deck, so the nest shows. + self.plate_holder.appearance = {"color": 0x8C9198} # type: ignore[attr-defined] + self.deck.assign_child_resource(self.plate_holder, location=PLATE_HOLDER_LOCATION) + + # Both chip racks, always: whether a side carries a carousel instead is not read from the + # device; cb declares it (chip_layout), and carousels are not supported yet. + self.left_chip_rack = chip_rack(name=f"{name}_left_chip_rack", side="left") + self.assign_child_resource(self.left_chip_rack, location=measured_at["left_chip_rack"]) + self.right_chip_rack = chip_rack(name=f"{name}_right_chip_rack", side="right") + self.assign_child_resource(self.right_chip_rack, location=measured_at["right_chip_rack"]) + + @property + def plate_clamp(self) -> Optional[PlateClamp]: + """The plate clamp, once setup found the LC3 board; None before or without it.""" + return self.driver.plate_clamp + + @property + def rfid(self) -> Optional[Rfid]: + """The RFID reader, once setup found it on interface A; None before or without it.""" + return self.driver.rfid + + async def setup(self) -> None: + """Connect, read the device's configuration and check it. Nothing moves.""" + await self.driver.setup() + + async def stop(self) -> None: + """Disconnect.""" + await self.driver.stop() + + +def Mantis( + name: str = "Mantis", + *, + device_id: Optional[str] = None, + io: Optional[FTDI] = None, + rfid_io: Optional[FTDI] = None, + size_x: float = SIZE_X, + size_y: float = SIZE_Y, + size_z: float = SIZE_Z, + declared_configuration_json: Optional[str] = None, + oem_config_directory: Optional[str] = None, + simulation: bool = False, +) -> MantisDevice: + """A Formulatrix Mantis, v3 or v4. + + Args: + name: the resource name. + device_id: the FTDI serial number. None uses the first Mantis found. + io: an already-built ``FTDI`` for interface B, used instead of building one. + rfid_io: an already-built ``FTDI`` for interface A, the RFID reader. + size_x: width, in mm. + size_y: depth, in mm. + size_z: height, in mm. + declared_configuration_json: a file ``save_configuration`` wrote; setup refuses a device that + disagrees on what is fitted. Simulated, the configuration; None uses ``RECORDING_MANTIS_V4``. + oem_config_directory: the unit's vendor ``Data/Device/Configs`` folder; where it and the + declared file both hold a unit value, they must agree. + simulation: build a simulated Mantis, with no link. + + Raises: + ValueError: if ``simulation`` is given with ``device_id``, ``io`` or ``rfid_io``. + """ + return MantisDevice( + name=name, + device_id=device_id, + io=io, + rfid_io=rfid_io, + size_x=size_x, + size_y=size_y, + size_z=size_z, + declared_configuration_json=declared_configuration_json, + oem_config_directory=oem_config_directory, + simulation=simulation, + ) diff --git a/pylabrobot/formulatrix/mantis/device_tests.py b/pylabrobot/formulatrix/mantis/device_tests.py new file mode 100644 index 00000000000..467e0ce3d5d --- /dev/null +++ b/pylabrobot/formulatrix/mantis/device_tests.py @@ -0,0 +1,31 @@ +"""Mantis device tests: the parts tree.""" + +import os +import unittest +from unittest import mock + +from pylabrobot.formulatrix.mantis import Mantis, MantisDevice +from pylabrobot.io.ftdi import FTDI + +RESOURCE_MODEL = os.path.join(os.path.dirname(__file__), "resource_model") + + +class MantisTests(unittest.TestCase): + def make_device(self) -> MantisDevice: + return Mantis(io=mock.create_autospec(FTDI, instance=True)) + + def test_every_part_has_its_model_file(self): + device = self.make_device() + drawn = [r for r in [device, *device.get_all_children()] if hasattr(r, "mesh")] + self.assertIn(device.left_chip_rack, drawn) + for resource in drawn: + with self.subTest(resource=resource.name): + self.assertTrue(os.path.isfile(os.path.join(RESOURCE_MODEL, f"{resource.model}.glb"))) + + def test_simulation_refuses_a_link(self): + with self.assertRaises(ValueError): + Mantis(simulation=True, io=mock.create_autospec(FTDI, instance=True)) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/formulatrix/mantis/driver/__init__.py b/pylabrobot/formulatrix/mantis/driver/__init__.py new file mode 100644 index 00000000000..41241350e27 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/__init__.py @@ -0,0 +1,29 @@ +"""Mantis low-level driver: the link on the FTDI's interface B and the device reads.""" + +from .configuration import DeviceConfiguration +from .confirmed_firmware_versions import CONFIRMED_FIRMWARE_VERSIONS +from .errors import ( + CLAMP_STATE_DESCRIPTIONS, + MOTOR_ERROR_CODE_DESCRIPTIONS, + MOTOR_STATUS_ERROR_BITS, + SENSOR_ALARM_CODE_DESCRIPTIONS, + ChecksumError, + CommandRefusedError, + FrameError, + InvalidQueueItemError, + MantisConfigurationError, + MantisConnectionLostError, + MantisCoordinateError, + MantisError, + MantisKinematicsError, + MantisMotionError, + MantisNotConnectedError, + MantisPressureError, + MantisPrimeError, + MantisTimeoutError, + PlateClampError, +) +from .features.plate_clamp import PlateClamp +from .features.rfid import Rfid +from .master import MantisDriver +from .simulator import MantisSimulationDriver, MantisSimulationPlateClamp, MantisSimulationRfid diff --git a/pylabrobot/formulatrix/mantis/driver/configuration.py b/pylabrobot/formulatrix/mantis/driver/configuration.py new file mode 100644 index 00000000000..00a36ccd576 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/configuration.py @@ -0,0 +1,72 @@ +"""Mantis configuration: what the device reported about itself at setup.""" + +import dataclasses +import json +import os +from typing import Any, Dict, Literal, Optional, Tuple + +from .framing import ABSOLUTE_POSITION_SENSOR_BOARD + + +@dataclasses.dataclass +class DeviceConfiguration: + """What the device reported about itself. + + Attributes: + generation: "v4" if board 11 answered the version read, else "v3"; None until read. + firmware_versions: each board's firmware version, by board; None for a board that did not + answer. + motor_limits: each motor's (lower, upper) limit, by motor, in the device's units. + """ + + generation: Optional[Literal["v3", "v4"]] = None + firmware_versions: Dict[int, Optional[str]] = dataclasses.field(default_factory=dict) + motor_limits: Dict[int, Tuple[float, float]] = dataclasses.field(default_factory=dict) + + +# One real unit of each generation: its device block and its unit blocks. +RECORDING_MANTIS_V3 = os.path.join(os.path.dirname(__file__), "recordings", "mantis_v3.json") +RECORDING_MANTIS_V4 = os.path.join(os.path.dirname(__file__), "recordings", "mantis_v4.json") +# The blocks a unit's own values fill: from its vendor folder, its declared file or a recording. +UNIT_BLOCKS = ("plate_clamp", "rfid") + +# What is fitted: compared between a declared configuration and the device at setup. Firmware is +# not, so a configuration taken off one device still describes another of its build. +FITTED_FIELDS = ("generation", "motor_limits") + + +def compute_generation(*, firmware_versions: Dict[int, Optional[str]]) -> Literal["v3", "v4"]: + """The generation from which boards answered the version read: board 11 answers on a v4. + + Args: + firmware_versions: each board's firmware version, None for a board that did not answer. + """ + # A v3 is not known to have the absolute position sensor board. + if firmware_versions.get(ABSOLUTE_POSITION_SENSOR_BOARD) is not None: + return "v4" + return "v3" + + +def configuration_to_json(configuration: DeviceConfiguration) -> Dict[str, Any]: + """The fields as JSON values, as saved.""" + return dict(json.loads(json.dumps(dataclasses.asdict(configuration)))) + + +def configuration_from_json(fields: Dict[str, Any]) -> DeviceConfiguration: + """A configuration from saved fields, with JSON's lists and string keys turned back.""" + return DeviceConfiguration( + generation=fields.get("generation"), + firmware_versions={ + int(board): version for board, version in fields.get("firmware_versions", {}).items() + }, + motor_limits={ + int(motor): (float(lower), float(upper)) + for motor, (lower, upper) in fields.get("motor_limits", {}).items() + }, + ) + + +def read_configuration(path: str) -> Dict[str, Any]: + """Read a configuration written by ``MantisDriver.save_configuration``.""" + with open(path, encoding="utf-8") as f: + return dict(json.load(f)) diff --git a/pylabrobot/formulatrix/mantis/driver/configuration_tests.py b/pylabrobot/formulatrix/mantis/driver/configuration_tests.py new file mode 100644 index 00000000000..1d77f1e6d59 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/configuration_tests.py @@ -0,0 +1,23 @@ +"""Configuration tests.""" + +import unittest + +from pylabrobot.formulatrix.mantis.device import RECORDING_MANTIS_V4 +from pylabrobot.formulatrix.mantis.driver.configuration import ( + compute_generation, + configuration_from_json, + read_configuration, +) + + +class ComputeGenerationTests(unittest.TestCase): + def test_board_0_version_decides_nothing(self): + recorded = configuration_from_json( + fields=read_configuration(path=RECORDING_MANTIS_V4)["device"] + ).firmware_versions + firmware_versions = {**recorded, 0: "3.0.0"} + self.assertEqual(compute_generation(firmware_versions=firmware_versions), "v4") + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/formulatrix/mantis/driver/confirmed_firmware_versions.py b/pylabrobot/formulatrix/mantis/driver/confirmed_firmware_versions.py new file mode 100644 index 00000000000..c4238ce694c --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/confirmed_firmware_versions.py @@ -0,0 +1,7 @@ +"""Mantis firmware versions seen on a device, per generation, by board.""" + +from typing import Dict, Optional, Tuple + +CONFIRMED_FIRMWARE_VERSIONS: Dict[str, Tuple[Dict[int, Optional[str]], ...]] = { + "v4": ({0: "4.20.6.2", 10: "1.0.8", 11: "0.1.7 DBG", 12: "0.1.8 DBG"},), +} diff --git a/pylabrobot/formulatrix/mantis/driver/errors.py b/pylabrobot/formulatrix/mantis/driver/errors.py new file mode 100644 index 00000000000..43e8a14002a --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/errors.py @@ -0,0 +1,189 @@ +"""Errors of the Mantis, one class per kind, and what the device's codes mean. + +Catch ``MantisError`` for any of them. Each also subclasses the built-in it corresponds to, so an +``except RuntimeError``, ``ValueError`` or ``TimeoutError`` still matches. +""" + +from typing import Dict, Optional + + +class MantisError(Exception): + """Any Mantis error.""" + + +class FrameError(MantisError, RuntimeError): + """A frame from the device is malformed.""" + + +class ChecksumError(FrameError): + """A frame's check word does not match its bytes.""" + + +class CommandRefusedError(MantisError, RuntimeError): + """The device answered a command with a status other than 0.""" + + def __init__(self, message: str, *, board: int, command: int, status: int): + """ + Args: + message: what went wrong, with the device's own message when it sent one. + board: the board the command went to. + command: the command number. + status: the reply's status, signed; negative is a device error. + """ + super().__init__(message) + self.board = board + self.command = command + self.status = status + + +class MantisNotConnectedError(MantisError, RuntimeError): + """A command needs the link, but setup has not run or stop has.""" + + +class MantisConnectionLostError(MantisError, RuntimeError): + """Reading a link failed; the failure is the cause. Setup runs again to reopen it.""" + + +class MantisTimeoutError(MantisError, TimeoutError): + """The device did not answer, or did not reach a state, in time.""" + + +class MantisConfigurationError(MantisError, ValueError): + """A declared configuration disagrees with the device, lacks a value, or names an unknown slot.""" + + +class PlateClampError(MantisError, RuntimeError): + """The plate clamp read a failure state (``CLAMP_STATE_DESCRIPTIONS``).""" + + def __init__(self, message: str, *, state: int): + """ + Args: + message: what went wrong. + state: the clamp state read, 4, 7 or 8. + """ + super().__init__(message) + self.state = state + + +class MantisMotionError(MantisError, RuntimeError): + """A motor reported a fault or did not reach a commanded state.""" + + def __init__( + self, + message: str, + *, + motor: Optional[int] = None, + code: Optional[int] = None, + status: Optional[int] = None, + ): + """ + Args: + message: what went wrong. + motor: the motor, when known. + code: the motor error code (``MOTOR_ERROR_CODE_DESCRIPTIONS``), when the device sent one. + status: the motor status word, when the fault was read from it (``MOTOR_STATUS_ERROR_BITS``). + """ + super().__init__(message) + self.motor = motor + self.code = code + self.status = status + + +class MantisPressureError(MantisError, RuntimeError): + """A pressure controller did not reach its target, or a sensor raised an alarm.""" + + def __init__( + self, + message: str, + *, + sensor: Optional[int] = None, + code: Optional[int] = None, + value: Optional[float] = None, + ): + """ + Args: + message: what went wrong. + sensor: the sensor, when known. + code: the alarm code (``SENSOR_ALARM_CODE_DESCRIPTIONS``), for a sensor alarm. + value: the sensor's value, when the device sent one. + """ + super().__init__(message) + self.sensor = sensor + self.code = code + self.value = value + + +class InvalidQueueItemError(MantisError, RuntimeError): + """A sequencer queue command answered a non-zero error field.""" + + def __init__(self, message: str, *, command: int, error: int): + """ + Args: + message: what went wrong. + command: the queue command number. + error: the error field it answered. + """ + super().__init__(message) + self.command = command + self.error = error + + +class MantisKinematicsError(MantisError, ValueError): + """A target position is out of the arm's reach or at a singularity.""" + + +class MantisCoordinateError(MantisError, ValueError): + """A container cannot be mapped to a device position.""" + + +class MantisPrimeError(MantisError, RuntimeError): + """A prime ran its full volume, but the liquid sensor never confirmed flow.""" + + +# The motor error codes a motor command answers, or a motor-error event carries. +MOTOR_ERROR_CODE_DESCRIPTIONS: Dict[int, str] = { + 1: "the motor id is not registered", + 2: "below the motor's lower limit", + 3: "above the motor's upper limit", + 4: "the position cannot be reached", + 5: "the velocity is too low or too high", + 6: "the acceleration is too low or too high", + 7: "the motor aborted", + 8: "a skip was detected while the motor was idle", + 9: "a skip was detected while the motor was moving", + 10: "the encoder failed", + 11: "the motor is disabled", + 12: "the motor is homing", + 13: "the motor current is unstable", +} + +# The motor status word's fault bits, in the order they are reported when several are set. +MOTOR_STATUS_ERROR_BITS: Dict[int, str] = { + 32: "over current", + 64: "aborted", + 128: "following error while idle", + 256: "following error while moving", + 512: "encoder error", + 1024: "unstable current", +} + +# The alarm codes of a sensor-alarm event. +SENSOR_ALARM_CODE_DESCRIPTIONS: Dict[int, str] = { + 0: "below the limit, set", + 1: "below the limit, cleared", + 2: "above the limit, set", + 3: "above the limit, cleared", +} + +# The plate clamp's states, as it reads them. +CLAMP_STATE_DESCRIPTIONS: Dict[int, str] = { + 0: "idle", + 1: "stopped", + 2: "clamping", + 3: "clamping done", + 4: "clamping error timeout", + 5: "unclamping", + 6: "unclamping done", + 7: "unclamping error timeout", + 8: "motor fault", +} diff --git a/pylabrobot/formulatrix/mantis/driver/features/__init__.py b/pylabrobot/formulatrix/mantis/driver/features/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/formulatrix/mantis/driver/features/plate_clamp.py b/pylabrobot/formulatrix/mantis/driver/features/plate_clamp.py new file mode 100644 index 00000000000..bc0f85a168e --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/features/plate_clamp.py @@ -0,0 +1,162 @@ +"""The plate clamp: a solenoid on the LC3 board (board 12) that holds the plate on the deck.""" + +import asyncio +import logging +import struct +from typing import TYPE_CHECKING, Dict, Optional, Tuple + +from ..errors import ( + CLAMP_STATE_DESCRIPTIONS, + FrameError, + MantisConfigurationError, + MantisTimeoutError, + PlateClampError, +) +from ..framing import LC3_BOARD + +if TYPE_CHECKING: + from ..master import MantisDriver + +logger = logging.getLogger(__name__) + +# The clamp's states are in CLAMP_STATE_DESCRIPTIONS. +CLAMPING_DONE = 3 +UNCLAMPING_DONE = 6 +# The states the clamp reports a failure in; a wait ends on them at once. +CLAMPING_FAILED = (4, 8) +UNCLAMPING_FAILED = (7, 8) +# The waits for a state: the poll interval (s), the tries and each command's timeout (s). +CLAMP_STATE_POLL_INTERVAL = 0.1 +CLAMP_WAIT_TRIES = 3 +CLAMPING_TIMEOUT = 3.0 +UNCLAMPING_TIMEOUT = 5.0 + + +class PlateClamp: + """The plate clamp. Locking waits until the device reports clamping done.""" + + # Lock level when the caller names none: 1, the weakest. + default_lock_level: int = 1 + + def __init__(self, *, driver: "MantisDriver", levels: Optional[Dict[int, Tuple[float, int]]]): + """ + Args: + driver: the driver it sends through. + levels: the unit's lock levels, level -> (PWM duty, timeout_ms), as declared; None when + none are declared, and then ``lock`` refuses. + """ + self._driver = driver + self.levels = levels + + async def _unchecked_fw_do_plate_unclamping(self) -> None: + """DoPlateUnclamping (board 12, command 31), sent once; its payload is fixed.""" + await self._driver._send_command( + board=LC3_BOARD, + command=31, + payload=struct.pack(" None: + """DoPlateClamping (board 12, command 30), sent once. + + Args: + pwm: the solenoid's PWM duty. + timeout_ms: the device's clamping timeout, in ms. + """ + await self._driver._send_command( + board=LC3_BOARD, + command=30, + payload=struct.pack(" int: + """The clamp's state (GetMotorClampState); ``CLAMP_STATE_DESCRIPTIONS`` says what each means. + + Args: + attempts: how often the read is sent before giving up. + """ + reply = await self._driver._send_command(board=LC3_BOARD, command=33, attempts=attempts) + if len(reply) < 2: + raise FrameError(f"{self._driver.name}: a clamp state reply is 2 bytes, got {reply.hex()}") + (state,) = struct.unpack_from(" None: + """Release the clamp and wait until it reads unclamping done. + + Raises: + PlateClampError: at once, if it reads a state in ``UNCLAMPING_FAILED``. + MantisTimeoutError: if it does not read unclamping done within ``CLAMP_WAIT_TRIES`` times + ``UNCLAMPING_TIMEOUT``, polled every ``CLAMP_STATE_POLL_INTERVAL``. + """ + await self._unchecked_fw_do_plate_unclamping() + state = await self.request_state() + for _ in range(CLAMP_WAIT_TRIES * round(UNCLAMPING_TIMEOUT / CLAMP_STATE_POLL_INTERVAL)): + if state == UNCLAMPING_DONE: + return + if state in UNCLAMPING_FAILED: + raise PlateClampError( + f"{self._driver.name}: the clamp did not release: " + f"{CLAMP_STATE_DESCRIPTIONS[state]} (state {state})", + state=state, + ) + await asyncio.sleep(CLAMP_STATE_POLL_INTERVAL) + state = await self.request_state() + if state != UNCLAMPING_DONE: + raise MantisTimeoutError( + f"{self._driver.name}: the clamp did not release; last state {state}" + ) + + async def lock(self, *, level: Optional[int] = None) -> None: + """Release the clamp, lock it at ``level`` and wait until it reads clamping done. + + If it does not, the clamp is released again before raising. + + Args: + level: one of ``levels``, 1 the weakest. ``default_lock_level`` when None. + + Raises: + MantisConfigurationError: if no levels are declared; nothing is sent. + ValueError: if ``level`` is not in ``levels``; nothing is sent. + PlateClampError: at once, after the release, if it reads a state in ``CLAMPING_FAILED``. + MantisTimeoutError: if it does not read clamping done within ``CLAMP_WAIT_TRIES`` times + ``CLAMPING_TIMEOUT``, polled every ``CLAMP_STATE_POLL_INTERVAL``. + """ + if self.levels is None: + raise MantisConfigurationError( + f"{self._driver.name}: no clamp levels for this unit or its generation; give its " + "vendor folder (oem_config_directory)" + ) + level = self.default_lock_level if level is None else level + self._driver._check_index(name="level", value=level, allowed=tuple(self.levels)) + await self.unlock() + locked = False + try: + pwm, timeout_ms = self.levels[level] + await self._unchecked_fw_do_plate_clamping(pwm=pwm, timeout_ms=timeout_ms) + state = await self.request_state() + for _ in range(CLAMP_WAIT_TRIES * round(CLAMPING_TIMEOUT / CLAMP_STATE_POLL_INTERVAL)): + if state == CLAMPING_DONE: + break + if state in CLAMPING_FAILED: + raise PlateClampError( + f"{self._driver.name}: the clamp did not lock at level {level}: " + f"{CLAMP_STATE_DESCRIPTIONS[state]} (state {state})", + state=state, + ) + await asyncio.sleep(CLAMP_STATE_POLL_INTERVAL) + state = await self.request_state() + if state != CLAMPING_DONE: + raise MantisTimeoutError( + f"{self._driver.name}: the clamp did not report clamping done at level {level}; " + f"last state {state}" + ) + locked = True + finally: + if not locked: + try: + await self.unlock() + except Exception: + logger.warning("[%s] could not release the clamp", self._driver.name, exc_info=True) diff --git a/pylabrobot/formulatrix/mantis/driver/features/plate_clamp_tests.py b/pylabrobot/formulatrix/mantis/driver/features/plate_clamp_tests.py new file mode 100644 index 00000000000..580441d7c67 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/features/plate_clamp_tests.py @@ -0,0 +1,145 @@ +"""Plate clamp tests: the bytes sent, against clamp states recorded from a Mantis v4.""" + +import unittest +from typing import List, Tuple +from unittest import mock + +from pylabrobot.formulatrix.mantis.device import RECORDING_MANTIS_V4 +from pylabrobot.formulatrix.mantis.driver.errors import ( + MantisConfigurationError, + MantisTimeoutError, +) +from pylabrobot.formulatrix.mantis.driver.features import plate_clamp +from pylabrobot.formulatrix.mantis.driver.framing import parse_frame +from pylabrobot.formulatrix.mantis.driver.master_tests import DriverTestCase, FakeLink, make_answers + +RELEASE = (12, 31, bytes.fromhex("000000000000e83f000000000000000088130000")) +LOCK_AT_LEVEL_2 = (12, 30, bytes.fromhex("0000000000000000e17a14ae47e1ca3fe8030000")) +STATE_READ = (12, 33, b"") +# The states the device read after each command, as recorded. +STATES_AFTER_RELEASE_WHILE_RELEASED = ["0600", "0600"] +STATES_AFTER_RELEASE_WHILE_LOCKED = ["0500", "0600"] +STATES_AFTER_LOCK_AT_LEVEL_2 = ["0200"] * 7 + ["0300"] +STATES_AFTER_LOCK_AT_LEVEL_1 = ["0200"] * 14 + ["0300"] +# After power-on the clamp read 0, then 6 after the release stop sent. +STATES_AT_POWER_ON = ["0000"] +STATES_AFTER_RELEASE_AT_POWER_ON = ["0600"] + + +def answer_states_in_turn(*, link: FakeLink, turns: List[Tuple[int, List[str]]]) -> None: + """When each board-12 command in ``turns`` is written, answer state reads with its states.""" + write = link.write + + async def write_then_answer_states(data: bytes) -> int: + _, board, command, _ = parse_frame(frame=data) + if turns and (board, command) == (12, turns[0][0]): + _, states = turns.pop(0) + link.answers[STATE_READ] = [bytes.fromhex(state) for state in states] + return await write(data) + + link.io.write.side_effect = write_then_answer_states + + +class PlateClampTests(DriverTestCase): + async def asyncSetUp(self): + self.link = FakeLink(answers=make_answers()) + self.driver = await self.set_up_driver( + link=self.link, declared_configuration_json=RECORDING_MANTIS_V4 + ) + assert self.driver.plate_clamp is not None + self.clamp = self.driver.plate_clamp + # every clamp command is answered; a short timeout would resend a read the runner was slow on + self.driver.default_read_timeout = 5.0 + self.sent_before = len(self.link.written) + + def sent(self) -> List[Tuple[int, int, bytes]]: + return [parse_frame(frame=frame)[1:] for frame in self.link.written[self.sent_before :]] + + async def test_unlock(self): + answer_states_in_turn(link=self.link, turns=[(31, STATES_AFTER_RELEASE_WHILE_LOCKED)]) + await self.clamp.unlock() + self.assertEqual(self.sent(), [RELEASE, STATE_READ, STATE_READ]) + + async def test_clamping_done_never_read_releases_and_raises(self): + answer_states_in_turn( + link=self.link, + turns=[ + (31, STATES_AFTER_RELEASE_WHILE_RELEASED), + (30, STATES_AFTER_LOCK_AT_LEVEL_1), + (31, STATES_AFTER_RELEASE_WHILE_LOCKED), + ], + ) + # 9 polls end the wait before the recorded clamping done (the 15th read) + with mock.patch.object(plate_clamp, "CLAMPING_TIMEOUT", 0.3): + with self.assertRaisesRegex(MantisTimeoutError, "level 1; last state 2"): + await self.clamp.lock(level=1) + self.assertEqual(self.sent()[-3:], [RELEASE, STATE_READ, STATE_READ]) + + async def test_lock_with_no_level_uses_default_lock_level(self): + self.assertEqual(self.clamp.default_lock_level, 1) + answer_states_in_turn( + link=self.link, + turns=[(31, STATES_AFTER_RELEASE_WHILE_RELEASED), (30, STATES_AFTER_LOCK_AT_LEVEL_2)], + ) + self.clamp.default_lock_level = 2 + await self.clamp.lock() + self.assertEqual(self.sent(), [RELEASE, STATE_READ, LOCK_AT_LEVEL_2] + [STATE_READ] * 8) + + async def test_unknown_default_lock_level_sends_nothing(self): + self.clamp.default_lock_level = 4 + with self.assertRaises(ValueError): + await self.clamp.lock() + self.assertEqual(self.sent(), []) + + async def test_unknown_level_sends_nothing(self): + with self.assertRaises(ValueError): + await self.clamp.lock(level=4) + self.assertEqual(self.sent(), []) + + async def test_stop_releases_a_locked_clamp(self): + answer_states_in_turn( + link=self.link, + turns=[ + (31, STATES_AFTER_RELEASE_WHILE_RELEASED), + (30, STATES_AFTER_LOCK_AT_LEVEL_2), + (31, STATES_AFTER_RELEASE_WHILE_LOCKED), + ], + ) + await self.clamp.lock(level=2) + await self.driver.stop() + self.assertEqual( + self.sent(), + [RELEASE, STATE_READ, LOCK_AT_LEVEL_2] + + [STATE_READ] * 8 + + [STATE_READ, RELEASE, STATE_READ, STATE_READ], + ) + + async def test_stop_releases_a_clamp_not_actuated_since_power_on(self): + self.link.answers[STATE_READ] = [bytes.fromhex(state) for state in STATES_AT_POWER_ON] + answer_states_in_turn(link=self.link, turns=[(31, STATES_AFTER_RELEASE_AT_POWER_ON)]) + await self.driver.stop() + self.assertEqual(self.sent(), [STATE_READ, RELEASE, STATE_READ]) + + +class PlateClampDiscoveryTests(DriverTestCase): + async def test_lock_with_no_levels_sends_nothing(self): + link = FakeLink(answers=make_answers()) + driver = await self.set_up_driver(link=link) + assert driver.plate_clamp is not None + driver.plate_clamp.levels = None # a unit whose folder, declared file and recording have none + sent_before = len(link.written) + with self.assertRaisesRegex(MantisConfigurationError, "no clamp levels"): + await driver.plate_clamp.lock(level=1) + self.assertEqual(len(link.written), sent_before) + + async def test_built_only_when_the_lc3_board_answers(self): + for absent_boards, built in (((), True), ((12,), False)): + with self.subTest(absent_boards=absent_boards): + driver = await self.set_up_driver( + link=FakeLink(answers=make_answers(absent_boards=absent_boards)) + ) + self.assertEqual(driver.plate_clamp is not None, built) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/formulatrix/mantis/driver/features/rfid.py b/pylabrobot/formulatrix/mantis/driver/features/rfid.py new file mode 100644 index 00000000000..252e960fcaf --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/features/rfid.py @@ -0,0 +1,277 @@ +"""The RFID reader: which chip sits in each input slot. + +A PPI write (board 0, command 41) at address 120 selects the slot's antenna; a TRF7970A reader on +the FTDI's interface A then reads the tag in that antenna's field, 24 blocks of 4 bytes. A slot +holds a chip when a whole tag is read. +""" + +import asyncio +import logging +import re +import struct +from datetime import datetime +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from ..errors import MantisConfigurationError, MantisNotConnectedError, MantisTimeoutError +from ..framing import MOTION_BOARD + +if TYPE_CHECKING: + from pylabrobot.io.ftdi import FTDI + + from ..master import MantisDriver + +logger = logging.getLogger(__name__) + +# The reader's link: interface A of the device's FTDI bridge, 115200 baud, 8N1. +RFID_FTDI_INTERFACE = 1 +RFID_BAUD_RATE = 115_200 +# The reader's commands, ASCII hex as sent: ping, external antenna, ISO15693 at full power, and a +# read of the tag's 24 blocks. +RFID_PING_DEVICE_COMMAND = b"0108000304FF0000" +RFID_SET_ANTENNA_MODE_COMMAND = b"01080003042B0000" +RFID_SET_UP_ISO15693_PROTOCOL_COMMAND = b"010C00030410002101000000" +RFID_READ_TAG_COMMAND = b"010C00030418002300170000" +RFID_TAG_BLOCK_COUNT = 24 +RFID_REPLY_TIMEOUT = 1.0 # s +# The PPI address of the antenna multiplexer on board 0; the valve bank sits elsewhere. +RFID_MULTIPLEXER_BASE_INDEX = 120 +# The chip type each tag code names; 0x7FFF marks a tag whose chip type is invalid. +RFID_CHIP_TYPE_CODES: Dict[int, str] = { + 0x00: "high_volume", + 0x01: "low_volume", + 0x02: "high_volume_pfe", + 0x03: "low_volume_pfe", + 0x04: "high_volume_3pfe", + 0x05: "low_volume_3pfe", + 0x06: "hv_continuous_flow", + 0x07: "high_volume_hi_fi", + 0x08: "low_volume_hi_fi", + 0x09: "high_viscosity_continuous_flow", + 0x0A: "xl_volume_pfe", +} +RFID_CHIP_TYPE_INVALID = 0x7FFF +# Where the fields sit in a tag: 1 flag byte, then the blocks. +RFID_TAG_SERIAL_OFFSET = 5 # blocks 1-4 +RFID_TAG_SERIAL_LENGTH = 16 +RFID_TAG_CHIP_TYPE_OFFSET = 21 # block 5, a little-endian u32 +RFID_DATE_OF_MANUFACTURE_OFFSET = 25 # blocks 6-7, yyyyMMdd +RFID_DATE_OF_MANUFACTURE_LENGTH = 8 + + +def _transform_serial(*, raw: str) -> str: + """Reverse each 4-character block, drop NULs and trim: ``52SL3210`` reads ``LS250123``.""" + text = "".join(raw[i : i + 4][::-1] for i in range(0, len(raw), 4)) + return text.replace("\x00", "").strip() + + +def compute_tag_serial(*, tag: bytes) -> Optional[str]: + """The chip's serial, from a whole tag; None when ``tag`` is not one. + + Args: + tag: the tag, hex-decoded. + """ + if len(tag) != 1 + 4 * RFID_TAG_BLOCK_COUNT: + return None + end = RFID_TAG_SERIAL_OFFSET + RFID_TAG_SERIAL_LENGTH + return _transform_serial(raw=tag[RFID_TAG_SERIAL_OFFSET:end].decode("ascii", "replace")) + + +def compute_chip_type_code(*, tag: bytes) -> Optional[int]: + """The tag's chip type code (``RFID_CHIP_TYPE_CODES``); None when ``tag`` is too short. + + Args: + tag: the tag, hex-decoded. + """ + end = RFID_TAG_CHIP_TYPE_OFFSET + 4 + if len(tag) < end: + return None + return int.from_bytes(tag[RFID_TAG_CHIP_TYPE_OFFSET:end], "little") + + +def compute_tag_valid(*, tag: bytes) -> bool: + """Whether a tag is valid. + + Valid means a known chip type code, a serial that is not an integer, and a yyyyMMdd date. + + Args: + tag: the tag, hex-decoded. + """ + serial = compute_tag_serial(tag=tag) + if serial is None: + return False + code = compute_chip_type_code(tag=tag) + if code not in RFID_CHIP_TYPE_CODES and code != RFID_CHIP_TYPE_INVALID: + return False + if re.fullmatch(r"\s*[+-]?[0-9]+\s*", serial) and -(2**63) <= int(serial) < 2**63: + return False + end = RFID_DATE_OF_MANUFACTURE_OFFSET + RFID_DATE_OF_MANUFACTURE_LENGTH + date = _transform_serial(raw=tag[RFID_DATE_OF_MANUFACTURE_OFFSET:end].decode("ascii", "replace")) + if not re.fullmatch(r"[0-9]{8}", date): + return False + try: + datetime.strptime(date, "%Y%m%d") + except ValueError: + return False + return True + + +async def _send_command_to_ftdi(*, io: "FTDI", command: bytes) -> None: + """Clear what the reader sent before, then write one command.""" + await io.usb_purge_rx_buffer() + await io.write(command) + + +async def _read_reply(*, io: "FTDI", stop_key: bytes) -> bytes: + """Read the reader's reply until ``stop_key`` arrives. + + Raises: + MantisTimeoutError: if ``stop_key`` has not arrived within ``RFID_REPLY_TIMEOUT``. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + RFID_REPLY_TIMEOUT + buffer = b"" + while stop_key not in buffer: + if loop.time() > deadline: + raise MantisTimeoutError(f"the RFID reader did not answer in time; read {buffer!r}") + chunk = await io.read(num_bytes=512) + if chunk: + buffer += chunk + else: + await asyncio.sleep(0.01) # PyLabRobot design choice: poll every 10 ms + return buffer + + +async def _initialize_rfid_module(*, io: "FTDI") -> None: + """Ping the reader, then set its external antenna and ISO15693. + + Raises: + MantisNotConnectedError: if the ping's reply does not name the TRF7970A. + MantisTimeoutError: if the ping is not answered. + """ + await _send_command_to_ftdi(io=io, command=RFID_PING_DEVICE_COMMAND) + if b"TRF7970A EVM" not in await _read_reply(io=io, stop_key=b"\r\n"): + raise MantisNotConnectedError("no TRF7970A RFID reader on interface A") + for command in (RFID_SET_ANTENNA_MODE_COMMAND, RFID_SET_UP_ISO15693_PROTOCOL_COMMAND): + await _send_command_to_ftdi(io=io, command=command) + await asyncio.sleep(0.1) # its replies are cleared, not read + await io.usb_purge_rx_buffer() + + +class Rfid: + """The RFID reader. Reading a slot selects its antenna, then reads the tag in its field.""" + + def __init__( + self, + *, + driver: "MantisDriver", + slot_to_antenna: Optional[Dict[int, int]], + multiplexer_delay_raw: float, + ): + """ + Args: + driver: the driver it sends through. + slot_to_antenna: the unit's antenna for each input slot, as declared; None when none is + declared, and then a read refuses. + multiplexer_delay_raw: the delay after selecting an antenna, as the unit's file writes it; + its unit is unconfirmed, so a read refuses unless it is 0. + """ + self._driver = driver + self.slot_to_antenna = slot_to_antenna + self.multiplexer_delay_raw = multiplexer_delay_raw + self._lock = asyncio.Lock() # an antenna and the read through it go together + + async def _unchecked_fw_write_ppi(self, *, start_address: int, values: List[int]) -> None: + """A PPI write (board 0, command 41), sent once. + + Args: + start_address: the first PPI address written. + values: the values written from there. + """ + payload = struct.pack(" None: + """Select one antenna on the multiplexer.""" + await self._unchecked_fw_write_ppi(start_address=RFID_MULTIPLEXER_BASE_INDEX, values=[antenna]) + + async def _read_tag(self) -> Optional[bytes]: + """The tag in the selected antenna's field, hex-decoded; None if no whole tag is read in time. + + Raises: + MantisNotConnectedError: if the reader's link is not open. + """ + io = self._driver.rfid_io + if io is None: + raise MantisNotConnectedError(f"{self._driver.name}: the RFID reader's link is not open") + await _send_command_to_ftdi(io=io, command=RFID_READ_TAG_COMMAND) + try: + reply = await _read_reply(io=io, stop_key=b"]") + except MantisTimeoutError: + return None + match = re.search(rb"\[([0-9A-Fa-f]*)\]", reply) + if match is None or len(match.group(1)) != 2 + 8 * RFID_TAG_BLOCK_COUNT: + return None + return bytes.fromhex(match.group(1).decode("ascii")) + + async def request_tag(self, *, slot: int) -> Optional[Dict[str, Any]]: + """Read the tag of the chip in an input slot. + + Args: + slot: one of ``slot_to_antenna``. + + Returns: + ``serial``, ``chip_type`` (None for a code not in ``RFID_CHIP_TYPE_CODES``), + ``chip_type_code``, ``date_of_manufacture`` (yyyyMMdd), ``valid`` and ``tag_hex``; None + when the slot holds no chip. + + Raises: + MantisConfigurationError: if there are no antennas, or ``multiplexer_delay_raw`` is not 0; + nothing is sent. + ValueError: if ``slot`` is not one of them; nothing is sent. + """ + if self.multiplexer_delay_raw != 0: + raise MantisConfigurationError( + f"{self._driver.name}: RFID multiplexer delay {self.multiplexer_delay_raw} has an " + "unconfirmed unit; only 0 is supported" + ) + if self.slot_to_antenna is None: + raise MantisConfigurationError( + f"{self._driver.name}: no RFID antennas for this unit or its generation; give its " + "vendor folder (oem_config_directory)" + ) + self._driver._check_index(name="slot", value=slot, allowed=tuple(sorted(self.slot_to_antenna))) + async with self._lock: + await self._switch_antenna(antenna=self.slot_to_antenna[slot]) + tag = await self._read_tag() + if tag is None: + return None + code = compute_chip_type_code(tag=tag) + end = RFID_DATE_OF_MANUFACTURE_OFFSET + RFID_DATE_OF_MANUFACTURE_LENGTH + result = { + "serial": compute_tag_serial(tag=tag), + "chip_type": RFID_CHIP_TYPE_CODES.get(code) if code is not None else None, + "chip_type_code": code, + "date_of_manufacture": _transform_serial( + raw=tag[RFID_DATE_OF_MANUFACTURE_OFFSET:end].decode("ascii", "replace") + ), + "valid": compute_tag_valid(tag=tag), + "tag_hex": tag.hex(), + } + if not result["valid"]: + logger.warning("[%s] slot %d: tag %s is not valid", self._driver.name, slot, result["serial"]) + return result + + async def request_tags(self) -> Dict[int, Optional[Dict[str, Any]]]: + """Read every declared slot in turn; ``request_tag`` for each, by slot. + + Raises: + MantisConfigurationError: if there are no antennas; nothing is sent. + """ + if self.slot_to_antenna is None: + raise MantisConfigurationError( + f"{self._driver.name}: no RFID antennas for this unit or its generation; give its " + "vendor folder (oem_config_directory)" + ) + return {slot: await self.request_tag(slot=slot) for slot in sorted(self.slot_to_antenna)} diff --git a/pylabrobot/formulatrix/mantis/driver/features/rfid_tests.py b/pylabrobot/formulatrix/mantis/driver/features/rfid_tests.py new file mode 100644 index 00000000000..78c3b5de8b0 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/features/rfid_tests.py @@ -0,0 +1,138 @@ +"""RFID reader tests: the antenna switches and reader commands sent, against real replies.""" + +import unittest +from typing import List +from unittest import mock + +from pylabrobot.formulatrix.mantis.device import RECORDING_MANTIS_V4 +from pylabrobot.formulatrix.mantis.driver.errors import MantisConfigurationError +from pylabrobot.formulatrix.mantis.driver.features import rfid +from pylabrobot.formulatrix.mantis.driver.framing import parse_frame +from pylabrobot.formulatrix.mantis.driver.master_tests import ( + DriverTestCase, + FakeLink, + FakeReader, + make_answers, +) + +# Interface A: the tags of a low-volume Hi-Fi and a high-volume Hi-Fi chip, as a Mantis v4's +# reader sent them, chunked as they arrived. +LOW_VOLUME_HI_FI_TAG_REPLY = [ + b"Reque", + b"st mode.\r\n", + b"[00000000003532534C3230323220202", + b"0202020202008000000353230323730313139342E302020343230312E3020202033030000002E313231303" + b"03030303030303130303090050000C60400004E08000054080000000000000000000000000000]\r\n", +] +HIGH_VOLUME_HI_FI_TAG_REPLY = [ + b"Request mode.\r\n", + b"[00000000003532534838393130202020202020202007000", + b"000353230323032353037302E352020313431302E31202020370100000038383632202020202020202020" + b"202020DC010000D80200006E0400006F040000000000000000000000000000]\r\n", +] + + +def compute_tag(*, reply: List[bytes]) -> bytes: + """The tag a reply carries, hex-decoded.""" + return bytes.fromhex(b"".join(reply).split(b"[")[1].split(b"]")[0].decode()) + + +class RfidTests(DriverTestCase): + async def set_up(self, *, reader: FakeReader): + self.link = FakeLink(answers=make_answers(), reader=reader) + driver = await self.set_up_driver( + link=self.link, declared_configuration_json=RECORDING_MANTIS_V4 + ) + self.sent_before = len(self.link.written) + return driver + + async def test_setup_sets_up_the_reader(self): + reader = FakeReader() + await self.set_up(reader=reader) + self.assertEqual( + reader.written, + [ + rfid.RFID_PING_DEVICE_COMMAND, + rfid.RFID_SET_ANTENNA_MODE_COMMAND, + rfid.RFID_SET_UP_ISO15693_PROTOCOL_COMMAND, + ], + ) + + async def test_tags_switch_each_antenna_then_read(self): + reader = FakeReader(tags={0: LOW_VOLUME_HI_FI_TAG_REPLY, 1: HIGH_VOLUME_HI_FI_TAG_REPLY}) + driver = await self.set_up(reader=reader) + assert driver.rfid is not None + tags = await driver.rfid.request_tags() + self.assertEqual( + [parse_frame(frame=frame)[1:] for frame in self.link.written[self.sent_before :]], + [(0, 41, bytes.fromhex(f"78000100{antenna:02x}00")) for antenna in (0, 1, 4, 6, 7, 5)], + ) + self.assertEqual(reader.written[3:], [rfid.RFID_READ_TAG_COMMAND] * 6) + slot_1, slot_2 = tags[1], tags[2] + assert slot_1 is not None and slot_2 is not None + self.assertEqual( + (slot_1["serial"], slot_1["chip_type"], slot_1["valid"]), + ("LS252202", "low_volume_hi_fi", True), + ) + self.assertEqual( + (slot_2["serial"], slot_2["chip_type"], slot_2["valid"]), + ("HS250198", "high_volume_hi_fi", True), + ) + self.assertEqual([tags[slot] for slot in (3, 4, 5, 6)], [None] * 4) + + async def test_short_or_late_tag_is_no_chip(self): + short = [ + b"Request mode.\r\n", + b"[" + compute_tag(reply=LOW_VOLUME_HI_FI_TAG_REPLY)[:-4].hex().encode() + b"]\r\n", + ] + for reply in (short, [b"Request mode.\r\n"]): + with self.subTest(reply=reply): + driver = await self.set_up(reader=FakeReader(tags={0: reply})) + assert driver.rfid is not None + with mock.patch.object(rfid, "RFID_REPLY_TIMEOUT", 0.05): + self.assertIsNone(await driver.rfid.request_tag(slot=1)) + + async def test_no_antennas_an_unknown_slot_or_a_delay_send_nothing(self): + driver = await self.set_up(reader=FakeReader()) + assert driver.rfid is not None + with self.assertRaisesRegex(ValueError, "slot must be one of"): + await driver.rfid.request_tag(slot=7) + driver.rfid.multiplexer_delay_raw = 5.0 + with self.assertRaisesRegex(MantisConfigurationError, "unconfirmed unit"): + await driver.rfid.request_tag(slot=1) + driver.rfid.slot_to_antenna = None # a unit whose folder, declared file and recording have none + driver.rfid.multiplexer_delay_raw = 0.0 + with self.assertRaisesRegex(MantisConfigurationError, "no RFID antennas"): + await driver.rfid.request_tags() + self.assertEqual(len(self.link.written), self.sent_before) + + async def test_built_only_when_the_reader_answers(self): + with mock.patch.object(rfid, "RFID_REPLY_TIMEOUT", 0.05): + driver = await self.set_up(reader=FakeReader(answers_ping=False)) + self.assertIsNone(driver.rfid) + + async def test_a_reader_link_that_does_not_open_leaves_setup_working(self): + reader = FakeReader() + reader.io.setup.side_effect = OSError("interface A busy") + driver = await self.set_up(reader=reader) + self.assertIsNone(driver.rfid) + self.assertIsNotNone(driver.plate_clamp) + + async def test_the_reader_is_set_up_again_after_the_link_reopens(self): + reader = FakeReader() + driver = await self.set_up(reader=reader) + await driver.stop() + self.assertIsNone(driver.rfid) + await driver.setup() + self.assertIsNotNone(driver.rfid) + self.assertEqual(reader.written.count(rfid.RFID_PING_DEVICE_COMMAND), 2) + + def test_tag_validity(self): + tag = bytearray(compute_tag(reply=LOW_VOLUME_HI_FI_TAG_REPLY)) + self.assertTrue(rfid.compute_tag_valid(tag=bytes(tag))) + tag[25:33] = b"52020231" # date 20251320: no month 13 + self.assertFalse(rfid.compute_tag_valid(tag=bytes(tag))) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/formulatrix/mantis/driver/framing.py b/pylabrobot/formulatrix/mantis/driver/framing.py new file mode 100644 index 00000000000..13214aae7b6 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/framing.py @@ -0,0 +1,125 @@ +"""Mantis wire framing, on interface B of the device's FTDI bridge. + +Every frame, in both directions, is little-endian:: + + size (4) | packet number (2) | 0 (2) | board (2) | code (2) | payload (n) | check word (2) + +``size`` is 12 + n: the header and the payload, not the check word. The check word is the XOR of +the frame's 16-bit words before it. A host frame's code is its command. A device frame's code is 0 +for a reply, which echoes the command's packet number and board, and the event number for an event. +""" + +import logging +import struct +from typing import List, Tuple + +from .errors import ChecksumError, FrameError + +logger = logging.getLogger(__name__) + +HEADER_SIZE = 12 +CHECK_WORD_SIZE = 2 +FIRST_EVENT_CODE = 0x0200 +# PyLabRobot design choice: a 512-byte payload at most. The largest frame seen is 92 bytes. +MAXIMUM_FRAME_SIZE = HEADER_SIZE + 512 + CHECK_WORD_SIZE + +MOTION_BOARD = 0 +PRESSURE_BOARD = 10 +ABSOLUTE_POSITION_SENSOR_BOARD = 11 +LC3_BOARD = 12 + + +def compute_check_word(data: bytes) -> int: + """The XOR of the 16-bit little-endian words of ``data``. + + Raises: + ValueError: if ``data`` has an odd length. + """ + if len(data) % 2 != 0: + raise ValueError(f"a check word covers whole 16-bit words, got {len(data)} bytes") + check_word = 0 + for offset in range(0, len(data), 2): + check_word ^= int.from_bytes(data[offset : offset + 2], "little") + return check_word + + +def build_frame(*, packet_number: int, board: int, command: int, payload: bytes = b"") -> bytes: + """Frame a command for the device. + + Args: + packet_number: the command's packet number, 0 to 0xFFFF; the reply echoes it. + board: the board the command goes to. + command: the command number. + payload: the command's arguments, a whole number of 16-bit words. + """ + if len(payload) % 2 != 0: + raise ValueError(f"a payload is a whole number of 16-bit words, got {len(payload)} bytes") + frame = ( + (HEADER_SIZE + len(payload)).to_bytes(4, "little") + + packet_number.to_bytes(2, "little") + + bytes(2) + + board.to_bytes(2, "little") + + command.to_bytes(2, "little") + + payload + ) + return frame + compute_check_word(data=frame).to_bytes(2, "little") + + +def parse_frame(frame: bytes) -> Tuple[int, int, int, bytes]: + """Check one whole frame and split it into packet number, board, code and payload. + + A reserved word other than 0, never seen from the device, is logged. + + Raises: + FrameError: if the size field or the length is wrong. + ChecksumError: if the check word does not match. + """ + if len(frame) < HEADER_SIZE + CHECK_WORD_SIZE: + raise FrameError(f"frame too short ({len(frame)} bytes): {frame.hex()}") + size, packet_number, reserved, board, code = struct.unpack_from(" List[bytes]: + """Take every whole frame off the front of ``buffer``, leaving a partial one in it. + + Frames follow each other with no separator, so each one's size field says where the next starts. + A size field no frame can have (below the header, odd, or over ``MAXIMUM_FRAME_SIZE``) is not a + frame start: one byte is dropped and the next is tried, and the bytes dropped are logged. + """ + frames: List[bytes] = [] + dropped = bytearray() + while len(buffer) >= 4: + (size,) = struct.unpack_from(" MAXIMUM_FRAME_SIZE: + dropped.append(buffer.pop(0)) + continue + if len(buffer) < size + CHECK_WORD_SIZE: + break + frames.append(bytes(buffer[: size + CHECK_WORD_SIZE])) + del buffer[: size + CHECK_WORD_SIZE] + if dropped: + logger.warning("dropped %d bytes that start no frame: %s", len(dropped), dropped.hex()) + return frames + + +def decode_string(payload: bytes) -> str: + """A UTF-16LE string ending in a 0x0000 character, as in a version or name reply. + + Raises: + FrameError: if the payload has no terminating 0x0000 character. + """ + for offset in range(0, len(payload) - 1, 2): + if payload[offset : offset + 2] == bytes(2): + return payload[:offset].decode("utf-16-le") + raise FrameError(f"string without a terminating 0x0000: {payload.hex()}") diff --git a/pylabrobot/formulatrix/mantis/driver/framing_tests.py b/pylabrobot/formulatrix/mantis/driver/framing_tests.py new file mode 100644 index 00000000000..f7f364ba956 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/framing_tests.py @@ -0,0 +1,93 @@ +"""Framing tests, against frames recorded on the link of a Mantis v4.""" + +import unittest + +from pylabrobot.formulatrix.mantis.driver.errors import ChecksumError, FrameError +from pylabrobot.formulatrix.mantis.driver.framing import build_frame, parse_frame, split_frames + +# Host frames: (frame, packet number, board, command, payload). +RECORDED_HOST_FRAMES = { + "version read, board 0": ("0c00000000000000000001000d00", 0, 0, 0x0001, ""), + "limits read, motor 2": ("0e0000000e00000000000a0002000800", 14, 0, 0x000A, "0200"), + "two-word payload": ("100000000c00000000003800230001000600", 12, 0, 0x0038, "23000100"), + "board 12": ("0e0000000f0000000c00100000001d00", 15, 12, 0x0010, "0000"), + "high packet number": ( + "12000000816d000000002900780001000000c36d", + 0x6D81, + 0, + 0x0029, + "780001000000", + ), +} +# Device frames: (frame, packet number, board, code, payload). +RECORDED_REPLY = ("0c000000816d0000000000008d6d", 0x6D81, 0, 0x0000, "") +RECORDED_UNANSWERED_FRAME = ("0c000000000000000000ff7ff37f", 0, 0, 0x7FFF, "") + + +class BuildFrameTests(unittest.TestCase): + def test_reproduces_every_recorded_host_frame(self): + for key, (frame, packet_number, board, command, payload) in RECORDED_HOST_FRAMES.items(): + with self.subTest(key=key): + built = build_frame( + packet_number=packet_number, + board=board, + command=command, + payload=bytes.fromhex(payload), + ) + self.assertEqual(built.hex(), frame) + + +class ParseFrameTests(unittest.TestCase): + def test_every_recorded_frame(self): + for frame, packet_number, board, code, payload in [ + *RECORDED_HOST_FRAMES.values(), + RECORDED_REPLY, + RECORDED_UNANSWERED_FRAME, + ]: + with self.subTest(frame=frame): + self.assertEqual( + parse_frame(frame=bytes.fromhex(frame)), + (packet_number, board, code, bytes.fromhex(payload)), + ) + + def test_wrong_check_word(self): + frame = bytearray.fromhex(RECORDED_REPLY[0]) + frame[-1] ^= 0x01 + with self.assertRaises(ChecksumError): + parse_frame(frame=bytes(frame)) + + def test_malformed_frames(self): + reply = bytes.fromhex(RECORDED_REPLY[0]) + with self.subTest(case="too short"), self.assertRaises(FrameError): + parse_frame(frame=bytes(12)) + with self.subTest(case="size field disagrees"), self.assertRaises(FrameError): + parse_frame(frame=reply + bytes(2)) + + +class SplitFramesTests(unittest.TestCase): + def test_frames_back_to_back_and_a_partial_one(self): + reply = bytes.fromhex(RECORDED_REPLY[0]) + other = bytes.fromhex(RECORDED_UNANSWERED_FRAME[0]) + buffer = bytearray(reply + other + reply[:5]) + self.assertEqual(split_frames(buffer=buffer), [reply, other]) + self.assertEqual(bytes(buffer), reply[:5]) + buffer.extend(reply[5:]) + self.assertEqual(split_frames(buffer=buffer), [reply]) + self.assertEqual(buffer, bytearray()) + + def test_a_size_no_frame_can_have_drops_one_byte_at_a_time(self): + reply = bytes.fromhex(RECORDED_REPLY[0]) + for case, garbage in ( + ("below the header", bytes.fromhex("0a000000")), + ("odd", bytes.fromhex("0d000000")), + ("over the maximum", bytes.fromhex("0e")), + ): + with self.subTest(case=case): + buffer = bytearray(garbage + reply) + with self.assertLogs("pylabrobot.formulatrix.mantis.driver.framing", level="WARNING"): + self.assertEqual(split_frames(buffer=buffer), [reply]) + self.assertEqual(buffer, bytearray()) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/formulatrix/mantis/driver/master.py b/pylabrobot/formulatrix/mantis/driver/master.py new file mode 100644 index 00000000000..c0bd27bb5df --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/master.py @@ -0,0 +1,619 @@ +"""The Mantis driver: the link on the FTDI's interface B, discovery and the device reads. + +Every command sent here reads the device; the features in ``features/`` send the commands that +act. Reads return the device's units. The motor and pressure reads arrive with the arm and pump +features. +""" + +import asyncio +import json +import logging +import struct +from typing import Any, Dict, List, Optional, Set, Tuple + +from pylabrobot.io.ftdi import FTDI + +from .configuration import ( + FITTED_FIELDS, + RECORDING_MANTIS_V3, + RECORDING_MANTIS_V4, + UNIT_BLOCKS, + DeviceConfiguration, + compute_generation, + configuration_to_json, + read_configuration, +) +from .confirmed_firmware_versions import CONFIRMED_FIRMWARE_VERSIONS +from .errors import ( + CommandRefusedError, + FrameError, + MantisConfigurationError, + MantisConnectionLostError, + MantisNotConnectedError, + MantisTimeoutError, +) +from .features.plate_clamp import UNCLAMPING_DONE, PlateClamp +from .features.rfid import RFID_BAUD_RATE, RFID_FTDI_INTERFACE, Rfid, _initialize_rfid_module +from .framing import ( + ABSOLUTE_POSITION_SENSOR_BOARD, + FIRST_EVENT_CODE, + LC3_BOARD, + MOTION_BOARD, + PRESSURE_BOARD, + build_frame, + decode_string, + parse_frame, + split_frames, +) +from .oem_config import _load_unit_configuration + +logger = logging.getLogger(__name__) + +MOTORS = (0, 1, 2) + + +class MantisDriver: + """Talks to a Mantis: one command in flight, events logged as they come.""" + + def _build_io(self, *, device_id: Optional[str]) -> FTDI: + """Build the link to the Mantis ``device_id`` names, or to the first one found.""" + return FTDI( + human_readable_device_name="Formulatrix Mantis", + device_id=device_id, + vid=0x0403, + pid=0x6010, + interface_select=2, # interface B carries the command link + ) + + def _build_rfid_io(self, *, device_id: Optional[str]) -> Optional[FTDI]: + """Build the link to the RFID reader, interface A of the same FTDI bridge.""" + return FTDI( + human_readable_device_name="Formulatrix Mantis RFID reader", + device_id=device_id, + vid=0x0403, + pid=0x6010, + interface_select=RFID_FTDI_INTERFACE, + ) + + def __init__( + self, + *, + device_id: Optional[str] = None, + io: Optional[FTDI] = None, + rfid_io: Optional[FTDI] = None, + name: str = "Mantis", + declared_configuration_json: Optional[str] = None, + oem_config_directory: Optional[str] = None, + ): + """ + Args: + device_id: the FTDI serial number. None uses the first Mantis found. + io: an already-built ``FTDI`` for interface B, used instead of building one. + rfid_io: an already-built ``FTDI`` for interface A, the RFID reader. None builds one for + ``device_id``, unless ``io`` is given: then there is no RFID reader. + name: the device name, heading its log messages. + declared_configuration_json: a file ``save_configuration`` wrote. Setup refuses a device + that disagrees with its ``device`` block on what is fitted (``FITTED_FIELDS``). + oem_config_directory: the unit's vendor ``Data/Device/Configs`` folder, read at every setup. + + The unit's own values (``UNIT_BLOCKS``) come from ``oem_config_directory`` and the declared + file; where both have a block, they must agree. A block neither has comes from the + generation's recording, with a warning: another unit's values, less accurate on this one. + """ + self.io: FTDI = io if io is not None else self._build_io(device_id=device_id) + self.name = name + self.declared_configuration_json = declared_configuration_json + self.oem_config_directory = oem_config_directory + self._unit_blocks: Dict[str, Dict[str, Any]] = {} + self._unit_blocks_from_recording: Set[str] = set() + self.configuration = DeviceConfiguration() + self._default_read_timeout = 5.0 # PyLabRobot design choice; replies take under 0.1 s + self._lock = asyncio.Lock() + self._packet_number = 0 + self._buffer = bytearray() + self._pending: Optional[Tuple[int, int, "asyncio.Future[Tuple[int, bytes]]"]] = None + self._event_waiters: List[Tuple[int, int, "asyncio.Future[bytes]"]] = [] + self._reader: Optional["asyncio.Task[None]"] = None + self.rfid_io: Optional[FTDI] = ( + rfid_io if rfid_io is not None or io is not None else self._build_rfid_io(device_id=device_id) + ) + self.plate_clamp: Optional[PlateClamp] = None + self.rfid: Optional[Rfid] = None + self._rfid_link_open = False + self._connected = False + + @property + def default_read_timeout(self) -> float: + """Seconds one attempt of a command waits for its reply when ``timeout`` is None.""" + return self._default_read_timeout + + @default_read_timeout.setter + def default_read_timeout(self, value: float) -> None: + if value <= 0: + raise ValueError(f"default_read_timeout must be positive, got {value}") + self._default_read_timeout = value + + # -- link ----------------------------------------------------------------- + + async def _set_up_line(self) -> None: + """Reset the bridge and set 115 200 baud, 8N1, RTS/CTS, RTS on; purge both buffers.""" + await self.io.usb_reset() + await self.io.set_baudrate(baudrate=115_200) + await self.io.set_line_property(bits=8, stopbits=0, parity=0) + await self.io.set_flowctrl(flowctrl=0x100) # RTS/CTS + await self.io.set_rts(level=True) + await self.io.set_latency_timer(latency=2) # PyLabRobot design choice, in ms + await self.io.usb_purge_rx_buffer() + await self.io.usb_purge_tx_buffer() + + def _handle_event(self, *, board: int, code: int, payload: bytes) -> None: + """Log an event and hand it to every waiter for its board and code.""" + logger.info("[%s] event 0x%04x from board %d: %s", self.name, code, board, payload.hex()) + waiting = self._event_waiters + self._event_waiters = [] + for waiter_board, waiter_code, future in waiting: + if (waiter_board, waiter_code) != (board, code): + self._event_waiters.append((waiter_board, waiter_code, future)) + elif not future.done(): + future.set_result(payload) + + def _handle_frame(self, *, frame: bytes) -> None: + """Route one device frame: an event by its board and code, a reply to the command it echoes.""" + packet_number, board, code, payload = parse_frame(frame=frame) + # The code is an int16: from 0x0200 an event, negative (0x8000 up) a reply's device error. + if FIRST_EVENT_CODE <= code < 0x8000: + self._handle_event(board=board, code=code, payload=payload) + return + if self._pending is not None: + pending_packet_number, pending_board, future = self._pending + if (pending_packet_number, pending_board) == (packet_number, board) and not future.done(): + future.set_result((code, payload)) + return + logger.debug("[%s] unmatched reply ignored: %s", self.name, frame.hex()) + + async def _read_frames(self) -> None: + """Read the link until cancelled, routing every frame; runs from setup to stop. + + A failed read ends it: the failure is logged and handed to the command in flight and to every + event waiter. + """ + while True: + try: + data = await self.io.read(num_bytes=1024) + except Exception as error: + logger.error("[%s] reading the link failed: %r", self.name, error) + lost = MantisConnectionLostError(f"{self.name}: reading the link failed: {error!r}") + lost.__cause__ = error + if self._pending is not None: + _, _, future = self._pending + if not future.done(): + future.set_exception(lost) + waiting, self._event_waiters = self._event_waiters, [] + for _, _, waiter in waiting: + if not waiter.done(): + waiter.set_exception(lost) + raise + if not data: + await asyncio.sleep(0.01) # PyLabRobot design choice: poll every 10 ms + continue + self._buffer.extend(data) + for frame in split_frames(buffer=self._buffer): + try: + self._handle_frame(frame=frame) + except FrameError as error: + logger.warning("[%s] frame dropped: %s", self.name, error) + + async def _write_frame(self, *, frame: bytes) -> None: + """Write one frame to the link. + + Raises: + MantisNotConnectedError: if the driver is not set up. + MantisConnectionLostError: if reading the link has failed. + """ + if self._reader is None: + raise MantisNotConnectedError(f"{self.name} is not set up; call setup() first") + if self._reader.done(): + failure = None if self._reader.cancelled() else self._reader.exception() + raise MantisConnectionLostError( + f"{self.name}: reading the link has failed; call setup() again" + ) from failure + await self.io.write(frame) + + async def _send_command( + self, + *, + board: int, + command: int, + payload: bytes = b"", + timeout: Optional[float] = None, + attempts: int = 1, + ) -> bytes: + """Send one command and return its reply's payload; one command is in flight at a time. + + A command is sent once unless ``attempts`` says otherwise; a read passes ``attempts=3`` and is + resent with the same packet number, as the device answers a resend like the first send. + + Args: + board: the board the command goes to. + command: the command number. + payload: the command's arguments. + timeout: seconds per attempt; None uses ``default_read_timeout``. + attempts: how often the command is sent before giving up. + + Raises: + MantisNotConnectedError: if the driver is not set up, or it stopped. + MantisConnectionLostError: if reading the link has failed. + MantisTimeoutError: if no attempt got a reply. + CommandRefusedError: if the reply's status is not 0. + """ + timeout = self.default_read_timeout if timeout is None else timeout + async with self._lock: + packet_number = self._packet_number + self._packet_number = (self._packet_number + 1) & 0xFFFF + frame = build_frame( + packet_number=packet_number, board=board, command=command, payload=payload + ) + future: "asyncio.Future[Tuple[int, bytes]]" = asyncio.get_running_loop().create_future() + self._pending = (packet_number, board, future) + try: + for attempt in range(attempts): + if attempt > 0: + logger.warning( + "[%s] no reply to board %d command %d; resending", self.name, board, command + ) + await self._write_frame(frame=frame) + try: + code, reply = await asyncio.wait_for(asyncio.shield(future), timeout) + break + except asyncio.TimeoutError: + continue + else: + raise MantisTimeoutError( + f"{self.name}: no reply to board {board} command {command} after {attempts} " + f"attempt(s) of {timeout} s" + ) + finally: + self._pending = None + if code != 0: + status = code - 0x10000 if code >= 0x8000 else code + message = f"{self.name}: board {board} refused command {command} with status {status}" + if status < 0: + try: + message += f": {decode_string(payload=reply)}" + except (FrameError, UnicodeDecodeError): + message += f" ({reply.hex()})" + raise CommandRefusedError(message, board=board, command=command, status=status) + return reply + + def _expect_event(self, *, board: int, code: int) -> "asyncio.Future[bytes]": + """Register a waiter for the next event with ``board`` and ``code``, before the command that + causes it is sent; pass it to ``_wait_for_event``.""" + future: "asyncio.Future[bytes]" = asyncio.get_running_loop().create_future() + self._event_waiters.append((board, code, future)) + return future + + async def _wait_for_event(self, *, waiter: "asyncio.Future[bytes]", timeout: float) -> bytes: + """Wait for the event ``waiter`` was registered for and return its payload. + + Raises: + MantisTimeoutError: if the event does not come within ``timeout`` seconds. + MantisNotConnectedError: if the driver stops first. + MantisConnectionLostError: if reading the link fails first. + """ + try: + return await asyncio.wait_for(waiter, timeout) + except asyncio.TimeoutError as error: + raise MantisTimeoutError(f"{self.name}: no event within {timeout} s") from error + finally: + self._event_waiters = [ + (board, code, future) for board, code, future in self._event_waiters if future is not waiter + ] + + # -- device reads --------------------------------------------------------- + + def _check_index(self, *, name: str, value: int, allowed: Tuple[int, ...]) -> None: + """Raise ``ValueError`` if ``value`` is not one of ``allowed``, before anything is sent.""" + if value not in allowed: + raise ValueError(f"{name} must be one of {allowed}, got {value}") + + async def request_firmware_version(self, *, board: int = MOTION_BOARD) -> str: + """The firmware version of a board. + + Args: + board: 0 (motion), 10 (pressure), 11 (absolute position sensors) or 12 (LC3). + """ + self._check_index( + name="board", + value=board, + allowed=(MOTION_BOARD, PRESSURE_BOARD, ABSOLUTE_POSITION_SENSOR_BOARD, LC3_BOARD), + ) + return decode_string(payload=await self._send_command(board=board, command=1, attempts=3)) + + async def request_name(self) -> str: + """The name the motion firmware reports, ``LiquidHandler`` on the v4 we have run. + + Whether it differs between units is not known; it does not identify this Mantis. + """ + return decode_string( + payload=await self._send_command(board=MOTION_BOARD, command=2, attempts=3) + ) + + async def request_motor_limits(self, *, motor: int) -> Tuple[float, float]: + """A motor's (lower, upper) limit. + + Args: + motor: 0, 1 (arm) or 2 (Z). + """ + self._check_index(name="motor", value=motor, allowed=MOTORS) + reply = await self._send_command( + board=MOTION_BOARD, command=10, payload=struct.pack(" DeviceConfiguration: + """Read the firmware versions and the motor limits off the device. + + Only the motion board must answer. Each other board gets one attempt; one that times out, + refuses or answers a malformed version is absent, not a fault. + """ + firmware_versions: Dict[int, Optional[str]] = { + MOTION_BOARD: await self.request_firmware_version(board=MOTION_BOARD) + } + for board in (PRESSURE_BOARD, ABSOLUTE_POSITION_SENSOR_BOARD, LC3_BOARD): + try: + reply = await self._send_command(board=board, command=1, attempts=1) + firmware_versions[board] = decode_string(payload=reply) + except (TimeoutError, CommandRefusedError, FrameError, UnicodeDecodeError) as error: + logger.info("[%s] board %d taken as absent: %r", self.name, board, error) + firmware_versions[board] = None + return DeviceConfiguration( + generation=compute_generation(firmware_versions=firmware_versions), + firmware_versions=firmware_versions, + motor_limits={motor: await self.request_motor_limits(motor=motor) for motor in MOTORS}, + ) + + def _discover_features(self) -> None: + """Build the features the configuration says are fitted, with the unit's values. + + A feature already built is kept. + + Raises: + MantisConfigurationError: if the ``plate_clamp`` block is malformed. + """ + if self.configuration.firmware_versions.get(LC3_BOARD) is not None and self.plate_clamp is None: + levels: Optional[Dict[int, Tuple[float, int]]] = None + block = self._unit_blocks.get("plate_clamp") + if block is not None: + try: + levels = { + int(level): (float(pwm), int(timeout_ms)) + for level, (pwm, timeout_ms) in block["levels"].items() + } + except (KeyError, TypeError, ValueError) as error: + raise MantisConfigurationError(f"malformed plate_clamp block: {error!r}") from error + self.plate_clamp = PlateClamp(driver=self, levels=levels) + + def _check_declared_configuration(self) -> None: + """Compare what is fitted with the declared configuration's ``device`` block, if there is one. + + Raises: + MantisConfigurationError: if the device and the declaration disagree on what is fitted. + """ + if self.declared_configuration_json is None: + return + declared = read_configuration(path=self.declared_configuration_json).get("device") + if declared is None: + return + answered = configuration_to_json(configuration=self.configuration) + differences = [ + f"{field}: declared {declared.get(field)!r}, device {answered[field]!r}" + for field in FITTED_FIELDS + if declared.get(field) != answered[field] + ] + # A board the declaration lists with a version must answer; versions are not compared. + differences += [ + f"board {board}: declared {version!r}, device did not answer" + for board, version in declared.get("firmware_versions", {}).items() + if version is not None and answered["firmware_versions"].get(board) is None + ] + if differences: + raise MantisConfigurationError( + f"{self.name} does not match {self.declared_configuration_json}: " + "; ".join(differences) + ) + + async def discover(self) -> None: + """Read the configuration into ``configuration``, check it against the declared one, then + build the features it lists, and the RFID reader if it answers on interface A. + + The reader is set up again after every opening of the link; one that fails is absent. + + Raises: + MantisConfigurationError: if the device does not match the declared configuration, the + unit's folder and declared file disagree on a block, or a block is malformed. + """ + self.configuration = await self._request_configuration() + self._check_declared_configuration() + # The unit's blocks: its folder and its declared file must agree; a recording fills the rest. + folder = ( + _load_unit_configuration(oem_config_directory=self.oem_config_directory) + if self.oem_config_directory is not None + else {} + ) + declared = ( + read_configuration(path=self.declared_configuration_json) + if self.declared_configuration_json is not None + else {} + ) + recording_path = ( + RECORDING_MANTIS_V4 if self.configuration.generation == "v4" else RECORDING_MANTIS_V3 + ) + recording = read_configuration(path=recording_path) + differences = [ + f"{block}: declared {declared[block]!r}, folder {folder[block]!r}" + for block in UNIT_BLOCKS + if block in folder + and block in declared + and json.loads(json.dumps(folder[block])) != json.loads(json.dumps(declared[block])) + ] + if differences: + raise MantisConfigurationError( + f"{self.oem_config_directory} and {self.declared_configuration_json} disagree: " + + "; ".join(differences) + ) + self._unit_blocks, self._unit_blocks_from_recording = {}, set() + for block in UNIT_BLOCKS: + if block in folder or block in declared: + self._unit_blocks[block] = folder[block] if block in folder else declared[block] + elif block in recording: + self._unit_blocks[block] = recording[block] + self._unit_blocks_from_recording.add(block) + logger.warning( + "[%s] %s: none of this unit's values given; using %s's, less accurate on this unit", + self.name, + block, + recording_path, + ) + self._discover_features() + if self.rfid is None and self.rfid_io is not None and self._rfid_link_open: + try: + await _initialize_rfid_module(io=self.rfid_io) + except Exception as error: + logger.info("[%s] no RFID reader: %r", self.name, error) + return + slot_to_antenna: Optional[Dict[int, int]] = None + multiplexer_delay_raw = 0.0 # unused while slot_to_antenna is None: every read refuses + rfid_block = self._unit_blocks.get("rfid") + if rfid_block is not None: + try: + slot_to_antenna = { + int(slot): int(antenna) for slot, antenna in rfid_block["slot_to_antenna"].items() + } + multiplexer_delay_raw = float(rfid_block["multiplexer_delay_raw"]) + except (KeyError, TypeError, ValueError) as error: + raise MantisConfigurationError(f"malformed rfid block: {error!r}") from error + self.rfid = Rfid( + driver=self, slot_to_antenna=slot_to_antenna, multiplexer_delay_raw=multiplexer_delay_raw + ) + + def _check_firmware_compatibility(self) -> None: + """Log a warning if the boards' firmware versions are not confirmed for the generation.""" + generation = self.configuration.generation + confirmed = CONFIRMED_FIRMWARE_VERSIONS.get(generation or "", ()) + if self.configuration.firmware_versions not in confirmed: + logger.warning( + "[%s] firmware %s on a Mantis %s is not confirmed; please report how it works", + self.name, + self.configuration.firmware_versions, + generation, + ) + + def save_configuration(self, path: str, indent: Optional[int] = 2) -> None: + """Write what this device reported to a file, to be declared later + (``declared_configuration_json``), with the unit's blocks; none taken from a recording. + + Args: + path: where to write it. + indent: how far to indent the JSON, or None to write it on one line. + + Raises: + MantisNotConnectedError: if nothing has been read off the device yet. + """ + if self.configuration.generation is None: + raise MantisNotConnectedError("nothing read off the device yet; call `setup` first") + saved: Dict[str, Any] = {"device": configuration_to_json(configuration=self.configuration)} + for block, values in self._unit_blocks.items(): + if block not in self._unit_blocks_from_recording: + saved[block] = values + with open(path, "w", encoding="utf-8") as f: + json.dump(saved, f, indent=indent) + f.write("\n") + + async def _open(self) -> None: + """Open the link, set up the line and start reading it; then open the RFID reader's link.""" + await self.io.setup() + await self._set_up_line() + self._buffer.clear() + self._reader = asyncio.create_task(self._read_frames()) + logger.info("[%s] connected to FTDI %s", self.name, self.io.device_id) + if self.rfid_io is not None: + # The reader is optional: a link that does not open leaves it absent, not setup failed. + try: + await self.rfid_io.setup() + await self.rfid_io.set_baudrate(baudrate=RFID_BAUD_RATE) + await self.rfid_io.set_line_property(bits=8, stopbits=0, parity=0) # 8N1 + self._rfid_link_open = True + except Exception: + logger.warning("[%s] the RFID reader's link did not open", self.name, exc_info=True) + + async def _close(self) -> None: + """Stop reading the link and close it, then close the RFID reader's link.""" + reader, self._reader = self._reader, None + if reader is not None: + reader.cancel() + try: + await reader + except asyncio.CancelledError: + pass + except Exception: + logger.warning("[%s] the link reader had failed", self.name, exc_info=True) + self.rfid = None # the reader is set up again when the link reopens + rfid_link_open, self._rfid_link_open = self._rfid_link_open, False + try: + await self.io.stop() + finally: + if self.rfid_io is not None and rfid_link_open: + try: + await self.rfid_io.stop() + except Exception: + logger.warning("[%s] the RFID reader's link did not close", self.name, exc_info=True) + + async def stop(self) -> None: + """Fail the command in flight and every event waiter, release the plate clamp, stop reading + the link and close it. Each step runs whether or not the one before it succeeded.""" + stopped = f"{self.name} stopped" + if self._pending is not None: + _, _, future = self._pending + if not future.done(): + future.set_exception(MantisNotConnectedError(stopped)) + waiting, self._event_waiters = self._event_waiters, [] + for _, _, waiter in waiting: + if not waiter.done(): + waiter.set_exception(MantisNotConnectedError(stopped)) + if self.plate_clamp is not None and self._connected: + try: + if await self.plate_clamp.request_state(attempts=1) != UNCLAMPING_DONE: + await self.plate_clamp.unlock() + except Exception: + logger.warning("[%s] could not release the plate clamp", self.name, exc_info=True) + self._connected = False + await self._close() + + async def setup(self) -> None: + """Open the link if it is not open, read the device's configuration and check it. + + Raises: + MantisConfigurationError: if the device does not match the declared configuration. + """ + if self._reader is not None and self._reader.done(): + await self.stop() # reading the link failed: close it before opening it again + try: + if not self._connected: + await self._open() + self._connected = True + await self.discover() + except BaseException: + try: + await self.stop() + except Exception: + logger.warning("[%s] stop after a failed setup failed", self.name, exc_info=True) + raise + self._check_firmware_compatibility() + logger.info( + "[%s] generation %s, firmware %s", + self.name, + self.configuration.generation, + self.configuration.firmware_versions, + ) diff --git a/pylabrobot/formulatrix/mantis/driver/master_tests.py b/pylabrobot/formulatrix/mantis/driver/master_tests.py new file mode 100644 index 00000000000..bde33dfcd37 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/master_tests.py @@ -0,0 +1,322 @@ +"""MantisDriver tests: the bytes sent, against replies recorded from a Mantis v4.""" + +import asyncio +import json +import os +import struct +import tempfile +import unittest +from typing import Dict, List, Optional, Tuple +from unittest import mock + +from pylabrobot.formulatrix.mantis.device import RECORDING_MANTIS_V4 +from pylabrobot.formulatrix.mantis.driver.configuration import DeviceConfiguration +from pylabrobot.formulatrix.mantis.driver.errors import ( + MantisConfigurationError, + MantisConnectionLostError, + MantisNotConnectedError, + MantisTimeoutError, +) +from pylabrobot.formulatrix.mantis.driver.features.rfid import ( + RFID_PING_DEVICE_COMMAND, + RFID_READ_TAG_COMMAND, +) +from pylabrobot.formulatrix.mantis.driver.framing import build_frame, parse_frame +from pylabrobot.formulatrix.mantis.driver.master import MantisDriver +from pylabrobot.io.ftdi import FTDI + +with open(RECORDING_MANTIS_V4, encoding="utf-8") as f: + RECORDING = json.load(f) +# A status read of motor 0 with the event the device sent before its reply, as recorded. +RECORDED_EVENT = ( + "280000005d0000000000010200000000000000001096943f00000000000000000000000000000000f0ab" +) +# A reply to a command never sent, as recorded. +RECORDED_UNMATCHED_REPLY = "0c000000816d0000000000008d6d" +# The reply payloads a Mantis v4 sent, by (board, command, request payload), as hex. +RECORDED_REPLIES: Dict[Tuple[int, int, str], str] = { + (0, 1, ""): "34002e00320030002e0036002e0032000000", + (0, 2, ""): "4c0069007100750069006400480061006e0064006c00650072000000", + (10, 1, ""): "31002e0030002e0038000000", + (11, 1, ""): "30002e0031002e003700200044004200470000000000", + (12, 1, ""): "30002e0031002e003800200044004200470000000000", + (0, 10, "0000"): "000000000040afc0000000000040af40", + (0, 10, "0100"): "000000000040afc0000000000040af40", + (0, 10, "0200"): "00000000000064c00000000000f8a140", + # The release and the lock at levels 1, 2 and 3. + (12, 31, "000000000000e83f000000000000000088130000"): "", + (12, 30, "0000000000000000105839b4c876be3fd0070000"): "", + (12, 30, "0000000000000000e17a14ae47e1ca3fe8030000"): "", + (12, 30, "0000000000000000333333333333d33ff4010000"): "", + (12, 33, ""): "0600", + # The RFID antenna switch to each of the six antennas. + **{(0, 41, f"78000100{antenna:02x}00"): "" for antenna in (0, 1, 4, 5, 6, 7)}, +} +# Interface A: what the RFID reader on a Mantis v4 sent, chunked as it arrived. +READER_PING_REPLY = [b"TRF7970A EVM \r\n\r\n"] +READER_EMPTY_SLOT_REPLY = [b"Request mode.\r\n", b"[]\r\n"] + + +def make_answers( + *, absent_boards: Tuple[int, ...] = () +) -> Dict[Tuple[int, int, bytes], List[bytes]]: + """The recorded reply payloads, by (board, command, payload); a board absent answers nothing.""" + return { + (board, command, bytes.fromhex(request)): [bytes.fromhex(reply)] + for (board, command, request), reply in RECORDED_REPLIES.items() + if board not in absent_boards + } + + +class FakeReader: + """Interface A: answers the ping, and a tag read with the tag at the selected antenna, in its + recorded chunks; an antenna with no tag answers as an empty slot.""" + + def __init__(self, *, answers_ping: bool = True, tags: Optional[Dict[int, List[bytes]]] = None): + self.answers_ping = answers_ping + self.tags = tags or {} + self.antenna: Optional[int] = None + self.written: List[bytes] = [] + self.pending: List[bytes] = [] + self.io = mock.create_autospec(FTDI, instance=True) + self.io.write.side_effect = self.write + self.io.read.side_effect = self.read + self.io.usb_purge_rx_buffer.side_effect = self.purge + + async def purge(self) -> None: + self.pending = [] + + async def write(self, data: bytes) -> int: + self.written.append(data) + if data == RFID_PING_DEVICE_COMMAND and self.answers_ping: + self.pending = list(READER_PING_REPLY) + elif data == RFID_READ_TAG_COMMAND and self.antenna is not None: + self.pending = list(self.tags.get(self.antenna, READER_EMPTY_SLOT_REPLY)) + return len(data) + + async def read(self, num_bytes: int = 1) -> bytes: + return self.pending.pop(0) if self.pending else b"" + + +class FakeLink: + """Answers each frame written with its recorded reply, echoing packet number and board. A list + of replies is played in order and its last reply repeats. An antenna switch selects + ``reader``'s antenna.""" + + def __init__( + self, + answers: Dict[Tuple[int, int, bytes], List[bytes]], + *, + reader: Optional[FakeReader] = None, + ): + self.answers = answers + self.reader = reader or FakeReader() + self.written: List[bytes] = [] + self.incoming = bytearray() + self.io = mock.create_autospec(FTDI, instance=True) + self.io.device_id = "test" + self.io.write.side_effect = self.write + self.io.read.side_effect = self.read + + async def write(self, data: bytes) -> int: + self.written.append(data) + packet_number, board, command, payload = parse_frame(frame=data) + if (board, command) == (0, 41): + (self.reader.antenna,) = struct.unpack_from(" 1 else replies[0] + self.incoming.extend( + build_frame(packet_number=packet_number, board=board, command=0, payload=reply) + ) + return len(data) + + async def read(self, num_bytes: int = 1) -> bytes: + data = bytes(self.incoming[:num_bytes]) + del self.incoming[:num_bytes] + return data + + +class DriverTestCase(unittest.IsolatedAsyncioTestCase): + def make_driver(self, *, link: FakeLink, **kwargs) -> MantisDriver: + driver = MantisDriver(io=link.io, rfid_io=link.reader.io, **kwargs) + driver.default_read_timeout = 0.05 + return driver + + async def set_up_driver(self, *, link: FakeLink, **kwargs) -> MantisDriver: + driver = self.make_driver(link=link, **kwargs) + await driver.setup() + self.addAsyncCleanup(driver.stop) + return driver + + +class SetupTests(DriverTestCase): + async def test_line_set_up(self): + link = FakeLink(answers=make_answers()) + await self.set_up_driver(link=link) + link.io.set_baudrate.assert_awaited_once_with(baudrate=115_200) + link.io.set_line_property.assert_awaited_once_with(bits=8, stopbits=0, parity=0) + link.io.set_flowctrl.assert_awaited_once_with(flowctrl=0x100) + + async def test_v4_discovery(self): + link = FakeLink(answers=make_answers()) + driver = await self.set_up_driver(link=link) + self.assertEqual( + [parse_frame(frame=frame)[1:] for frame in link.written[:4]], + [(board, 1, b"") for board in (0, 10, 11, 12)], + ) + self.assertEqual( + driver.configuration, + DeviceConfiguration( + generation="v4", + firmware_versions={0: "4.20.6.2", 10: "1.0.8", 11: "0.1.7 DBG", 12: "0.1.8 DBG"}, + motor_limits={0: (-4000.0, 4000.0), 1: (-4000.0, 4000.0), 2: (-160.0, 2300.0)}, + ), + ) + + async def test_only_board_0_must_answer_and_each_other_board_is_asked_once(self): + link = FakeLink(answers=make_answers(absent_boards=(10, 11, 12))) + driver = await self.set_up_driver(link=link) + self.assertEqual(driver.configuration.generation, "v3") + self.assertEqual( + driver.configuration.firmware_versions, {0: "4.20.6.2", 10: None, 11: None, 12: None} + ) + sent = [parse_frame(frame=frame)[1:] for frame in link.written] + for board in (10, 11, 12): + with self.subTest(board=board): + self.assertEqual(sent.count((board, 1, b"")), 1) + + async def test_declared_configuration_that_disagrees_refuses_setup(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "declared.json") + with open(path, "w", encoding="utf-8") as f: + json.dump({"device": {**RECORDING["device"], "generation": "v3"}}, f) + link = FakeLink(answers=make_answers()) + driver = self.make_driver(link=link, declared_configuration_json=path) + with self.assertRaisesRegex( + MantisConfigurationError, "generation: declared 'v3', device 'v4'" + ): + await driver.setup() + link.io.stop.assert_awaited() + sent = [parse_frame(frame=frame)[1:] for frame in link.written] + self.assertEqual([command for board, command, _ in sent if board == 12], [1]) + + async def test_declared_board_that_does_not_answer_refuses_setup(self): + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "declared.json") + with open(path, "w", encoding="utf-8") as f: + json.dump({"device": RECORDING["device"]}, f) + link = FakeLink(answers=make_answers(absent_boards=(12,))) + driver = self.make_driver(link=link, declared_configuration_json=path) + with self.assertRaisesRegex( + MantisConfigurationError, "board 12: declared '0.1.8 DBG', device did not" + ): + await driver.setup() + self.assertIsNone(driver.plate_clamp) + + async def test_a_second_setup_opens_the_link_once(self): + link = FakeLink(answers=make_answers()) + driver = await self.set_up_driver(link=link) + reader = driver._reader + await driver.setup() + link.io.setup.assert_awaited_once() + link.io.set_baudrate.assert_awaited_once() + self.assertIs(driver._reader, reader) + + async def test_saved_configuration_declares_the_same_device(self): + link = FakeLink(answers=make_answers()) + driver = await self.set_up_driver(link=link, declared_configuration_json=RECORDING_MANTIS_V4) + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "mantis.json") + driver.save_configuration(path=path) + await driver.stop() + with open(path, encoding="utf-8") as f: + self.assertEqual(json.load(f)["plate_clamp"], RECORDING["plate_clamp"]) + link = FakeLink(answers=make_answers()) + await self.set_up_driver(link=link, declared_configuration_json=path) + + +class ReadTests(DriverTestCase): + async def asyncSetUp(self): + self.link = FakeLink(answers=make_answers()) + self.driver = await self.set_up_driver(link=self.link) + + async def test_reads(self): + driver = self.driver + cases = [ + (0, 1, "", lambda: driver.request_firmware_version(board=0), "4.20.6.2"), + (0, 2, "", lambda: driver.request_name(), "LiquidHandler"), + (0, 10, "0200", lambda: driver.request_motor_limits(motor=2), (-160.0, 2300.0)), + ] + for board, command, request, read, expected in cases: + with self.subTest(board=board, command=command): + result = await read() + self.assertEqual( + parse_frame(frame=self.link.written[-1])[1:], (board, command, bytes.fromhex(request)) + ) + self.assertEqual(result, expected) + + async def test_reads_refuse_unseen_motor_and_board(self): + sent_before = len(self.link.written) + with self.assertRaises(ValueError): + await self.driver.request_motor_limits(motor=3) + with self.assertRaises(ValueError): + await self.driver.request_firmware_version(board=13) + self.assertEqual(len(self.link.written), sent_before) + + async def test_event_arriving_with_the_reply_reaches_its_waiter(self): + write = self.link.write + + async def event_then_reply(data: bytes) -> int: + self.link.incoming.extend(bytes.fromhex(RECORDED_EVENT)) + return await write(data) + + self.link.io.write.side_effect = event_then_reply + waiter = self.driver._expect_event(board=0, code=0x0201) + self.assertEqual(await self.driver.request_motor_limits(motor=0), (-4000.0, 4000.0)) + payload = await self.driver._wait_for_event(waiter=waiter, timeout=1.0) + self.assertEqual(payload.hex(), RECORDED_EVENT[24:-4]) + self.assertEqual(self.driver._event_waiters, []) + + async def test_unmatched_reply_is_ignored(self): + write = self.link.write + + async def unmatched_then_reply(data: bytes) -> int: + self.link.incoming.extend(bytes.fromhex(RECORDED_UNMATCHED_REPLY)) + return await write(data) + + self.link.io.write.side_effect = unmatched_then_reply + self.assertEqual(await self.driver.request_name(), "LiquidHandler") + + async def test_failed_read_fails_the_command_in_flight_and_the_one_waiting(self): + self.link.io.read.side_effect = OSError("device unplugged") + in_flight, waiting = await asyncio.gather( + self.driver.request_name(), self.driver.request_name(), return_exceptions=True + ) + assert isinstance(in_flight, MantisConnectionLostError) + self.assertIsInstance(in_flight.__cause__, OSError) + assert isinstance(waiting, MantisConnectionLostError) + self.assertIsInstance(waiting.__cause__, OSError) + + async def test_stop_fails_the_command_in_flight_and_every_event_waiter(self): + del self.link.answers[(0, 2, b"")] + self.driver.default_read_timeout = 5.0 + waiter = self.driver._expect_event(board=0, code=0x0201) + command = asyncio.ensure_future(self.driver.request_name()) + await asyncio.sleep(0.02) + await self.driver.stop() + with self.assertRaisesRegex(MantisNotConnectedError, "stopped"): + await command + with self.assertRaisesRegex(MantisNotConnectedError, "stopped"): + await self.driver._wait_for_event(waiter=waiter, timeout=1.0) + + async def test_unanswered_read_is_resent_with_its_packet_number_then_times_out(self): + del self.link.answers[(0, 10, b"\x01\x00")] + with self.assertRaises(MantisTimeoutError): + await self.driver.request_motor_limits(motor=1) + self.assertEqual(len(set(self.link.written[-3:])), 1) + self.assertNotEqual(self.link.written[-4][4:6], self.link.written[-1][4:6]) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/formulatrix/mantis/driver/oem_config.py b/pylabrobot/formulatrix/mantis/driver/oem_config.py new file mode 100644 index 00000000000..4c4a924dc6c --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/oem_config.py @@ -0,0 +1,107 @@ +"""A Mantis unit's own values, read from the ``.config`` files of its vendor install. + +Only the values set per unit are read: a block is loaded only where its source applies to the unit. +""" + +import os +import re +import xml.etree.ElementTree as ElementTree +from typing import Any, Dict + +from .errors import MantisConfigurationError + + +def _parse_config_entries(*, path: str) -> Dict[str, str]: + """The ```` settings of one ``.config`` file, by key. + + Raises: + MantisConfigurationError: if the file is not well-formed XML. + """ + try: + root = ElementTree.parse(path).getroot() + except ElementTree.ParseError as error: + raise MantisConfigurationError(f"{path} is not a readable .config file: {error}") from error + settings = root.find("appSettings") + if settings is None: + return {} + return { + entry.attrib["key"]: entry.attrib.get("value", "") + for entry in settings.iter("add") + if "key" in entry.attrib + } + + +def _load_unit_configuration(*, oem_config_directory: str) -> Dict[str, Any]: + """The unit's blocks, as declared configurations hold them, from its vendor ``.config`` files. + + ``plate_clamp``: the lock levels, from ``Device.config`` (``PlateClampInisiatePWM``, + ``PlateClampTimer``), only for a clamp driven as a DC motor (``PlateClampController``). + ``rfid``: each input slot's antenna, from every ``RFIDAntennaId.ACC`` of ``RFID.config``, only + when it exists; with ``Device.config``'s ``RfidMultiplexerDelay`` as written. + + Args: + oem_config_directory: the unit's ``Data/Device/Configs`` folder. + + Raises: + MantisConfigurationError: if ``Device.config`` is missing, or a block's key is missing or not a + number. + """ + device_config = os.path.join(oem_config_directory, "Device.config") + if not os.path.isfile(device_config): + raise MantisConfigurationError(f"{oem_config_directory} has no Device.config") + values = _parse_config_entries(path=device_config) + + def number(*, path: str, key: str, text: str) -> float: + try: + return float(text) + except ValueError as error: + raise MantisConfigurationError(f"{path}: {key} is not a number: {text!r}") from error + + def setting(*, path: str, settings: Dict[str, str], key: str) -> str: + if key not in settings: + raise MantisConfigurationError(f"{path} has no {key}") + return settings[key] + + blocks: Dict[str, Any] = {} + if values.get("PlateClampController") == "MotorDC": + pwms = [ + number(path=device_config, key="PlateClampInisiatePWM", text=text) + for text in setting(path=device_config, settings=values, key="PlateClampInisiatePWM").split( + "," + ) + ] + timeouts_ms = [ + int(number(path=device_config, key="PlateClampTimer", text=text)) + for text in setting(path=device_config, settings=values, key="PlateClampTimer").split(",") + ] + if len(pwms) != len(timeouts_ms): + raise MantisConfigurationError( + f"{device_config} lists {len(pwms)} clamp PWM duties but {len(timeouts_ms)} timeouts" + ) + blocks["plate_clamp"] = { + "levels": { + str(level): [pwm, timeout_ms] + for level, (pwm, timeout_ms) in enumerate(zip(pwms, timeouts_ms), start=1) + } + } + + rfid_config = os.path.join(oem_config_directory, "RFID.config") + if os.path.isfile(rfid_config): + rfid_values = _parse_config_entries(path=rfid_config) + slot_to_antenna = {} + for key in rfid_values: + match = re.fullmatch(r"RFIDAntennaId\.ACC(\d+)", key) + if match is not None: + slot = int(match.group(1)) + slot_to_antenna[str(slot)] = int(number(path=rfid_config, key=key, text=rfid_values[key])) + if not slot_to_antenna: + raise MantisConfigurationError(f"{rfid_config} has no RFIDAntennaId.ACC") + blocks["rfid"] = { + "slot_to_antenna": dict(sorted(slot_to_antenna.items(), key=lambda item: int(item[0]))), + "multiplexer_delay_raw": number( + path=device_config, + key="RfidMultiplexerDelay", + text=setting(path=device_config, settings=values, key="RfidMultiplexerDelay"), + ), + } + return blocks diff --git a/pylabrobot/formulatrix/mantis/driver/oem_config_tests.py b/pylabrobot/formulatrix/mantis/driver/oem_config_tests.py new file mode 100644 index 00000000000..c18b046413a --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/oem_config_tests.py @@ -0,0 +1,154 @@ +"""Unit configuration tests: the unit's values from its vendor files, its declared file or a +recording.""" + +import json +import os +import tempfile +import unittest +from typing import Dict + +from pylabrobot.formulatrix.mantis.driver.configuration import RECORDING_MANTIS_V4 +from pylabrobot.formulatrix.mantis.driver.errors import MantisConfigurationError +from pylabrobot.formulatrix.mantis.driver.master_tests import DriverTestCase, FakeLink, make_answers +from pylabrobot.formulatrix.mantis.driver.oem_config import ( + _load_unit_configuration, + _parse_config_entries, +) + +with open(RECORDING_MANTIS_V4, encoding="utf-8") as f: + RECORDING = json.load(f) +HEAD = '\r\n\r\n \r\n' +TAIL = " \r\n\r\n" +# Lines of a Mantis v4's vendor files, as installed. +V4_DEVICE_CONFIG = ( + HEAD + + ( + ' \r\n' + ' \r\n' + ' \r\n' + ' \r\n' + ' \r\n' + ' \r\n' + ) + + TAIL +) +V4_RFID_CONFIG = ( + HEAD + + "".join( + f'\t\r\n' + for slot, antenna in ((1, 0), (2, 1), (3, 4), (4, 6), (5, 7), (6, 5)) + ) + + TAIL +) +V4_AIR_PUMP_CONFIG = ( + HEAD + + ( + ' \r\n' + ' \r\n' + ) + + TAIL +) +# Lines of a Mantis v3's Device.config, as installed; its install has no RFID.config. +V3_DEVICE_CONFIG = ( + HEAD + + ( + ' \r\n' + ' \r\n' + ' \r\n' + ' \r\n' + ) + + TAIL +) + + +def write_files(*, directory: str, files: Dict[str, str]) -> str: + for file_name, text in files.items(): + with open(os.path.join(directory, file_name), "w", encoding="utf-8", newline="") as f: + f.write(text) + return directory + + +class LoadUnitConfigurationTests(unittest.TestCase): + def test_a_v4_folder_gives_its_lock_levels_and_antennas(self): + with tempfile.TemporaryDirectory() as directory: + files = {"Device.config": V4_DEVICE_CONFIG, "RFID.config": V4_RFID_CONFIG} + blocks = _load_unit_configuration( + oem_config_directory=write_files(directory=directory, files=files) + ) + self.assertEqual(blocks, {block: RECORDING[block] for block in ("plate_clamp", "rfid")}) + + def test_a_v3_folder_gives_neither(self): + with tempfile.TemporaryDirectory() as directory: + files = {"Device.config": V3_DEVICE_CONFIG} + blocks = _load_unit_configuration( + oem_config_directory=write_files(directory=directory, files=files) + ) + self.assertEqual(blocks, {}) + + def test_keys_with_spaces_before_the_equals_sign_are_read(self): + with tempfile.TemporaryDirectory() as directory: + write_files(directory=directory, files={"AirPump.config": V4_AIR_PUMP_CONFIG}) + entries = _parse_config_entries(path=os.path.join(directory, "AirPump.config")) + self.assertEqual(entries["StopAirPumpSequenceFile"], "StopAirPump.seq.txt") + + def test_a_value_that_is_no_number_refuses(self): + text = V4_DEVICE_CONFIG.replace("0.119,0.21,0.3", "0.119;0.21;0.3") + with tempfile.TemporaryDirectory() as directory: + write_files(directory=directory, files={"Device.config": text}) + with self.assertRaisesRegex(MantisConfigurationError, "PlateClampInisiatePWM is not a"): + _load_unit_configuration(oem_config_directory=directory) + + +class UnitBlockSourceTests(DriverTestCase): + async def set_up_with(self, **kwargs): + return await self.set_up_driver(link=FakeLink(answers=make_answers()), **kwargs) + + def saved_blocks(self, *, driver, directory: str) -> Dict: + path = os.path.join(directory, "saved.json") + driver.save_configuration(path=path) + with open(path, encoding="utf-8") as f: + return {block: values for block, values in json.load(f).items() if block != "device"} + + async def test_the_folder_alone_gives_the_blocks_and_they_are_saved(self): + with tempfile.TemporaryDirectory() as directory: + files = {"Device.config": V4_DEVICE_CONFIG, "RFID.config": V4_RFID_CONFIG} + driver = await self.set_up_with( + oem_config_directory=write_files(directory=directory, files=files) + ) + saved = self.saved_blocks(driver=driver, directory=directory) + self.assertEqual(saved, {block: RECORDING[block] for block in ("plate_clamp", "rfid")}) + + async def test_a_folder_and_declared_file_that_agree_set_up(self): + with tempfile.TemporaryDirectory() as directory: + files = {"Device.config": V4_DEVICE_CONFIG, "RFID.config": V4_RFID_CONFIG} + driver = await self.set_up_with( + oem_config_directory=write_files(directory=directory, files=files), + declared_configuration_json=RECORDING_MANTIS_V4, + ) + assert driver.plate_clamp is not None + self.assertEqual(driver.plate_clamp.levels, {1: (0.119, 2000), 2: (0.21, 1000), 3: (0.3, 500)}) + + async def test_a_folder_and_declared_file_that_disagree_refuse_setup(self): + with tempfile.TemporaryDirectory() as directory: + text = V4_DEVICE_CONFIG.replace("0.119,0.21,0.3", "0.12,0.21,0.3") + write_files(directory=directory, files={"Device.config": text}) + driver = self.make_driver( + link=FakeLink(answers=make_answers()), + oem_config_directory=directory, + declared_configuration_json=RECORDING_MANTIS_V4, + ) + with self.assertRaisesRegex(MantisConfigurationError, "plate_clamp: declared"): + await driver.setup() + + async def test_neither_takes_the_recording_warns_and_saves_none_of_it(self): + with self.assertLogs("pylabrobot.formulatrix.mantis.driver.master", level="WARNING") as logs: + driver = await self.set_up_with() + self.assertTrue(any("less accurate on this unit" in line for line in logs.output)) + assert driver.rfid is not None + self.assertEqual(driver.rfid.slot_to_antenna, {1: 0, 2: 1, 3: 4, 4: 6, 5: 7, 6: 5}) + with tempfile.TemporaryDirectory() as directory: + self.assertEqual(self.saved_blocks(driver=driver, directory=directory), {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/formulatrix/mantis/driver/recordings/README.md b/pylabrobot/formulatrix/mantis/driver/recordings/README.md new file mode 100644 index 00000000000..aaa0ac22dad --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/recordings/README.md @@ -0,0 +1,44 @@ +# Recordings + +What a real Mantis reported about itself, and its unit's own values, kept as data so a simulated +one can stand in for it, and so a unit without its own values still runs. + +A configuration has two sources. What the device reports is read at setup. What it cannot report, +its unit's calibration, sits in the `Data/Device/Configs` folder of its vendor install, which setup +reads when given it: + +```python +mantis = Mantis(oem_config_directory="path/to/Data/Device/Configs") +await mantis.setup() +mantis.driver.save_configuration(path="my_mantis.json") +``` + +`save_configuration` writes both: the `device` block and the unit's blocks. From then on the file +alone carries them, read back through `declared_configuration_json`; the folder is no longer +needed. + +| block | from | a **simulated** Mantis | a **physical** Mantis | +|---|---|---|---| +| `device` | the device, at setup | takes its configuration from it | is checked against it at setup, on `FITTED_FIELDS`; setup refuses if they disagree | +| `plate_clamp` | `Device.config`: `PlateClampInisiatePWM`, `PlateClampTimer`; only for a clamp driven as a DC motor | takes its lock levels from it | takes its lock levels from it | +| `rfid` | `RFID.config`: every `RFIDAntennaId.ACC`; `Device.config`: `RfidMultiplexerDelay`, as written (`multiplexer_delay_raw`) | takes its slots from it; every slot reads empty | takes each slot's antenna; a delay other than 0 refuses the reads, as its unit is unconfirmed | + +For each unit block, setup takes: + +1. the folder's and the declared file's, when both have it and they agree; setup refuses, listing + the differences, when they do not; +2. the one source's, when only one has it; +3. the generation's recording's, when neither has it, with a warning: another unit's values, + less accurate on this one. `save_configuration` does not save a block taken from a recording. + +| file | device | +|---|---| +| `mantis_v4.json` | Mantis v4, motion firmware 4.20.6.2, with that unit's lock levels and RFID antennas (vendor software 5.5.2). The default for a simulated `Mantis`. | +| `mantis_v3.json` | Mantis v3 (vendor software 4.8.2.5): the generation only. No v3 has run setup, so its firmware is not recorded; its install has no DC-motor clamp and no `RFID.config`, so it has no unit blocks yet. | + +## What is not in one + +- **The serial number.** Never saved. +- **The device name.** Read with `request_name`; a simulated Mantis does not answer it. +- **State.** Where the arm is, the clamp's state, which chips are loaded, anything that changes + during a run. diff --git a/pylabrobot/formulatrix/mantis/driver/recordings/mantis_v3.json b/pylabrobot/formulatrix/mantis/driver/recordings/mantis_v3.json new file mode 100644 index 00000000000..739a1d57844 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/recordings/mantis_v3.json @@ -0,0 +1,5 @@ +{ + "device": { + "generation": "v3" + } +} diff --git a/pylabrobot/formulatrix/mantis/driver/recordings/mantis_v4.json b/pylabrobot/formulatrix/mantis/driver/recordings/mantis_v4.json new file mode 100644 index 00000000000..407629a7be1 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/recordings/mantis_v4.json @@ -0,0 +1,52 @@ +{ + "device": { + "generation": "v4", + "firmware_versions": { + "0": "4.20.6.2", + "10": "1.0.8", + "11": "0.1.7 DBG", + "12": "0.1.8 DBG" + }, + "motor_limits": { + "0": [ + -4000.0, + 4000.0 + ], + "1": [ + -4000.0, + 4000.0 + ], + "2": [ + -160.0, + 2300.0 + ] + } + }, + "plate_clamp": { + "levels": { + "1": [ + 0.119, + 2000 + ], + "2": [ + 0.21, + 1000 + ], + "3": [ + 0.3, + 500 + ] + } + }, + "rfid": { + "slot_to_antenna": { + "1": 0, + "2": 1, + "3": 4, + "4": 6, + "5": 7, + "6": 5 + }, + "multiplexer_delay_raw": 0.0 + } +} diff --git a/pylabrobot/formulatrix/mantis/driver/simulator.py b/pylabrobot/formulatrix/mantis/driver/simulator.py new file mode 100644 index 00000000000..4fdfca3a744 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/simulator.py @@ -0,0 +1,175 @@ +"""A simulated Mantis: the driver with no device behind it, answering from its configuration.""" + +import logging +from typing import Dict, List, Optional, Tuple + +from pylabrobot.io.ftdi import FTDI + +from .configuration import ( + DeviceConfiguration, + compute_generation, + configuration_from_json, + read_configuration, +) +from .errors import MantisConfigurationError, MantisTimeoutError +from .features.plate_clamp import CLAMPING_DONE, UNCLAMPING_DONE, PlateClamp +from .features.rfid import Rfid +from .framing import ABSOLUTE_POSITION_SENSOR_BOARD, LC3_BOARD, MOTION_BOARD, PRESSURE_BOARD +from .master import MOTORS, MantisDriver + +logger = logging.getLogger(__name__) + + +class MantisSimulationPlateClamp(PlateClamp): + """A plate clamp with no device behind it: each command sets the state it ends in, at once.""" + + def __init__( + self, *, driver: "MantisSimulationDriver", levels: Optional[Dict[int, Tuple[float, int]]] + ): + """ + Args: + driver: the simulated driver. + levels: the declared lock levels, as for ``PlateClamp``. + """ + super().__init__(driver=driver, levels=levels) + self._state = 0 # idle, as after power-on + + async def _unchecked_fw_do_plate_unclamping(self) -> None: + """Set the state to unclamping done.""" + self._state = UNCLAMPING_DONE + + async def _unchecked_fw_do_plate_clamping(self, *, pwm: float, timeout_ms: int) -> None: + """Set the state to clamping done.""" + self._state = CLAMPING_DONE + + async def request_state(self, *, attempts: int = 3) -> int: + """The simulated state.""" + return self._state + + +class MantisSimulationRfid(Rfid): + """An RFID reader with no device behind it: every slot reads empty.""" + + async def _unchecked_fw_write_ppi(self, *, start_address: int, values: List[int]) -> None: + """Select nothing: there is no multiplexer.""" + + async def _read_tag(self) -> Optional[bytes]: + """No tag: every slot is empty.""" + return None + + +class MantisSimulationDriver(MantisDriver): + """A Mantis driver with no device behind it. + + Nothing reaches a wire. The configuration comes from ``declared_configuration_json``, and the + reads answer from it; a board it leaves ``None`` does not answer. The plate clamp's state changes + at once, and every RFID slot reads empty. The device name is not simulated. + """ + + def __init__( + self, + declared_configuration_json: str, + name: str = "Mantis", + *, + oem_config_directory: Optional[str] = None, + ): + """ + Args: + declared_configuration_json: a file ``save_configuration`` wrote; the simulated device's + configuration. + name: the device name, heading its log messages. + oem_config_directory: a unit's vendor ``Data/Device/Configs`` folder, as for a device. + """ + super().__init__( + name=name, + declared_configuration_json=declared_configuration_json, + oem_config_directory=oem_config_directory, + ) + + def _build_io(self, *, device_id: Optional[str]) -> FTDI: + """Build no link.""" + return None # type: ignore[return-value] # no link: nothing reaches a wire + + def _build_rfid_io(self, *, device_id: Optional[str]) -> Optional[FTDI]: + """Build no link.""" + return None + + async def _send_command( + self, + *, + board: int, + command: int, + payload: bytes = b"", + timeout: Optional[float] = None, + attempts: int = 1, + ) -> bytes: + """Raise: a simulated Mantis sends nothing.""" + raise NotImplementedError(f"{self.name}: a simulated Mantis sends nothing") + + async def request_firmware_version(self, *, board: int = MOTION_BOARD) -> str: + """The declared firmware version of a board. + + Raises: + MantisTimeoutError: if the configuration leaves the board ``None``. + """ + self._check_index( + name="board", + value=board, + allowed=(MOTION_BOARD, PRESSURE_BOARD, ABSOLUTE_POSITION_SENSOR_BOARD, LC3_BOARD), + ) + version = self.configuration.firmware_versions.get(board) + if version is None: + raise MantisTimeoutError(f"{self.name}: board {board} does not answer") + return version + + async def request_name(self) -> str: + """Raise: the name is not part of the configuration.""" + raise NotImplementedError(f"{self.name}: the device name is not simulated") + + async def request_motor_limits(self, *, motor: int) -> Tuple[float, float]: + """A motor's declared (lower, upper) limit.""" + self._check_index(name="motor", value=motor, allowed=MOTORS) + return self.configuration.motor_limits[motor] + + async def _request_configuration(self) -> DeviceConfiguration: + """The declared configuration's ``device`` block; the generation from its boards, as a + device's is.""" + configuration = configuration_from_json( + fields=read_configuration(path=self.declared_configuration_json or "")["device"] + ) + configuration.generation = compute_generation(firmware_versions=configuration.firmware_versions) + return configuration + + def _discover_features(self) -> None: + """Build the features as a device would, simulated, and the RFID reader. + + Raises: + MantisConfigurationError: if a declared block is malformed. + """ + super()._discover_features() + if self.plate_clamp is not None and not isinstance( + self.plate_clamp, MantisSimulationPlateClamp + ): + self.plate_clamp = MantisSimulationPlateClamp(driver=self, levels=self.plate_clamp.levels) + if self.rfid is None: + rfid_block = self._unit_blocks.get("rfid") + slot_to_antenna: Optional[Dict[int, int]] = None + multiplexer_delay_raw = 0.0 # unused while slot_to_antenna is None: every read refuses + if rfid_block is not None: + try: + slot_to_antenna = { + int(slot): int(antenna) for slot, antenna in rfid_block["slot_to_antenna"].items() + } + multiplexer_delay_raw = float(rfid_block["multiplexer_delay_raw"]) + except (KeyError, TypeError, ValueError) as error: + raise MantisConfigurationError(f"malformed rfid block: {error!r}") from error + self.rfid = MantisSimulationRfid( + driver=self, slot_to_antenna=slot_to_antenna, multiplexer_delay_raw=multiplexer_delay_raw + ) + + async def _open(self) -> None: + """Open nothing: there is no link.""" + logger.info("[%s] simulated, from %s", self.name, self.declared_configuration_json) + + async def _close(self) -> None: + """Close nothing: there is no link.""" diff --git a/pylabrobot/formulatrix/mantis/driver/simulator_tests.py b/pylabrobot/formulatrix/mantis/driver/simulator_tests.py new file mode 100644 index 00000000000..5d4fd38ad36 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/simulator_tests.py @@ -0,0 +1,71 @@ +"""Simulated Mantis tests.""" + +import json +import os +import tempfile +import unittest + +from pylabrobot.formulatrix.mantis import Mantis +from pylabrobot.formulatrix.mantis.device import RECORDING_MANTIS_V4 +from pylabrobot.formulatrix.mantis.driver.errors import MantisTimeoutError + + +class SimulationTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.mantis = Mantis(simulation=True) + await self.mantis.setup() + + async def test_setup_reports_the_recorded_generation(self): + self.assertEqual(self.mantis.driver.configuration.generation, "v4") + + async def test_clamp_starts_idle_then_locks_and_unlocks(self): + clamp = self.mantis.plate_clamp + assert clamp is not None + self.assertEqual(await clamp.request_state(), 0) + await clamp.lock(level=3) + self.assertEqual(await clamp.request_state(), 3) + await clamp.unlock() + self.assertEqual(await clamp.request_state(), 6) + + async def test_stop_releases_a_locked_clamp(self): + clamp = self.mantis.plate_clamp + assert clamp is not None + await clamp.lock(level=3) + await self.mantis.stop() + await self.mantis.setup() + self.assertEqual(await clamp.request_state(), 6) + + async def test_board_the_recording_declares_absent_does_not_answer(self): + with open(RECORDING_MANTIS_V4, encoding="utf-8") as f: + recording = json.load(f) + recording["device"]["firmware_versions"]["11"] = None + recording["device"]["generation"] = "v3" + with tempfile.TemporaryDirectory() as directory: + path = os.path.join(directory, "recording.json") + with open(path, "w", encoding="utf-8") as f: + json.dump(recording, f) + mantis = Mantis(simulation=True, declared_configuration_json=path) + await mantis.setup() + self.addAsyncCleanup(mantis.stop) + self.assertEqual(mantis.driver.configuration.generation, "v3") + with self.assertRaises(MantisTimeoutError): + await mantis.driver.request_firmware_version(board=11) + + async def test_rfid_reads_every_declared_slot_empty(self): + rfid = self.mantis.rfid + assert rfid is not None + self.assertEqual(await rfid.request_tags(), {slot: None for slot in range(1, 7)}) + + async def test_reads_answer_from_the_configuration(self): + self.assertEqual(await self.mantis.driver.request_firmware_version(board=12), "0.1.8 DBG") + self.assertEqual(await self.mantis.driver.request_motor_limits(motor=2), (-160.0, 2300.0)) + with self.assertRaisesRegex(NotImplementedError, "name is not simulated"): + await self.mantis.driver.request_name() + + async def test_simulated_mantis_sends_nothing(self): + with self.assertRaisesRegex(NotImplementedError, "sends nothing"): + await self.mantis.driver._send_command(board=0, command=3) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/formulatrix/mantis/liquid_classes/__init__.py b/pylabrobot/formulatrix/mantis/liquid_classes/__init__.py new file mode 100644 index 00000000000..360c0be2db5 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/liquid_classes/__init__.py @@ -0,0 +1,6 @@ +"""The Mantis's liquid classes: one per viscosity band and chip type, in `mantis_mapping`.""" + +from .base import MantisLiquidClass +from .mapping import mantis_mapping + +__all__ = ["MantisLiquidClass", "mantis_mapping"] diff --git a/pylabrobot/formulatrix/mantis/liquid_classes/base.py b/pylabrobot/formulatrix/mantis/liquid_classes/base.py new file mode 100644 index 00000000000..44ff152eff9 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/liquid_classes/base.py @@ -0,0 +1,61 @@ +from pylabrobot.serializer import SerializableMixin + + +class MantisLiquidClass(SerializableMixin): + """Dispense tuning for one viscosity band on one chip type. + + The diaphragm delays, and the pressure and vacuum of each step. + """ + + def __init__( + self, + *, + large_fill_delay: int, + large_empty_delay: int, + small_fill_delay: int, + small_empty_delay: int, + recovery_fill_delay: int, + recovery_empty_delay: int, + chip_pressure: float, + bottle_pressure: float, + vacuum: float, + prime_pressure: float, + prime_vacuum: float, + wash_pressure: float, + wash_vacuum: float, + recovery_pressure: float, + recovery_vacuum: float, + ): + """ + Args: + large_fill_delay: the large diaphragm's fill delay. + large_empty_delay: the large diaphragm's empty delay. + small_fill_delay: the small diaphragm's fill delay. + small_empty_delay: the small diaphragm's empty delay. + recovery_fill_delay: the fill delay while drawing reagent back to the input. + recovery_empty_delay: the empty delay while drawing reagent back to the input. + chip_pressure: the chip's pressure while dispensing. + bottle_pressure: the bottle's pressure while dispensing. + vacuum: the vacuum while dispensing. + prime_pressure: the chip's pressure while priming. + prime_vacuum: the vacuum while priming. + wash_pressure: the chip's pressure while washing. + wash_vacuum: the vacuum while washing. + recovery_pressure: the chip's pressure while recovering reagent. + recovery_vacuum: the vacuum while recovering reagent. + """ + self.large_fill_delay = large_fill_delay + self.large_empty_delay = large_empty_delay + self.small_fill_delay = small_fill_delay + self.small_empty_delay = small_empty_delay + self.recovery_fill_delay = recovery_fill_delay + self.recovery_empty_delay = recovery_empty_delay + self.chip_pressure = chip_pressure + self.bottle_pressure = bottle_pressure + self.vacuum = vacuum + self.prime_pressure = prime_pressure + self.prime_vacuum = prime_vacuum + self.wash_pressure = wash_pressure + self.wash_vacuum = wash_vacuum + self.recovery_pressure = recovery_pressure + self.recovery_vacuum = recovery_vacuum diff --git a/pylabrobot/formulatrix/mantis/liquid_classes/mapping.py b/pylabrobot/formulatrix/mantis/liquid_classes/mapping.py new file mode 100644 index 00000000000..d66ce737555 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/liquid_classes/mapping.py @@ -0,0 +1,474 @@ +from typing import Dict, Tuple + +from .base import MantisLiquidClass + +# The Mantis v4's liquid classes, by (viscosity band, chip type), as its vendor install lists them. +mantis_mapping: Dict[Tuple[str, str], MantisLiquidClass] = {} + +mantis_mapping[("1 cP", "low_volume")] = MantisLiquidClass( + large_fill_delay=77, + large_empty_delay=21, + small_fill_delay=27, + small_empty_delay=21, + recovery_fill_delay=77, + recovery_empty_delay=21, + chip_pressure=12.0, + bottle_pressure=0.0, + vacuum=-12.5, + prime_pressure=12.0, + prime_vacuum=-12.5, + wash_pressure=12.0, + wash_vacuum=-12.5, + recovery_pressure=12.0, + recovery_vacuum=-12.5, +) + +mantis_mapping[("1 cP", "high_volume")] = MantisLiquidClass( + large_fill_delay=136, + large_empty_delay=84, + small_fill_delay=34, + small_empty_delay=21, + recovery_fill_delay=60, + recovery_empty_delay=30, + chip_pressure=12.0, + bottle_pressure=0.0, + vacuum=-12.5, + prime_pressure=12.0, + prime_vacuum=-12.5, + wash_pressure=12.0, + wash_vacuum=-12.5, + recovery_pressure=12.0, + recovery_vacuum=-12.5, +) + +mantis_mapping[("1 cP", "low_volume_pfe")] = MantisLiquidClass( + large_fill_delay=151, + large_empty_delay=86, + small_fill_delay=127, + small_empty_delay=75, + recovery_fill_delay=151, + recovery_empty_delay=86, + chip_pressure=15.0, + bottle_pressure=0.0, + vacuum=-14.0, + prime_pressure=15.0, + prime_vacuum=-14.0, + wash_pressure=15.0, + wash_vacuum=-14.0, + recovery_pressure=15.0, + recovery_vacuum=-14.0, +) + +mantis_mapping[("1 cP", "high_volume_pfe")] = MantisLiquidClass( + large_fill_delay=273, + large_empty_delay=84, + small_fill_delay=121, + small_empty_delay=51, + recovery_fill_delay=273, + recovery_empty_delay=84, + chip_pressure=15.0, + bottle_pressure=0.0, + vacuum=-14.0, + prime_pressure=15.0, + prime_vacuum=-14.0, + wash_pressure=15.0, + wash_vacuum=-14.0, + recovery_pressure=15.0, + recovery_vacuum=-14.0, +) + +mantis_mapping[("1 cP", "low_volume_3pfe")] = MantisLiquidClass( + large_fill_delay=151, + large_empty_delay=86, + small_fill_delay=127, + small_empty_delay=75, + recovery_fill_delay=151, + recovery_empty_delay=86, + chip_pressure=15.0, + bottle_pressure=0.0, + vacuum=-14.0, + prime_pressure=15.0, + prime_vacuum=-14.0, + wash_pressure=15.0, + wash_vacuum=-14.0, + recovery_pressure=15.0, + recovery_vacuum=-14.0, +) + +mantis_mapping[("1 cP", "high_volume_3pfe")] = MantisLiquidClass( + large_fill_delay=115, + large_empty_delay=80, + small_fill_delay=60, + small_empty_delay=40, + recovery_fill_delay=50, + recovery_empty_delay=30, + chip_pressure=16.0, + bottle_pressure=0.0, + vacuum=-15.0, + prime_pressure=16.0, + prime_vacuum=-15.0, + wash_pressure=16.0, + wash_vacuum=-15.0, + recovery_pressure=16.0, + recovery_vacuum=-15.0, +) + +mantis_mapping[("1 cP", "low_volume_hi_fi")] = MantisLiquidClass( + large_fill_delay=104, + large_empty_delay=19, + small_fill_delay=38, + small_empty_delay=21, + recovery_fill_delay=104, + recovery_empty_delay=19, + chip_pressure=12.0, + bottle_pressure=0.0, + vacuum=-12.5, + prime_pressure=12.0, + prime_vacuum=-12.5, + wash_pressure=12.0, + wash_vacuum=-12.5, + recovery_pressure=12.0, + recovery_vacuum=-12.5, +) + +mantis_mapping[("1 cP", "high_volume_hi_fi")] = MantisLiquidClass( + large_fill_delay=136, + large_empty_delay=84, + small_fill_delay=34, + small_empty_delay=21, + recovery_fill_delay=60, + recovery_empty_delay=30, + chip_pressure=12.0, + bottle_pressure=0.0, + vacuum=-13.5, + prime_pressure=12.0, + prime_vacuum=-13.5, + wash_pressure=12.0, + wash_vacuum=-13.5, + recovery_pressure=12.0, + recovery_vacuum=-13.5, +) + +mantis_mapping[("1 cP", "xl_volume_pfe")] = MantisLiquidClass( + large_fill_delay=425, + large_empty_delay=150, + small_fill_delay=425, + small_empty_delay=150, + recovery_fill_delay=425, + recovery_empty_delay=150, + chip_pressure=16.0, + bottle_pressure=0.0, + vacuum=-15.0, + prime_pressure=16.0, + prime_vacuum=-15.0, + wash_pressure=16.0, + wash_vacuum=-15.0, + recovery_pressure=16.0, + recovery_vacuum=-15.0, +) + +mantis_mapping[("2-5 cP", "low_volume")] = MantisLiquidClass( + large_fill_delay=200, + large_empty_delay=21, + small_fill_delay=125, + small_empty_delay=21, + recovery_fill_delay=200, + recovery_empty_delay=21, + chip_pressure=12.0, + bottle_pressure=0.0, + vacuum=-15.0, + prime_pressure=12.0, + prime_vacuum=-15.0, + wash_pressure=12.0, + wash_vacuum=-15.0, + recovery_pressure=12.0, + recovery_vacuum=-15.0, +) + +mantis_mapping[("2-5 cP", "high_volume")] = MantisLiquidClass( + large_fill_delay=175, + large_empty_delay=84, + small_fill_delay=70, + small_empty_delay=21, + recovery_fill_delay=175, + recovery_empty_delay=84, + chip_pressure=12.0, + bottle_pressure=0.0, + vacuum=-15.0, + prime_pressure=12.0, + prime_vacuum=-15.0, + wash_pressure=12.0, + wash_vacuum=-15.0, + recovery_pressure=12.0, + recovery_vacuum=-15.0, +) + +mantis_mapping[("2-5 cP", "low_volume_hi_fi")] = MantisLiquidClass( + large_fill_delay=176, + large_empty_delay=26, + small_fill_delay=88, + small_empty_delay=26, + recovery_fill_delay=176, + recovery_empty_delay=26, + chip_pressure=12.0, + bottle_pressure=0.0, + vacuum=-14.0, + prime_pressure=12.0, + prime_vacuum=-14.0, + wash_pressure=12.0, + wash_vacuum=-14.0, + recovery_pressure=12.0, + recovery_vacuum=-14.0, +) + +mantis_mapping[("2-5 cP", "high_volume_hi_fi")] = MantisLiquidClass( + large_fill_delay=110, + large_empty_delay=300, + small_fill_delay=31, + small_empty_delay=150, + recovery_fill_delay=110, + recovery_empty_delay=300, + chip_pressure=10.0, + bottle_pressure=0.0, + vacuum=-16.0, + prime_pressure=10.0, + prime_vacuum=-16.0, + wash_pressure=10.0, + wash_vacuum=-16.0, + recovery_pressure=10.0, + recovery_vacuum=-16.0, +) + +mantis_mapping[("2-5 cP", "xl_volume_pfe")] = MantisLiquidClass( + large_fill_delay=1000, + large_empty_delay=150, + small_fill_delay=1000, + small_empty_delay=150, + recovery_fill_delay=1000, + recovery_empty_delay=150, + chip_pressure=16.0, + bottle_pressure=0.0, + vacuum=-15.0, + prime_pressure=16.0, + prime_vacuum=-15.0, + wash_pressure=16.0, + wash_vacuum=-15.0, + recovery_pressure=16.0, + recovery_vacuum=-15.0, +) + +mantis_mapping[("6-10 cP", "low_volume")] = MantisLiquidClass( + large_fill_delay=300, + large_empty_delay=21, + small_fill_delay=200, + small_empty_delay=21, + recovery_fill_delay=300, + recovery_empty_delay=21, + chip_pressure=12.0, + bottle_pressure=0.0, + vacuum=-15.0, + prime_pressure=12.0, + prime_vacuum=-15.0, + wash_pressure=12.0, + wash_vacuum=-15.0, + recovery_pressure=12.0, + recovery_vacuum=-15.0, +) + +mantis_mapping[("6-10 cP", "high_volume")] = MantisLiquidClass( + large_fill_delay=300, + large_empty_delay=84, + small_fill_delay=85, + small_empty_delay=21, + recovery_fill_delay=300, + recovery_empty_delay=84, + chip_pressure=15.0, + bottle_pressure=0.0, + vacuum=-15.0, + prime_pressure=15.0, + prime_vacuum=-15.0, + wash_pressure=15.0, + wash_vacuum=-15.0, + recovery_pressure=15.0, + recovery_vacuum=-15.0, +) + +mantis_mapping[("6-10 cP", "low_volume_hi_fi")] = MantisLiquidClass( + large_fill_delay=233, + large_empty_delay=34, + small_fill_delay=192, + small_empty_delay=37, + recovery_fill_delay=233, + recovery_empty_delay=34, + chip_pressure=12.0, + bottle_pressure=0.0, + vacuum=-15.0, + prime_pressure=12.0, + prime_vacuum=-15.0, + wash_pressure=12.0, + wash_vacuum=-15.0, + recovery_pressure=12.0, + recovery_vacuum=-15.0, +) + +mantis_mapping[("6-10 cP", "high_volume_hi_fi")] = MantisLiquidClass( + large_fill_delay=150, + large_empty_delay=400, + small_fill_delay=62, + small_empty_delay=150, + recovery_fill_delay=150, + recovery_empty_delay=400, + chip_pressure=10.0, + bottle_pressure=0.0, + vacuum=-16.0, + prime_pressure=10.0, + prime_vacuum=-16.0, + wash_pressure=10.0, + wash_vacuum=-16.0, + recovery_pressure=10.0, + recovery_vacuum=-16.0, +) + +mantis_mapping[("11-20 cP", "low_volume")] = MantisLiquidClass( + large_fill_delay=450, + large_empty_delay=21, + small_fill_delay=300, + small_empty_delay=21, + recovery_fill_delay=450, + recovery_empty_delay=21, + chip_pressure=15.0, + bottle_pressure=0.0, + vacuum=-15.0, + prime_pressure=15.0, + prime_vacuum=-15.0, + wash_pressure=15.0, + wash_vacuum=-15.0, + recovery_pressure=15.0, + recovery_vacuum=-15.0, +) + +mantis_mapping[("11-20 cP", "high_volume")] = MantisLiquidClass( + large_fill_delay=500, + large_empty_delay=84, + small_fill_delay=200, + small_empty_delay=21, + recovery_fill_delay=500, + recovery_empty_delay=84, + chip_pressure=15.0, + bottle_pressure=0.0, + vacuum=-15.0, + prime_pressure=15.0, + prime_vacuum=-15.0, + wash_pressure=15.0, + wash_vacuum=-15.0, + recovery_pressure=15.0, + recovery_vacuum=-15.0, +) + +mantis_mapping[("11-20 cP", "low_volume_hi_fi")] = MantisLiquidClass( + large_fill_delay=399, + large_empty_delay=57, + small_fill_delay=255, + small_empty_delay=50, + recovery_fill_delay=399, + recovery_empty_delay=57, + chip_pressure=14.5, + bottle_pressure=0.0, + vacuum=-16.0, + prime_pressure=14.5, + prime_vacuum=-16.0, + wash_pressure=14.5, + wash_vacuum=-16.0, + recovery_pressure=14.5, + recovery_vacuum=-16.0, +) + +mantis_mapping[("11-20 cP", "high_volume_hi_fi")] = MantisLiquidClass( + large_fill_delay=340, + large_empty_delay=700, + small_fill_delay=95, + small_empty_delay=500, + recovery_fill_delay=340, + recovery_empty_delay=700, + chip_pressure=10.0, + bottle_pressure=0.0, + vacuum=-16.0, + prime_pressure=10.0, + prime_vacuum=-16.0, + wash_pressure=10.0, + wash_vacuum=-16.0, + recovery_pressure=10.0, + recovery_vacuum=-16.0, +) + +mantis_mapping[("21-25 cP", "low_volume")] = MantisLiquidClass( + large_fill_delay=600, + large_empty_delay=21, + small_fill_delay=400, + small_empty_delay=21, + recovery_fill_delay=600, + recovery_empty_delay=21, + chip_pressure=15.0, + bottle_pressure=0.0, + vacuum=-15.0, + prime_pressure=15.0, + prime_vacuum=-15.0, + wash_pressure=15.0, + wash_vacuum=-15.0, + recovery_pressure=15.0, + recovery_vacuum=-15.0, +) + +mantis_mapping[("21-25 cP", "high_volume")] = MantisLiquidClass( + large_fill_delay=650, + large_empty_delay=84, + small_fill_delay=350, + small_empty_delay=21, + recovery_fill_delay=650, + recovery_empty_delay=84, + chip_pressure=15.0, + bottle_pressure=0.0, + vacuum=-15.0, + prime_pressure=15.0, + prime_vacuum=-15.0, + wash_pressure=15.0, + wash_vacuum=-15.0, + recovery_pressure=15.0, + recovery_vacuum=-15.0, +) + +mantis_mapping[("21-25 cP", "low_volume_hi_fi")] = MantisLiquidClass( + large_fill_delay=540, + large_empty_delay=72, + small_fill_delay=346, + small_empty_delay=67, + recovery_fill_delay=600, + recovery_empty_delay=21, + chip_pressure=17.0, + bottle_pressure=0.0, + vacuum=-16.0, + prime_pressure=17.0, + prime_vacuum=-16.0, + wash_pressure=17.0, + wash_vacuum=-16.0, + recovery_pressure=17.0, + recovery_vacuum=-16.0, +) + +mantis_mapping[("21-25 cP", "high_volume_hi_fi")] = MantisLiquidClass( + large_fill_delay=400, + large_empty_delay=1000, + small_fill_delay=130, + small_empty_delay=700, + recovery_fill_delay=400, + recovery_empty_delay=1000, + chip_pressure=13.0, + bottle_pressure=0.0, + vacuum=-16.0, + prime_pressure=13.0, + prime_vacuum=-16.0, + wash_pressure=13.0, + wash_vacuum=-16.0, + recovery_pressure=13.0, + recovery_vacuum=-16.0, +) diff --git a/pylabrobot/formulatrix/mantis/liquid_classes/mapping_tests.py b/pylabrobot/formulatrix/mantis/liquid_classes/mapping_tests.py new file mode 100644 index 00000000000..aba7924a5c7 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/liquid_classes/mapping_tests.py @@ -0,0 +1,27 @@ +"""Liquid class table tests: its keys name chip types the RFID reader decodes.""" + +import unittest + +from pylabrobot.formulatrix.mantis.driver.features.rfid import RFID_CHIP_TYPE_CODES +from pylabrobot.formulatrix.mantis.liquid_classes import mantis_mapping + + +class MappingTests(unittest.TestCase): + def test_every_chip_type_is_one_the_rfid_reader_decodes(self): + self.assertLessEqual( + {chip_type for _, chip_type in mantis_mapping}, set(RFID_CHIP_TYPE_CODES.values()) + ) + + def test_every_band_holds_water_like_and_viscous_classes(self): + self.assertEqual( + {band for band, _ in mantis_mapping}, {"1 cP", "2-5 cP", "6-10 cP", "11-20 cP", "21-25 cP"} + ) + + def test_recovery_delays_are_their_own_values(self): + high_volume = mantis_mapping[("1 cP", "high_volume")] + self.assertEqual((high_volume.large_fill_delay, high_volume.large_empty_delay), (136, 84)) + self.assertEqual((high_volume.recovery_fill_delay, high_volume.recovery_empty_delay), (60, 30)) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/formulatrix/mantis/resource_model/Mantis.glb b/pylabrobot/formulatrix/mantis/resource_model/Mantis.glb new file mode 100644 index 0000000000000000000000000000000000000000..3e11a408f5537eddb3881cc9993febd9e8f9228a GIT binary patch literal 90424 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

jzu=*Xjqtt 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 literal 0 HcmV?d00001 diff --git a/pylabrobot/formulatrix/mantis/resource_model/__init__.py b/pylabrobot/formulatrix/mantis/resource_model/__init__.py new file mode 100644 index 00000000000..7e2fe535257 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/resource_model/__init__.py @@ -0,0 +1,45 @@ +"""The Mantis's parts, as resources, from the manufacturer's model. + +Sizes and positions come from ``manifest.json``. Each part's ``mesh`` is its ``.glb``: mm, Z up, +in the part's own frame; the base's is the main box's frame. +""" + +import json +import os +from typing import Any, Dict, Literal + +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.resource import Resource + +with open(os.path.join(os.path.dirname(__file__), "manifest.json"), encoding="utf-8") as f: + # The device frame of ``manifest.json``: its size, base file and modules. + MANIFEST: Dict[str, Any] = dict(json.load(f)["frames"]["Mantis"]) + + +def _mesh(*, file: str) -> Dict[str, str]: + """The ``mesh`` declaration for a model file in this folder.""" + return {"path": os.path.join(os.path.dirname(__file__), file), "units": "mm", "up": "Z"} + + +def chip_rack(name: str, *, side: Literal["left", "right"]) -> Resource: + """A chip rack, fitted on one side of the deck. + + Args: + name: the resource name. + side: "left" or "right", as you face the front. + """ + part = MANIFEST["modules"][f"{side}_chip_rack"] + size_x, size_y, size_z = part["size"] + rack = Resource( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category="chip_rack", + model=part["file"][: -len(".glb")], + ) + rack.mesh = _mesh(file=part["file"]) # type: ignore[attr-defined] + return rack + + +__all__ = ["MANIFEST", "chip_rack"] diff --git a/pylabrobot/formulatrix/mantis/resource_model/formulatrix_mantis_left_chip_rack.glb b/pylabrobot/formulatrix/mantis/resource_model/formulatrix_mantis_left_chip_rack.glb new file mode 100644 index 0000000000000000000000000000000000000000..5eed8d187750e3de1cb7358e34de4192df466d75 GIT binary patch literal 14920 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 J5B3xb{{yItfbReR literal 0 HcmV?d00001 diff --git a/pylabrobot/formulatrix/mantis/resource_model/formulatrix_mantis_right_chip_rack.glb b/pylabrobot/formulatrix/mantis/resource_model/formulatrix_mantis_right_chip_rack.glb new file mode 100644 index 0000000000000000000000000000000000000000..17be304f8b3e0089dbeeb7bac59d261eba61642c GIT binary patch literal 11456 zcmb_>2S60tvTgMQhMXjeG7P9AAaRHi2FV!#6$O+iAW@PiAPojklnkN*q9UN8BFYgA zoIz2^4oVc1pdc8*h=If3!@2jK``>@}-S^-B4YOzO?p;-@YVWGa-*kTC`t4cmT~n{cpW!nt?|GEj+?M zfYVd=O7c?q$0&go77!hT=5kJQx{GrXXpvFDVZkxMaeo{7#Kc4e`^Ls_h6yxhCs!Lc z8z;v_6OK+U4yN`r0}W(2INU$jk26OTDMR*X1`HKiKzvL*(vM>+9<3>S<}JYpH0f>Zq%$YwBw2XlUr^ z>M|Jr-qZgB!+(zYFHafT`e^h02T##*{8bD7T;e~+i*>z`vM{E~DD?&jQ+-It)(n`%~a=rv6ExzP^?Y=lWk|Y(q3B5j|-CMJfB= z=DP&!j17+Z_cBTBl>qB(9Kqbh)Xa%Mz%T&9P4e$NSR;=nd?R94_Frzre)MF1&USs| z*4+Ek&go~;-4~HbAGak~-e$Z8p=IS~|#cO^NWm*8P%Sz2t=lxm`O# z>{H4p{@flVEkr|#jr%!?P3@l5XMco#mS~tbq+flh@6ihd{cigmf25BnUPx+~JbnJ{ zulj_?Rj;D{xU{9FroHiyep8FvHVxZLJ9L)k|ITu$ee6eb$cx&_imTAPbu+f1z5e^_ zW@4hx-Q9VQ<0l)6ydFom^Ah#b8(ULTq+?4Swq>pyx?laSVUjX`FPP1&#V{h7_>3=B zXj;0JcTM&IT0?`Ii$?O+mBk%5&K!;U$QvwX)D^w0G3V4!v^Z8U{fjHPWA|Yp4eKo_ zdyyy^6Cte^uRdnVR8!*kzjB9tNa}gGEU4&E?q1C*n9b^ zcoS`TZi@9Ij^$^JvN9g-XP9*3!m!xY<3WCVMNLh0PIp9%GTX*SReu&1Uf@it)A05d z;7`FMM_2DU?WXkLWOQ-bx962>=C4W|mO8l)CoBNA_{T#84DAf`1w<0sAtJ;v5p}+p zp4|j}k}3BJt}*gz?pE?Fp(mG6=I)YHP$N8~J!1Ib_Dj-ami)qxd#+QtD@M)ZT-7S&$xA` zrk=QaqweS*On2X z%(dVEz%6r`~;==aju{80Gjm7pU%nylVD7Os+GLJYJ5(Hpb%O(2K<6T zaNx%iz>{rNfcZ>8fCs1z?*J^Th~7JX69JtkEnL7_(@Mih8b50Y;5@6Qip#X$F>pBm(?M@~JnHpOR#(%zQS~TJ}R6nKuP>DHOXkHdxWuA%K-3(8Eo<-UX0^ zF(FCRV;u=(0*GMAuSf#nX(5>M)`HMjVA%u_@rO@I9Qdb|zevGX!B+tL)nr8%!B6GA zh~{Ndgn0tG&8*?WgB}n*WylAW?}3}DLjY>u3@~5O7pKA~C;qIF6H7m*ZhpxmV#3lm zo2_qtkqENw{4ld)2j~crKt7raqy@R)<}Z6V@?HqigY4jQiX0cT>@tM?&nS4w3rAos zJxZu?)`I-!Aok`eFblPaAWtKM{G0_D3{8w-p!RJ80g686N?Q%T@4}c%;%8AJY(whK zp@^|Qy&&Sto?>nzfoKh04(gG>enl|`*-pwBP}A1{92S+Q0IL|)qP&;aF-VW)#f8iq zeT;d0OORSpf)r3M-y^U((g1!K!P8hUDdQNh|jJgIe)+)o# zBRsG&K^Wh4#}2ZqoniFuYb5}QBm7XW1H!oskh&%Z`DYN+%jd!YsI8S-fNiPAX6wBq z0^#m!DPXEbqFmA9=Z0(^!5LE;lnq}|0GvRBo=+Hb$^l<2;8nUz{nG^e!dx{lKigw9 z)h=MK%Z+k&3D!+@$f1Eu(a*>>KTIDbyO9^IdG+}8` zG^BUSz>)pD;O|+W3&Q=PM9^eu=UbdBascb^XQ0M69J2du0S|1@C4z^Z0JHH9lzC+U zoH^kJ>=m9+-AMuWY)w#?-UoVtR4B`j0G$X_?g2W+1^W3x)~94Osvo6RG+?WEuL737 zIsq8O6Dh`r-bZ4iuw4#D6)RAkz?h0qw#E@yLbw?~Sn}fHsb@I%E`{6!Z-hA-VKUF` zaU2<_Q;9_88834JJldSW4a}#LmH_5x&$FKMOM|z$d8z`Z8i`V{lOiA$au9IYN-@Yj zi}L|Pg#fJ2G;SEhg)#Q3hEq#{MSDA2J3u9an5-ex2Z-X7bQm2L#h2k&kt_+yMxjMa z7IK#I;#P{NG~DJQllq`*Gn^?;q4s1VNH6hccSmsy|EBDp;$E)c{9xZw;xkaY-D z-gNLz?8B;v2e8q#I|)p^;{@PJZpOUBgt2&6Lnw+yS<=)X2HvEzyb$^5cM|?pYy$+z zoFM@oNkLnpA&UacI0V24YUFr8xsne^a(u9lZ35QsiHcdvfX9QxO3MYNDBzn(+__Sq zl_CP(xgvNsany&^i&2Rr7?gz7f{>0i3MZESY{u+cT9vyBEvp@hkG63I2LE{JJoD~t zUm<-}=+3=&COThRTj}&w)m)O?xZ$lulz3(X(WXE!r9h?F)l^e_WKRqFY`+oqf$wymQlC!?Royf1CO^6 zqL~LgxMnXrAZcBtXtd*yxi&Y>=R$GE&4W4dU0C3>e;wYwA?t*>h#1DZAa_#?JGY~R zaGXF!&#@gH+L$)RU<4HhVd&rtFpNyl#=5bfAVx6*T_FYq5gEiFB;3)BX%m78+DNtp z>)_1h%tc)^m4O;GlY#!bvEt$mBtbz6RxwsFTscaL8I)p55Fb*-$5_e`Vo-vDmNFQA zXhdX4qHpxYSoj|v2|*r=O$^B(JBBhN21bH2dzB@TiqR=4F2vsqq1Bo#@P|GMLc2GER;%F^i)E0z*98R|^{4wb1b1mL)qQAl7*3$kll`F5s zJuU6}`UbdN`8Pb+eJg(G#oYO-7n`}Xr)__ij5H`4j!_A;Ed5;~g-5GBelBfn_6 z!z4M%bGl$V+pup9_QOPydN1W?b*I%KFUvY z=Il~Z)Q^FIn~uF(cjn0qNhI$wzMA^&W_O=k(%|c73tP6P-c!xq)SowcZKOIYOlJFF zxm~~j!=tWdePc?-%8tGeSUAdc<9&m|jgRy*xAuOmNF{Yg?us$bnrb&(H9TY)A@F2w z+rqctXZ{g3I=`X}BGr z9-Sz7*x5O>yg8Uw+#-`>YZHIuv6I)g1r2SN`JtxSZWk9HyXoflrJLl4wyBH*?t-_S zE`D6|ezwLaqxvPOV)e+Tym5~;UUGd8g&f-s?PW{dxR8^-Q}^DA%lA5`9j)SCZkXy? zeJZ}yyWQdEpIQ3f-Rb|xiCK-j1$2X6KsT8M;?b>#(38hM^G8`+KlrfkeCv7giTRjP z2^Lp}QEy?x*eR?ITlWY{@)AmF#1>FV5x$U4konoptvtIt`7&(x{ox8;oE!*V%pw zHY=~ZSXlm2^<0fe=R*-UN4n>`c&5?olK5$H0%djNUfK@CDL>9h)ebE(|UI@1EgTvn0-Rqi=e3x9(UV^1ENZ*)^FYIU^)tu z=6|zYsIL3H{tgG`5D}?Zrd~aBh`$M2H$U^xNPmLW;Z`T^;IY2#(#FoFgd^8RKBt?I zG_!8X9X@Sx=2P85@*`5>r?bvoQg@$e;MbD_`ywaae5u=WZf3h*S|`Vq0!`<#qkJ<{=e&a?*Q)mXl?NGzt{az*Qsjnz{AjCC{P zKh5@*=bZk)8^Mkf_(?E+I?3?de5EDjzhJWl#rWuA@`OvoUeY=81o=Mk9C@0%0lR&X z(Axa`V&Se+W1=70{GOiJG}RqHF#pM!xbcnOsCK|F@q>q2-=EN1m!+&YHLW%>B012% ztIvhHPw(4@y!;d24;~~{mF8Od+!u3HG*YNHY48x+sv4N7oS$`QwUL`c^O0vVW>+H| zF4k=MeLJbCpfq;*_R@>G4m*Zd?t8OIwV1#~X;mpmr6f>&3>IF0j^yqY#JIpJ{qpdGEc~7CTtTh>^;|0^?FKw;dS+tzYpbJn$wIk~0JndqX6wZ2 znu^~JU)ZnY$l9V_+#fpsIQ(hSm6%gZ%P+!ow;=`9qZ<1|Y=rL`BpUEM(k(5m55vpe z1*fTDUwZUg?9cWMS*~CvY?Cz;!@n5Z9=e)2$GbW+a)tZGYKtV7t=yGuNq2Awi9`pX zA$?saVqV1X&L@IUz~;tZuZUlK_+`7w@*s+#oAV}(m+G{X5%5d}kQ8DiU}ze+=cBg- z^v?Hgi&&Ff&@J@ZI@iO#F3W`;OMkRrpkqs-?w7!<&#i;(Bd*{k$WNU|ZwWB7R|~z& zv|A9t^O7X66-eUHazGBf%zAX2h4c%p5(n>n1mQb^u)LCjm0cC0Fg-PxesY3%XE`?d zYJ=~N{v1_|x&%NVb)Lon06R9>A;7D1C#Zi8``c-G*RRA<$g6kFnjfOqvw4p z?^-Bxk%Fdt;QodLybXpkD9sx_-N)0664gG6tf)y4!4efA)P_M|(VhrF>qi@)Co5tz zN461QVRty-S?FaNq-PH%h#-dKBv5lFDe%Cilo#e6MH9jMmnRghT@GExMIcd;2#r$* zU&?cU;0Za0kYhBv^o@WEwjbpNmeyV#;C2#PeBB$}O2WS&I_YC8VaCb=W~A33wCG6O zDLLi5*!wnaHBK7Ms1j=1sh0YC(TBzB^^(1V-4ftj(M*C&#E$-cBwheU#oVSbIDhRK z*P`Gy^P9EX7klq56n7wV7nX2=!W8m;RGJH@Z>$k4iT%u*lB>3S6a!X)Kj3P+05Yow z6#?D{qni`JaK`hiWDF121l$|Z3W#G+JJUBvgh@4+I%o%P{m2t3)Tbs zm#`xNT69U-ZnfGl)up}zabBh%tZpF+*{7Fda2pfAJtNgWu7=V1e2y#ZCog|IAizx= zQD;9b5EZ%KN(M%=s?R(5JR;VQ(tzoK%^-S1`n3pj1WN9JCg4pIDO})O^c}Gq&fbol_kuJ)433cYaaITB zVuY7*LcQmyW(Se=7=t2dsNqT6bJ&z=Rpq6979~u}G?NIcr5zx?V2&$M$HiMJ28lcWuBm8NBJ)&)x z@v`t+?vk0+TnCIQ;_er`7KU;Hcn6Gd=0I6oFX1=RP#u5P`NeUSm;5m~A*g4n!jSlh zilxBZw-%~u)e%=LLczOywxEni&Bw~zg+T2Sk-1uz0I~*x5ZNEeL>NCKT5XFkSnm~p zw=t+B={X8O+#gxMo1F^?jo@ab$rG2_Ddl7@R2QQ?k`Od_` zt#rg%B`P{XW4eM5>1D=y`zMH9LC0{$vr_aE1GRT8s>(^~xa%N_$O$})w5^#;q63xw z)*4vnYf;M0bV&g$#ie<`e5W$dD_4T>Wic+;TNdVoR_~Nj$`a%Im)rPoK5ufB7a0-X zk{GoggWm`3SrOnw9x&-bM4Woy#FI*Z?-U{{=M&(&pQ5K-bo5*{@e{{D ziYUSlCz{_Afqj|^uz8$!u~?n*mFCpxAUsD5A}8yHfGM!|2oe4`9`3p49f37H3Zp|V za9wrO&k$5L>w)lfQCM<73Mx&?PEvZrFeqPwSjLWc9$+OAnd+59CMNuV8-#(%Q>PqG z<526Y%IAil@QH}8U=Wyur!I(jc5lI`{71QP-Xeix&uO1UFY%uW0^zhLkbOzg6h^Hl zwJ`iwDT!IMpTvwLmUR%hcNz+S46M4Y8S-7303W&_<1-0$8Pxx@dASmdA;g1-omvaMR*8Gh*ahenR z{*A!QKtCBW$AAR{n8`(Vff~h9^kaZf9#CtNpv6fFFRn-X1;B{hqO#$;nCqcUi?`J( zue`QzJmAbXFWAUERYJ1fYykM>2w-Q8cS)|_!;yd*$)*ZZpsyB(B8iqIP$nydu{=M* z%raGA7^5mLIxT|i>Y-|VVEPWQVpbzQ9$?$Ab0io|85_u;k8+``jYP^=NfBxQUG*49 zC{n-qz9Cr>ftX9?6dL$SbC!=B%cw|3_Zr>hgl#?bMxcHSF9Oing~5Q;s@bjP0y30s zq|7b?E~14B3n%f#XC*nExA)W7x^un>(j3#Qt^1SnRSD1@`T$iH?4;Py=X>5LwOX!x zYFRNr3@Neiczq6^qmW#*7AKjCUY>uDrB?#^es`R zOQF@0b&*GviAKiDgYYYX?swZmVn>b#y^{$&`oyx~wRtW-peGL%IIKzKc=;b`H(#qA|DfFj0c+j4 zhCE1WA~X>kuvly^(anumPIN0TN1a5@$E}=Bq@xpVbYl73oIEx+i?J;T4qQe=dCW~7 zn?-+NgjuW{Nzu`CHl03Kj?Oz^Y`Q#F&PKB3Zm6NF(F5s1ZhUk*x)6N{osUj&OW?G0 zIl83V|Bzx^SV%ysmFWvay22tz)Vk5phmBw(c=A}{9D9ykUQVa8(fLF=yWGu)7)EHt zT9Km;SQBBMY=H?;!F~=WD2jY0>I{7^+ChcNBHm(YIlV&n< zu!-A$ouL?F5pMH8|EZjtYFQ9)Z_eyLOn8@jQTY#{)sOEdJ$>{{ zi4!p7s}XYe&SLcb<={hA(_sgJ(;XFiTLzx3vdsSdh?{`Trk2W2v1v(iBI{cg+LzzN z-(PQE)705(UiaTt``@!(q8-+$`|kJ053Ts(xATHU*gnw9>CXfdj{!z;V0@kHI81beSr?O*QY)xJLz zlIGxBr?W)Y>&>g(1FY(th^|w;2CXww`XRMV!>8#3J5G*oQ!*gi*}3&z*;L)uJU#B1 z+kIcZ=aoTkMZ=?cO1@_2H&$%HMmHrs3#}^_IfHxszs3eiezXPmXYN&1@kejjrp70k z?7n;@^_bAzuZ^u6MI!nwCpO!Y%UXvOBQ^bao41v7SsbkDHg;RbBk1ij)j9Ft{<>@n zk+zhRNo{`&#PC&@88kHa6tO=&i4d7Q_UY+~wgJhkv4;jDEk9ebZnQZlm8_WV5S1EQ z%V()4twYzax8M@5F=e>B2(<)%u+Y5swlE~{ZB~-Dv$^R2RdRb~k9x zl4z}V-A#<*XBY1h+<9AEu?oh|=K9Ue^`iTRs+0D&MgDQ7j>qQLZ{}LpM?6ae*Hi7% znWqPJMZ5JA=Bx)vqDkwo?rj}T*w^nCzmiedX+9CQ{*4vOwc`GUU+cBB9OHDieDe9= zrgLRun0=)D`H!c$8_w#a8m?UtaO#@+{+7;=Em}u6^bgLeZ+v9qw!W#@&r zY$#qk&`@^v=~|mV3Z2sJ7&7Z5zr@IX9rK|K`>GF4HC~x3@A0zM(>HsV=t56>W9q<{ z!*9CdgsoA-zsMAgvmeK5>ff#X1m|pSeMsVaFf9DQV>Nj+-1y9M1=00&Yd7=+EK8Xw za*(!vXughXUrcSv(StWtUj~p2gvdU=KkuQHs^Mk9W zD~?h1&l|U9JqqORv*2>2y~v8OcFf0%_%)vJe^{P!^MV7uHpJCu&r(lO`}U2Gw|mNc ni?fd|{+x2hbF%kq(uk;sg`JM+?SH~8pq}~{BiWTatS1mXD literal 0 HcmV?d00001 diff --git a/pylabrobot/formulatrix/mantis/resource_model/manifest.json b/pylabrobot/formulatrix/mantis/resource_model/manifest.json new file mode 100644 index 00000000000..a684af2743c --- /dev/null +++ b/pylabrobot/formulatrix/mantis/resource_model/manifest.json @@ -0,0 +1,43 @@ +{ + "note": "Modular. The device frame is the main box's: origin at the body's front-left corner, at the bottom of the feet. The base file is authored in that frame and also draws the side lobes outside the box. Every other file is authored in its own resource's frame, its lowest corner at the origin, so declaring it on that resource needs no offset. `measured_at` records where a part sits in the device frame in the pose the manufacturer's model is drawn in, in millimetres.", + "units": "mm", + "up": "Z", + "frames": { + "Mantis": { + "base": "Mantis.glb", + "size": [ + 175.0, + 212.0, + 136.0 + ], + "modules": { + "left_chip_rack": { + "file": "formulatrix_mantis_left_chip_rack.glb", + "size": [ + 65.06, + 121.9, + 13.53 + ], + "measured_at": [ + -55.53, + 51.12, + 183.97 + ] + }, + "right_chip_rack": { + "file": "formulatrix_mantis_right_chip_rack.glb", + "size": [ + 44.42, + 58.85, + 13.53 + ], + "measured_at": [ + 167.06, + 82.93, + 184.0 + ] + } + } + } + } +}