From 159c54c6bf5be8ef37c3b790d953bdcd34186a35 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 12 Nov 2026 01:36:01 +0000 Subject: [PATCH 1/2] v1 `Mantis`: bring up the arm and Z, and a stop that halts the device On a v4 the arm is built when board 11 answers. Its calibration, each arm motor's offset, encoder reference and encoder correction, comes from the unit's configuration and correction files or a saved configuration, never from a recording: without it, bringing the arm up refuses before anything is sent. The kinematics are pure maths in arm_kinematics: device units, inverse and forward kinematics, the encoder correction. setup() brings the arm and Z up command for command as in the recorded v4 startup: energise, declare from the absolute encoders, following error, move to idle, home Z on its home-done event, declare and move to idle twice, home Z. It moves only from near idle; elsewhere it refuses and unpowers the arms. setup(skip_arm=True) reads only. stop() aborts the sequencer, stops every motor, parks an arm brought up once every motor stopped (Z up alone and read back by its encoder, then queued as in the recorded close, Z down to 20 mm), then releases the clamp; each step in its own try. A move that fails part way stops the motors it started and logs where their encoders read. On a v4 the LED bar shows the bring-up in setup's colour. The Z column and the arm's links join the resource model, placed from a pose read. The simulation brings a simulated arm up. Co-Authored-By: Claude Opus 5.5 --- docs/api/pylabrobot.formulatrix.rst | 5 + .../formulatrix/mantis/hello-world.ipynb | 99 ++- pylabrobot/formulatrix/mantis/__init__.py | 2 + pylabrobot/formulatrix/mantis/design.md | 119 ++- pylabrobot/formulatrix/mantis/device.py | 85 ++- .../formulatrix/mantis/driver/__init__.py | 2 + .../mantis/driver/arm_kinematics.py | 230 ++++++ .../mantis/driver/arm_kinematics_tests.py | 117 +++ .../mantis/driver/configuration.py | 4 +- .../formulatrix/mantis/driver/features/arm.py | 668 +++++++++++++++++ .../mantis/driver/features/arm_tests.py | 704 ++++++++++++++++++ .../mantis/driver/features/led_bar_tests.py | 9 +- .../driver/features/plate_clamp_tests.py | 10 +- .../mantis/driver/features/rfid_tests.py | 2 +- .../formulatrix/mantis/driver/master.py | 197 ++++- .../formulatrix/mantis/driver/master_tests.py | 15 +- .../formulatrix/mantis/driver/oem_config.py | 62 +- .../mantis/driver/oem_config_tests.py | 105 +++ .../mantis/driver/recordings/README.md | 3 + .../formulatrix/mantis/driver/simulator.py | 153 +++- .../mantis/driver/simulator_tests.py | 79 +- .../mantis/resource_model/__init__.py | 48 +- .../formulatrix_mantis_z_column.glb | Bin 0 -> 143396 bytes .../mantis/resource_model/manifest.json | 96 ++- 24 files changed, 2700 insertions(+), 114 deletions(-) create mode 100644 pylabrobot/formulatrix/mantis/driver/arm_kinematics.py create mode 100644 pylabrobot/formulatrix/mantis/driver/arm_kinematics_tests.py create mode 100644 pylabrobot/formulatrix/mantis/driver/features/arm.py create mode 100644 pylabrobot/formulatrix/mantis/driver/features/arm_tests.py create mode 100644 pylabrobot/formulatrix/mantis/resource_model/formulatrix_mantis_z_column.glb diff --git a/docs/api/pylabrobot.formulatrix.rst b/docs/api/pylabrobot.formulatrix.rst index 544915f0a9b..5c237c97898 100644 --- a/docs/api/pylabrobot.formulatrix.rst +++ b/docs/api/pylabrobot.formulatrix.rst @@ -15,10 +15,13 @@ Mantis MantisDevice driver.master.MantisDriver driver.simulator.MantisSimulationDriver + driver.simulator.MantisSimulationArm driver.simulator.MantisSimulationPlateClamp driver.simulator.MantisSimulationPump driver.simulator.MantisSimulationRfid driver.simulator.MantisSimulationLedBar + driver.features.arm.Arm + driver.arm_kinematics driver.features.plate_clamp.PlateClamp driver.features.pump.Pump driver.features.rfid.Rfid @@ -40,4 +43,6 @@ Mantis driver.errors.MantisCoordinateError driver.errors.MantisPrimeError resource_model.chip_rack + resource_model.z_column + resource_model.arm_link liquid_classes.MantisLiquidClass diff --git a/docs/user_guide/formulatrix/mantis/hello-world.ipynb b/docs/user_guide/formulatrix/mantis/hello-world.ipynb index 050340130ce..9f0d1825603 100644 --- a/docs/user_guide/formulatrix/mantis/hello-world.ipynb +++ b/docs/user_guide/formulatrix/mantis/hello-world.ipynb @@ -7,7 +7,7 @@ "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, motors and pressure sensors, locks and unlocks the plate clamp, reads which chip sits in each input slot, and shows colours on a v4's LED bar. Nothing else in this driver moves the device, switches the pump on or opens a valve." + "The Mantis is a liquid dispenser from Formulatrix that dispenses through exchangeable chips. PyLabRobot connects to it, identifies it, reads its firmware versions, name, motors and pressure sensors, brings the arm and Z up and parks them at stop (v4), locks and unlocks the plate clamp, reads which chip sits in each input slot, and shows colours on a v4's LED bar. Nothing else in this driver moves the device, switches the pump on or opens a valve." ] }, { @@ -25,7 +25,7 @@ "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", + "This driver has run on one Mantis v4, on macOS and on Windows; no v3 has run it yet. The arm's bring-up and the stop that parks it have not yet run on a device through this driver; run them with the head clear and someone at the power switch. `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", "```" ] }, @@ -41,12 +41,13 @@ "| Generations | v3 and v4, told apart at setup |\n", "| Reads | firmware versions, device name, motor limits and state, the arm's absolute encoders (v4), line pressures and the pressure board's state |\n", "| Interface | USB, an FTDI bridge (VID 0x0403 / PID 0x6010): interface B for commands, interface A for the RFID reader |\n", - "| Sequencer | queues PPI writes and waits until each is played; internal, for the features that act |\n", + "| Sequencer | queues PPI writes and moves of the three motors, and waits until each is played; internal, for the features that act |\n", + "| Arm (v4) | brought up at setup from where its absolute encoders read; Z homed; parked at idle at stop |\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", - "| LED bar (v4) | one colour, one per LED, or an animation; shows setup, off at stop |\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, switching the pump or a pressure controller, chips, dispensing |" + "| LED bar (v4) | one colour, one per LED, or an animation; shows setup, with the arm's bring-up; off at stop |\n", + "| Unit configuration | the clamp's lock levels, each slot's RFID antenna and the arm's calibration, from the unit's configuration folder or a saved file; the first two else from a recording of its generation |\n", + "| Not supported yet | moving the arm to a position, switching the pump or a pressure controller, chips, dispensing |" ] }, { @@ -80,9 +81,9 @@ "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", + "Some of a Mantis's values are set per unit and cannot be read off the device; they sit in the unit's `.config` files, 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, each input slot's RFID antenna and, on a v4, the arm's calibration: each arm motor's offset, absolute encoder reference and encoder correction.\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. A file saved by an earlier version still loads: a value it lacks comes from the folder, else the recording, with one warning naming it." + "Without the clamp's levels or the antennas, a recording of a unit of the same generation supplies them, with a warning: the Mantis runs, less accurately. The arm's calibration never comes from a recording: without it the arm is not brought up and setup refuses. Where the folder and a saved file both hold a value, they must agree, or setup refuses. A file saved by an earlier version still loads: a value it lacks comes from the folder, else the recording, with one warning naming it." ] }, { @@ -104,7 +105,7 @@ "source": [ "## Setup\n", "\n", - "`setup()` opens both links, reads the firmware versions, the motor limits and the pressure sensors' calibration, takes the unit's values and sets up the RFID reader. It does not move anything. On a v4 the LED bar shows the setup, green for a second at its end, then the idle animation." + "`setup()` opens both links, reads the firmware versions, the motor limits and the pressure sensors' calibration, takes the unit's values and sets up the RFID reader. On a v4 it then brings the arm and Z up, as in the recorded v4 startup: it energises the arm motors, declares them where their absolute encoders read, moves them to idle, homes Z, and moves to idle again. It does so only if the head stands near idle, in front of or behind it; from anywhere else it refuses, the arm unpowered: park the arm at idle first. `setup(skip_arm=True)` reads only and moves nothing. On a v4 the LED bar shows the setup, the arm's bring-up with it, green for a second at its end, then the idle animation." ] }, { @@ -145,7 +146,7 @@ "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, every declared RFID slot reads empty, and its LED bar takes every command. Its motors read idle at 0; its pump reads off, every line at 0, with the recorded sensor calibration. Its sequencer, once started, plays each queued item at once. The device name is not simulated." + "`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, every declared RFID slot reads empty, and its LED bar takes every command. Its arm stands at idle, with no offsets unless the file declares the arm's calibration, and setup brings it up at once; its pump reads off, every line at 0, with the recorded sensor calibration. Its sequencer, once started, plays each queued item at once. The device name is not simulated." ] }, { @@ -367,6 +368,82 @@ "await mantis.driver.request_absolute_encoder_position(motor=0)" ] }, + { + "cell_type": "markdown", + "id": "mantis-arm-1", + "metadata": {}, + "source": [ + "## Arm (v4)\n", + "\n", + "Motors 0 and 1 each turn a pair of links; the two long links meet at the head. The arm frame has its origin where motor 0 turns its links, in mm; at idle the head stands at about (15.0, 31.2). Each motor's position, as the controller holds it: the arm motors in degrees, Z in mm down from its home flag." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mantis-arm-2", + "metadata": {}, + "outputs": [], + "source": [ + "await mantis.arm.request_joint_position()" + ] + }, + { + "cell_type": "markdown", + "id": "mantis-arm-3", + "metadata": {}, + "source": [ + "Each arm motor's angle from its absolute encoder, corrected with the unit's calibration, and where the head is:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mantis-arm-4", + "metadata": {}, + "outputs": [], + "source": [ + "await mantis.arm.request_absolute_joint_position()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mantis-arm-5", + "metadata": {}, + "outputs": [], + "source": [ + "await mantis.arm.request_head_position()" + ] + }, + { + "cell_type": "markdown", + "id": "mantis-arm-6", + "metadata": {}, + "source": [ + "Whether the head can reach a point, in the arm frame; nothing is clamped. The bring-up and the stop move at the arm's defaults, in degrees/s and mm/s:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mantis-arm-7", + "metadata": {}, + "outputs": [], + "source": [ + "mantis.arm.is_xy_reachable(x=15.0, y=60.0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "mantis-arm-8", + "metadata": {}, + "outputs": [], + "source": [ + "mantis.arm.default_arm_speed, mantis.arm.default_z_speed" + ] + }, { "cell_type": "markdown", "id": "pressure", @@ -749,7 +826,7 @@ "source": [ "## Stop\n", "\n", - "Stop the visualizer and close the link." + "Stop the visualizer and close the link. On a v4, `stop()` first aborts the sequencer and stops every motor; an arm brought up is then parked at idle and Z lowered to 20 mm, as in the recorded close, unless the head is away from idle. Then the plate clamp is released." ] }, { diff --git a/pylabrobot/formulatrix/mantis/__init__.py b/pylabrobot/formulatrix/mantis/__init__.py index 090428c3ebb..1429dc6ede0 100644 --- a/pylabrobot/formulatrix/mantis/__init__.py +++ b/pylabrobot/formulatrix/mantis/__init__.py @@ -9,6 +9,7 @@ PRESSURE_CONTROLLER_STATUS_DESCRIPTIONS, PRESSURE_CONTROLLERS, SENSOR_ALARM_CODE_DESCRIPTIONS, + Arm, ChecksumError, CommandRefusedError, DeviceConfiguration, @@ -25,6 +26,7 @@ MantisNotConnectedError, MantisPressureError, MantisPrimeError, + MantisSimulationArm, MantisSimulationDriver, MantisSimulationLedBar, MantisSimulationPlateClamp, diff --git a/pylabrobot/formulatrix/mantis/design.md b/pylabrobot/formulatrix/mantis/design.md index 4554c42545b..5bbc0b6a0e1 100644 --- a/pylabrobot/formulatrix/mantis/design.md +++ b/pylabrobot/formulatrix/mantis/design.md @@ -8,15 +8,19 @@ last section lists what is open and where the code does not follow a rule yet. ``` device.py MantisDevice, a Resource, and the Mantis() factory. setup() / stop(), - the driver, the parts. + the driver, the parts; places the arm's links from a pose read. driver/master.py MantisDriver. Owns the link, the reader, reply matching, events, the sequencer, discovery and the saved configuration; reads the - motors; shows setup and stop on the LED bar. + motors; halts the device at stop; shows setup and stop on the LED + bar. +driver/arm_kinematics.py The arm's geometry and units: device units, inverse and forward + kinematics, the absolute encoder's correction. Pure maths. driver/events.py The events the device sends unasked: their codes, what each carries and how it is logged. -driver/simulator.py MantisSimulationDriver, MantisSimulationPlateClamp, - MantisSimulationPump, MantisSimulationRfid, MantisSimulationLedBar. - Answer from the declared configuration; nothing reaches a wire. +driver/simulator.py MantisSimulationDriver, MantisSimulationArm, + MantisSimulationPlateClamp, MantisSimulationPump, MantisSimulationRfid, + MantisSimulationLedBar. 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. @@ -24,7 +28,7 @@ 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, Pump, +driver/features/ One class per thing the device is fitted with: Arm, PlateClamp, Pump, Rfid, LedBar. 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. @@ -49,10 +53,10 @@ is the exception: `discover` builds it when it answers its ping on interface A, 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; -`pump` only when board 10 answered. `led_bar` is gated by the generation: built only on a v4 whose -board 12 answered, never on a v3, whose install sets no LED count. A difference between v3 and v4 -is expressed there, by which feature is built, never by a branch on the generation inside a -command. +`pump` only when board 10 answered; `arm` only when board 11 answered (a v4). `led_bar` is gated +by the generation: built only on a v4 whose board 12 answered, never on a v3, whose install sets +no LED count. 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, and the pressure board's @@ -78,7 +82,15 @@ naming each and where it came from. A later block or field is optional the same `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, `led_bar.number_of_leds` where `Device.config` sets `NumberOfLed`. +exists, `led_bar.number_of_leds` where `Device.config` sets `NumberOfLed`, `arm` where the unit +declares the arms' absolute encoder references (a v4), its encoder correction from the `Data` +folder. + +**M4d. Motion calibration never comes from a recording.** A block that calibrates motion +(`MOTION_CALIBRATION_BLOCKS`: `arm`) comes from the unit's folder or its declared file only; no +recording holds one. Without it, nothing that needs it is sent: `Arm.initialize` refuses, and so +does `setup` unless `skip_arm=True`. Factory files (encoder corrections, taught sequences) are +never committed; tests use synthetic tables. Which vendor values are the unit's own, from a v3 and a v4 install side by side: @@ -127,7 +139,9 @@ sends it once (`_unchecked_fw_queue_write_ppi`, command 65). A reply of -1 free queue was full and the item not queued: it is sent again under the next item, for at most `default_queue_full_timeout`; a non-zero error word raises `InvalidQueueItemError`. `_wait_for_queue_item` waits for the item's progress event with nothing left queued; the reader -keeps the last such item, so a report that came with the reply is not missed. +keeps the last such item, so a report that came with the reply is not missed. A move of all three +motors is queued the same way (`_unchecked_fw_queue_move_item`, command 68, `_queue_move_item`); +a motor sent all zeros does not move. As seen on a v4: a duration is in steps of 1 ms. A sequencer not started takes 200 items (`SEQUENCER_QUEUE_SIZE`), then answers -1; a clear frees the slots. A frame written twice under one packet number is queued twice, so a queue write is never resent. A clear empties the queue @@ -141,7 +155,9 @@ bar's dispense progress follows; opening the link clears it. **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. +suffix on names in default units. The arm converts in `arm_kinematics`: 200 device units per motor +revolution, degrees on the arms and mm on Z (down from its flag), each rate rounded up half a step +as on the wire. **M7. A range is checked before anything is sent.** (STAR P5.) A read accepts only the indices seen on a device (`MOTORS`, `ABSOLUTE_ENCODER_MOTORS`, the boards, `PRESSURE_CONTROLLERS`); @@ -151,6 +167,11 @@ seen on a device (`MOTORS`, `ABSOLUTE_ENCODER_MOTORS`, the boards, `PRESSURE_CON 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. +`Arm.initialize` matches the recorded v4 startup command for command: energise the arms, declare +them three times where their absolute encoders read, set their following error, clear Z, declare, +move to idle, home Z (waiting for its home-done event), twice declare and move to idle, home Z. A +move reads each motor's status until none moves; a fault bit raises `MantisMotionError`. Idle is +`POST_HOMING_POSITION` (the same on every unit seen) less the unit's offsets. **M9. Names say what kind of thing they are.** (STAR P10.) @@ -168,10 +189,16 @@ Commands, boards and fields are named for what they do; no vendor class, method **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, releases -each built feature, turns the LED bar off, 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. +its own `try`, and the first fault is the one raised. `stop` fails what waits on the link; on a v4 +it aborts the sequencer, stops each motor at its default deceleration and, if every motor stopped, +parks an arm brought up: Z up on its own, read back by its encoder (command 84), never the demand +position (command 27); then queued, as in the recorded close, the arms, both, then Z down to +`SHUTDOWN_Z` at idle. Then it releases each built feature, turns the LED bar off and closes the +link. A move that fails part way stops the motors it sent a move and logs where their encoders +read. A `lock` that does not report clamping done releases the clamp before raising, and `stop` +releases a clamp that does not read released. A failed `Arm.initialize` unpowers the arm motors +again, and a failed setup's `stop` does not park them. 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. @@ -198,10 +225,12 @@ feature has a simulation subclass that overrides its `_unchecked_fw_*` calls and above the wire: `MantisSimulationPlateClamp` reads 0 until a command sets clamping done or unclamping done; `MantisSimulationRfid` reads every slot empty; `MantisSimulationPump` reads off, every line at 0, with the recorded calibration; `MantisSimulationLedBar` takes every command and -waits no hold. The motors read idle at 0, as after power-on; a motor read the configuration does not -hold is not simulated. The sequencer, on the driver, holds `SEQUENCER_QUEUE_SIZE` items, -stopped and empty after setup, and once started plays each item at once; stop and abort keep what -is queued. +waits no hold. `MantisSimulationArm` stands at idle with its absolute encoders reading there, so it +needs no encoder correction; its offsets are the declared ones, else 0. Each command moves it at +once, and the motor reads answer from it; without it the motors read idle at 0, as after power-on. +A motor read the configuration does not hold is not simulated. The sequencer, on the driver, holds +`SEQUENCER_QUEUE_SIZE` items, stopped and empty after setup, and once started plays each item at +once, a queued move moving the simulated arm; stop and abort keep what is queued. **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 @@ -213,7 +242,8 @@ master's links. Interface B carries the command frames; interface A, the RFID re **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. +keep them in step. A driver with no resource tree still drives. The Z column carries the arm's +four links (`LinkBody`), turned to the joint angles setup reads after the bring-up. **M19. The LED bar shows what the device does, never at the cost of it.** An action runs inside `_show_action`: the bar shows its colour, the enclosing action's when it ends, the idle animation @@ -221,13 +251,13 @@ when none is left, and `error_color` after one that failed. Each colour is an at (`setup_color`, ...), checked when it is set and read when it is shown; each bar has its own. `show_progress` lights a share of the LEDs from the left in `dispense_progress_color` and sends nothing when that many are already lit: each frame holds the link about 62 ms on a v4, so an action -shows at most `number_of_leds` frames. Setup shows blue, then green for `SETUP_DONE_HOLD`, then -idle; `stop` turns the bar off and shows no colour first: one sent there was replaced within -milliseconds, too briefly to see. An LED bar fault only warns: it never fails or -masks the action. `show_actions=False` shows no action, only the idle animation from setup on. A -colour change costs about 0.1 s of link time (two commands), so an action that is quick on its own -shows none: the plate clamp's lock and unlock, where a colour took a release from 0.21 s to 0.34 s -on the recorded v4. +shows at most `number_of_leds` frames. Setup shows blue, the arm's bring-up with it, then green for +`SETUP_DONE_HOLD`, then idle; `stop` turns the bar off and shows no colour first: one sent there was +replaced within milliseconds, too briefly to see. The arm's moves show no colour of their own. An +LED bar fault only warns: it never fails or masks the action. `show_actions=False` shows no action, +only the idle animation from setup on. A colour change costs about 0.1 s of link time (two +commands), so an action that is quick on its own shows none: the plate clamp's lock and unlock, +where a colour took a release from 0.21 s to 0.34 s on the recorded v4. ## Open, and where this is not consistent yet @@ -237,9 +267,11 @@ on the recorded v4. 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. Until then the motor - reads sit on the driver and return the device's units. +4. **The arm model is partial.** The links are drawn as boxes: the manufacturer's link models are + compressed in a form not converted yet. The Z column is drawn with Z at 0: how far it travels + with Z is not measured. Which side of the device motor 0's links sit on is taken from the name + of a link length in the unit's files, not seen on a device. The motor reads stay on the driver + in the device's units; the arm converts them. 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 @@ -292,3 +324,26 @@ on the recorded v4. item is never reported played. 26. **A sequence-stopped event's second word is not confirmed.** It read 0 after a stop that let the queue play out and 2 after an abort that left two items queued, as if it counts them. +27. **The arm's bring-up and stop have not run through this driver.** Their commands and order + match the recorded v4 startup and close; a device run settles the rest. Before it parks, stop + waits for the motors to stop, refuses on a fault and reads where the head is from the absolute + encoders; no fault is cleared, so whether a stopped motor plays a queued move is not seen. Z + rises on its own before the arms move, unlike the recorded close's one queue; it must read + within `Z_TRAVERSE_TOLERANCE` (0.1 mm) of its flag by its encoder, twice the largest gap read + between Z's encoder and its demand position. Z's encoder has been read at rest only, never right + after a move. +28. **The arm is moved to idle only from near idle.** Without the unit's zones, setup and stop move + to idle only with the head within `FRONT_ZONE_X_RANGE` (the Front zone's x bounds, the same on + every unit seen); elsewhere they refuse. That admits the strip below the Front zone (y under + 31 mm), where a recorded startup began (y about 28 mm): that startup moved X, Y and Z at once + from there; this driver raises Z first, as from the Front zone. The zones' routes lift this. +29. **The absolute encoder's wrap and the forward kinematics' choice are not observed.** A motor's + position is taken one span lower above 100 (motor 0) or 120 (motor 1) device units, and of the + two solutions the head's is the one of greater y; no reading near a wrap has been seen. +30. **A slip before Z homes refuses.** If an arm reads a following error just before Z homes, + setup raises instead of homing; no recording shows what should follow. +31. **Unpowering the arm (command 19, open loop) is unrecorded.** A failed bring-up sends it; its + reply has not been seen. +32. **The encoder correction files are found two levels above `oem_config_directory`.** That + argument still names `Data/Device/Configs`; the corrections sit in `Data/System`. + diff --git a/pylabrobot/formulatrix/mantis/device.py b/pylabrobot/formulatrix/mantis/device.py index 6aee57c31d9..42d0777c5d0 100644 --- a/pylabrobot/formulatrix/mantis/device.py +++ b/pylabrobot/formulatrix/mantis/device.py @@ -1,35 +1,68 @@ """Formulatrix Mantis liquid dispenser as a PyLabRobot resource.""" +import math import os -from typing import Optional +from typing import Dict, Optional from pylabrobot.io.ftdi import FTDI from pylabrobot.resources import Coordinate, Deck, Resource +from pylabrobot.resources.manipulator import LinkBody from pylabrobot.resources.resource_holder import ResourceHolder +from .driver.arm_kinematics import ( + LEFT_BASE, + RIGHT_BASE, + compute_elbow_positions, + compute_head_position, +) from .driver.configuration import RECORDING_MANTIS_V4 +from .driver.features.arm import Arm from .driver.features.led_bar import LedBar from .driver.features.plate_clamp import PlateClamp from .driver.features.pump import Pump from .driver.features.rfid import Rfid from .driver.master import MantisDriver from .driver.simulator import MantisSimulationDriver -from .resource_model import MANIFEST, chip_rack +from .resource_model import MANIFEST, arm_link, chip_rack, z_column # 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 +# Where the right motor turns, x and y in the device frame, and the arm's height, in mm; the arm +# frame is the device frame moved there and mirrored in y. +ARM_BASE_X, ARM_BASE_Y = MANIFEST["joints"]["base_right"] +ARM_Z = MANIFEST["joints"]["arm_z"] 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``). + The main box carries the deck with its plate holder, the two chip racks and the Z column with the + arm. The generation is read at setup (``driver.configuration.generation``). """ + def _update_arm_model(self, *, theta_1: float, theta_2: float) -> None: + """Turn the arm's links to two link angles, the geometry's own, in degrees. + + Args: + theta_1: motor 0's link angle; with its offset, the angle the motor reads. + theta_2: motor 1's link angle; with its offset, the angle the motor reads. + """ + right_elbow, left_elbow = compute_elbow_positions(theta_1=theta_1, theta_2=theta_2) + head = compute_head_position(theta_1=theta_1, theta_2=theta_2) + for side, base, elbow in ( + ("right", RIGHT_BASE, right_elbow), + ("left", LEFT_BASE, left_elbow), + ): + # The device frame mirrors the arm frame in y, so an angle there turns the other way. + short_angle = -math.degrees(math.atan2(elbow[1] - base[1], elbow[0] - base[0])) + long_angle = -math.degrees(math.atan2(head[1] - elbow[1], head[0] - elbow[0])) + short, long = self.arm_links[f"{side}_short_arm"], self.arm_links[f"{side}_long_arm"] + short.rotate_to(z=short_angle, pivot_coordinate=short.proximal_joint) + long.rotate_to(z=long_angle - short_angle, pivot_coordinate=long.proximal_joint) + def __init__( self, name: str = "Mantis", @@ -130,6 +163,29 @@ def __init__( 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"]) + # The Z column and the arm it carries, drawn at idle until setup reads where the arm is. The + # column is drawn where it stands with Z at 0: how far it travels with Z is not measured. + self.z_column = z_column(name=f"{name}_z_column") + self.assign_child_resource(self.z_column, location=measured_at["z_column"]) + self.arm_links: Dict[str, LinkBody] = {} + for side, base in (("right", RIGHT_BASE), ("left", LEFT_BASE)): + short = arm_link(name=f"{name}_{side}_short_arm", link=f"{side}_short_arm") + long = arm_link(name=f"{name}_{side}_long_arm", link=f"{side}_long_arm") + assert short.distal_joint is not None + self.z_column.assign_child_resource( + short, + location=Coordinate(ARM_BASE_X + base[0], ARM_BASE_Y - base[1], ARM_Z) + - measured_at["z_column"] + - short.proximal_joint, + ) + above = MANIFEST["arm_links"][f"{side}_long_arm"]["above"] + short.assign_child_resource( + long, + location=short.distal_joint - long.proximal_joint + Coordinate(0.0, 0.0, above), + ) + self.arm_links[f"{side}_short_arm"], self.arm_links[f"{side}_long_arm"] = short, long + self._update_arm_model(theta_1=0.0, theta_2=0.0) + @property def plate_clamp(self) -> Optional[PlateClamp]: """The plate clamp, once setup found the LC3 board; None before or without it.""" @@ -150,12 +206,25 @@ def led_bar(self) -> Optional[LedBar]: """The LED bar, once setup found board 12 on a v4; None before, without it, or on a v3.""" return self.driver.led_bar - async def setup(self) -> None: - """Connect, read the device's configuration and check it. Nothing moves.""" - await self.driver.setup() + @property + def arm(self) -> Optional[Arm]: + """The arm and Z, once setup found board 11 (a v4); None before or without it.""" + return self.driver.arm + + async def setup(self, *, skip_arm: bool = False) -> None: + """Connect, read the device's configuration and check it, then bring the arm and Z up. + + Args: + skip_arm: do not bring the arm up; nothing moves. + """ + await self.driver.setup(skip_arm=skip_arm) + arm = self.driver.arm + if arm is not None and arm.is_initialized: + theta_1, theta_2 = await arm.request_link_angles() + self._update_arm_model(theta_1=theta_1, theta_2=theta_2) async def stop(self) -> None: - """Disconnect.""" + """Stop the motors, park an arm brought up, release the clamp and disconnect.""" await self.driver.stop() diff --git a/pylabrobot/formulatrix/mantis/driver/__init__.py b/pylabrobot/formulatrix/mantis/driver/__init__.py index 2c3e5f3bc68..0699bb7de5e 100644 --- a/pylabrobot/formulatrix/mantis/driver/__init__.py +++ b/pylabrobot/formulatrix/mantis/driver/__init__.py @@ -23,6 +23,7 @@ MantisTimeoutError, PlateClampError, ) +from .features.arm import Arm from .features.led_bar import LedBar from .features.plate_clamp import PlateClamp from .features.pump import ( @@ -34,6 +35,7 @@ from .features.rfid import Rfid from .master import MantisDriver from .simulator import ( + MantisSimulationArm, MantisSimulationDriver, MantisSimulationLedBar, MantisSimulationPlateClamp, diff --git a/pylabrobot/formulatrix/mantis/driver/arm_kinematics.py b/pylabrobot/formulatrix/mantis/driver/arm_kinematics.py new file mode 100644 index 00000000000..244b282a6cc --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/arm_kinematics.py @@ -0,0 +1,230 @@ +"""The arm's kinematics and units: pure maths, nothing sent. + +The arm is a five-bar linkage: two motors on fixed bases, each turning a short link, whose two long +links meet at the head. The arm frame has the base of motor 0's link at its origin and the other +base ``ARM_DISTANCE`` along x; it is in mm, and the head at idle sits at about (15.0, 31.2). + +Motors 0 and 1 turn the links in degrees; motor 2, Z, travels in mm, down from its home flag. The +wire carries device units: 200 per revolution of a motor. +""" + +import bisect +import math +from typing import Dict, Sequence, Tuple + +from .errors import MantisKinematicsError + +# The link geometry, in mm and degrees; the same on a v3 and a v4. +RIGHT_SHORT_ARM_LENGTH = 63.0 +RIGHT_LONG_ARM_LENGTH = 84.0 +LEFT_SHORT_ARM_LENGTH = 63.0 +LEFT_LONG_ARM_LENGTH = 84.0 +ARM_DISTANCE = 30.0 +GAMMA = 0.0 +MIN_THETA_1 = -93.96 +MIN_THETA_2 = -141.786 +# Where each link pair turns, (x, y) in the arm frame, mm: the right pair at the origin. +RIGHT_BASE = (0.0, 0.0) +LEFT_BASE = ( + ARM_DISTANCE * math.cos(math.radians(GAMMA)), + ARM_DISTANCE * math.sin(math.radians(GAMMA)), +) + +# Each motor's travel per revolution: degrees on the arms, mm on Z. +PITCH: Dict[int, float] = {0: 360.0, 1: 360.0, 2: 5.08} +# Full steps per revolution and the microstep divisor, the same on every motor of both generations. +STEPS_PER_REVOLUTION = 20000 +MICROSTEPS = 100 + +# The absolute encoder spans 200 per revolution; its correction tables have 200 rows. +ABSOLUTE_ENCODER_SPAN = 200.0 +# Above this, a motor's absolute position is taken one span lower, in device units. +ABSOLUTE_ENCODER_WRAP: Dict[int, float] = {0: 100.0, 1: 120.0} + + +def compute_device_position(*, motor: int, position: float) -> float: + """A position in device units. + + Args: + motor: 0, 1 (arm, degrees) or 2 (Z, mm). + position: the position, in degrees or mm. + """ + return STEPS_PER_REVOLUTION / PITCH[motor] * position / MICROSTEPS + + +def compute_position(*, motor: int, device_position: float) -> float: + """A position from device units, in degrees (arms) or mm (Z). + + Args: + motor: 0, 1 (arm) or 2 (Z). + device_position: the position, in device units. + """ + return device_position * MICROSTEPS / (STEPS_PER_REVOLUTION / PITCH[motor]) + + +def compute_device_rate(*, motor: int, rate: float) -> float: + """A speed or an acceleration in device units, rounded up half a step as on the wire. + + Args: + motor: 0, 1 (arm, degrees/s or degrees/s²) or 2 (Z, mm/s or mm/s²). + rate: the speed or acceleration; 0 stays 0, a motor that does not move. + """ + if rate == 0: + return 0.0 + return (STEPS_PER_REVOLUTION / PITCH[motor] * abs(rate) + 0.5) / MICROSTEPS + + +def compute_wrapped_angle(*, angle: float, minimum: float) -> float: + """An angle moved by whole turns into ``[minimum, minimum + 360]``. + + Args: + angle: the angle, in degrees. + minimum: the lowest angle, in degrees. + """ + while angle < minimum: + angle += 360.0 + while angle > minimum + 360.0: + angle -= 360.0 + return angle + + +def compute_joint_angles(*, x: float, y: float) -> Tuple[float, float]: + """The two links' angles that put the head at (x, y): inverse kinematics. + + The angles are the geometry's own, before a unit's motor offsets. + + Args: + x: the head's x in the arm frame, in mm. + y: the head's y in the arm frame, in mm. + + Raises: + MantisKinematicsError: if (x, y) is out of either link pair's reach or on a base. + """ + base_x, base_y = LEFT_BASE + distance = math.hypot(x, y) + distance_to_other_base = math.hypot(x - base_x, y - base_y) + if distance == 0 or distance_to_other_base == 0: + raise MantisKinematicsError(f"({x:.3f}, {y:.3f}) mm is on a motor's base") + first = (RIGHT_SHORT_ARM_LENGTH**2 - RIGHT_LONG_ARM_LENGTH**2 + distance**2) / ( + 2 * RIGHT_SHORT_ARM_LENGTH * distance + ) + second = (LEFT_SHORT_ARM_LENGTH**2 - LEFT_LONG_ARM_LENGTH**2 + distance_to_other_base**2) / ( + 2 * LEFT_SHORT_ARM_LENGTH * distance_to_other_base + ) + if not -1.0 <= first <= 1.0 or not -1.0 <= second <= 1.0: + raise MantisKinematicsError(f"({x:.3f}, {y:.3f}) mm is out of the arm's reach") + theta_1 = 90 + math.degrees(math.asin(first) - math.atan2(y, x)) + theta_2 = 90 - math.degrees(math.pi - math.asin(second) - math.atan2(y - base_y, x - base_x)) + return ( + compute_wrapped_angle(angle=theta_1, minimum=MIN_THETA_1), + compute_wrapped_angle(angle=theta_2, minimum=MIN_THETA_2), + ) + + +def compute_elbow_positions( + *, theta_1: float, theta_2: float +) -> Tuple[Tuple[float, float], Tuple[float, float]]: + """Where each short link ends, (x, y) in the arm frame in mm. + + Args: + theta_1: motor 0's link angle, in degrees, the geometry's own. + theta_2: motor 1's link angle, in degrees, the geometry's own. + """ + base_x, base_y = LEFT_BASE + first = math.radians(180.0 - theta_1) + second = math.radians(theta_2) + return ( + (RIGHT_SHORT_ARM_LENGTH * math.cos(first), RIGHT_SHORT_ARM_LENGTH * math.sin(first)), + ( + base_x + LEFT_SHORT_ARM_LENGTH * math.cos(second), + base_y + LEFT_SHORT_ARM_LENGTH * math.sin(second), + ), + ) + + +def compute_head_position(*, theta_1: float, theta_2: float) -> Tuple[float, float]: + """Where the head is for two link angles: forward kinematics, the solution of greater y. + + Args: + theta_1: motor 0's link angle, in degrees, the geometry's own. + theta_2: motor 1's link angle, in degrees, the geometry's own. + + Raises: + MantisKinematicsError: if the long links cannot meet at these angles. + """ + (first_x, first_y), (second_x, second_y) = compute_elbow_positions( + theta_1=theta_1, theta_2=theta_2 + ) + distance = math.hypot(second_x - first_x, second_y - first_y) + if ( + distance == 0 + or distance > RIGHT_LONG_ARM_LENGTH + LEFT_LONG_ARM_LENGTH + or distance < abs(RIGHT_LONG_ARM_LENGTH - LEFT_LONG_ARM_LENGTH) + ): + raise MantisKinematicsError( + f"the links cannot meet at angles ({theta_1:.3f}, {theta_2:.3f}) degrees" + ) + along = (RIGHT_LONG_ARM_LENGTH**2 - LEFT_LONG_ARM_LENGTH**2 + distance**2) / (2 * distance) + across = math.sqrt(max(0.0, RIGHT_LONG_ARM_LENGTH**2 - along**2)) + middle_x = first_x + along * (second_x - first_x) / distance + middle_y = first_y + along * (second_y - first_y) / distance + solutions = [ + ( + middle_x + sign * across * (second_y - first_y) / distance, + middle_y - sign * across * (second_x - first_x) / distance, + ) + for sign in (1.0, -1.0) + ] + return max(solutions, key=lambda solution: solution[1]) + + +def compute_corrected_encoder_reading( + *, reading: float, correction: Sequence[Tuple[float, float]] +) -> float: + """An absolute encoder reading made linear by its one-revolution table. + + Between two measured samples the ideal values are interpolated; past the last sample the table + continues into the first, one span on. + + Args: + reading: the raw reading. + correction: the table's (ideal, measured) rows, measured ascending. + """ + measured = [row[1] for row in correction] + ideal = [row[0] for row in correction] + index = bisect.bisect_right(measured, reading) - 1 + if 0 <= index < len(measured) - 1: + low, high = measured[index], measured[index + 1] + share = (reading - low) / (high - low) + return ideal[index] + share * (ideal[index + 1] - ideal[index]) + low, high = measured[-1], measured[0] + ABSOLUTE_ENCODER_SPAN + value = reading + ABSOLUTE_ENCODER_SPAN if reading < low else reading + share = (value - low) / (high - low) + corrected = ideal[-1] + share * (ideal[0] + ABSOLUTE_ENCODER_SPAN - ideal[-1]) + half = ABSOLUTE_ENCODER_SPAN / 2 + return (corrected + half) % ABSOLUTE_ENCODER_SPAN - half + + +def compute_absolute_device_position( + *, + motor: int, + reading: float, + correction: Sequence[Tuple[float, float]], + homing_position_a: float, +) -> float: + """An arm motor's position, in device units, from its absolute encoder's raw reading. + + The corrected reading less the encoder reference, within one span; above ``ABSOLUTE_ENCODER_WRAP`` + one span lower. + + Args: + motor: 0 or 1. + reading: the raw reading. + correction: the motor's (ideal, measured) table rows. + homing_position_a: the motor's encoder reference, in the encoder's units. + """ + corrected = compute_corrected_encoder_reading(reading=reading, correction=correction) + position = (corrected - homing_position_a) % ABSOLUTE_ENCODER_SPAN + if position > ABSOLUTE_ENCODER_WRAP[motor]: + position -= ABSOLUTE_ENCODER_SPAN + return position diff --git a/pylabrobot/formulatrix/mantis/driver/arm_kinematics_tests.py b/pylabrobot/formulatrix/mantis/driver/arm_kinematics_tests.py new file mode 100644 index 00000000000..c3e8205e9c3 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/arm_kinematics_tests.py @@ -0,0 +1,117 @@ +"""Arm kinematics tests: units against the wire, the geometry at idle, the encoder correction.""" + +import struct +import unittest + +from pylabrobot.formulatrix.mantis.driver.arm_kinematics import ( + compute_absolute_device_position, + compute_corrected_encoder_reading, + compute_device_position, + compute_device_rate, + compute_head_position, + compute_joint_angles, + compute_position, +) +from pylabrobot.formulatrix.mantis.driver.errors import MantisKinematicsError + +# Doubles a Mantis v4 was sent, as hex: the arms' and Z's speed and acceleration of a move, Z at +# 20 mm, and Z's fast homing speed and acceleration. +SENT_ARM_SPEED = "b4a291808fb3b540" +SENT_ARM_ACCELERATION = "814e1be8b40a8a40" +SENT_Z_SPEED = "4cd431fab7eaa040" +SENT_Z_ACCELERATION = "bb34be4f05c2be40" +SENT_Z_AT_20_MM = "66b3d96c369b8840" +SENT_Z_HOMING_SPEED = "64ea130f73748240" +SENT_Z_HOMING_ACCELERATION = "7d2ae7ab04c2ce40" +# A synthetic correction table: each reading is its own ideal value. +IDENTITY = [(float(row), float(row)) for row in range(1, 201)] + + +def as_hex(value: float) -> str: + return struct.pack(" bool: + """Whether ``initialize`` brought the arm up since the link opened.""" + return self._initialized + + @staticmethod + def _check_positive(*, name: str, value: float) -> None: + """Raise ``ValueError`` unless ``value`` is above 0: the device reports no range of its own.""" + if not value > 0: + raise ValueError(f"{name} must be above 0, got {value}") + + @property + def default_arm_speed(self) -> float: + """Each arm motor's speed for a move, in degrees/s.""" + return self._default_arm_speed + + @default_arm_speed.setter + def default_arm_speed(self, value: float) -> None: + self._check_positive(name="default_arm_speed", value=value) + self._default_arm_speed = value + + @property + def default_arm_acceleration(self) -> float: + """Each arm motor's acceleration for a move, and its deceleration at a stop, in degrees/s².""" + return self._default_arm_acceleration + + @default_arm_acceleration.setter + def default_arm_acceleration(self, value: float) -> None: + self._check_positive(name="default_arm_acceleration", value=value) + self._default_arm_acceleration = value + + @property + def default_z_speed(self) -> float: + """Z's speed for a move, in mm/s.""" + return self._default_z_speed + + @default_z_speed.setter + def default_z_speed(self, value: float) -> None: + self._check_positive(name="default_z_speed", value=value) + self._default_z_speed = value + + @property + def default_z_acceleration(self) -> float: + """Z's acceleration for a move, and its deceleration at a stop, in mm/s².""" + return self._default_z_acceleration + + @default_z_acceleration.setter + def default_z_acceleration(self, value: float) -> None: + self._check_positive(name="default_z_acceleration", value=value) + self._default_z_acceleration = value + + def _get_device_speed(self, *, motor: int) -> float: + """A motor's default speed, in device units.""" + speed = self.default_z_speed if motor == Z_MOTOR else self.default_arm_speed + return compute_device_rate(motor=motor, rate=speed) + + def _get_device_acceleration(self, *, motor: int) -> float: + """A motor's default acceleration, in device units; a stop decelerates at it.""" + acceleration = ( + self.default_z_acceleration if motor == Z_MOTOR else self.default_arm_acceleration + ) + return compute_device_rate(motor=motor, rate=acceleration) + + def _check_calibrated(self) -> None: + """Raise ``MantisConfigurationError`` unless the unit's ``arm`` block was given.""" + if self.offsets is None or self.homing_position_a is None or self.encoder_correction is None: + raise MantisConfigurationError( + f"{self._driver.name}: no arm calibration for this unit; give its configuration folder " + "(oem_config_directory) or a configuration saved with it, or set up with skip_arm=True" + ) + + # -- raw firmware calls ---------------------------------------------------- + + async def _unchecked_fw_clear_motor_faults(self, *, motor: int) -> None: + """Clear a motor's faults (board 0, command 17), sent once.""" + await self._driver._send_command( + board=MOTION_BOARD, command=17, payload=struct.pack(" None: + """Run a motor open loop, unpowered, or closed loop, energised (command 19), sent once.""" + await self._driver._send_command( + board=MOTION_BOARD, command=19, payload=struct.pack(" None: + """Search a motor's home flag (command 21), sent once; speeds in device units.""" + payload = struct.pack( + " None: + """Move one motor (command 22), sent once, in device units; it answers as the move starts.""" + payload = struct.pack(" None: + """Stop a motor (command 24), sent once; deceleration in device units.""" + await self._driver._send_command( + board=MOTION_BOARD, command=24, payload=struct.pack(" None: + """Declare where a motor is (command 28), sent once, in device units.""" + await self._driver._send_command( + board=MOTION_BOARD, command=28, payload=struct.pack(" None: + """Set a motor's following-error check (command 83), sent once; maximum in device units.""" + await self._driver._send_command( + board=MOTION_BOARD, + command=83, + payload=struct.pack(" None: + """Declare a motor's servo encoder position (command 85), sent once, in device units.""" + await self._driver._send_command( + board=MOTION_BOARD, command=85, payload=struct.pack(" float: + """An arm motor's position from its absolute encoder, in device units. + + Raises: + MantisConfigurationError: without the unit's ``arm`` block; nothing is sent. + """ + self._check_calibrated() + assert self.encoder_correction is not None and self.homing_position_a is not None + reading = await self._driver.request_absolute_encoder_position(motor=motor) + return compute_absolute_device_position( + motor=motor, + reading=reading, + correction=self.encoder_correction[motor], + homing_position_a=self.homing_position_a[motor], + ) + + async def request_absolute_joint_position(self) -> Dict[int, float]: + """Each arm motor's angle from its absolute encoder, in degrees, by motor; nothing moves. + + Kept across power-off, unlike ``request_joint_position`` before the arms are declared. + + Raises: + MantisConfigurationError: without the unit's ``arm`` block; nothing is sent. + """ + return { + motor: compute_position( + motor=motor, device_position=await self._request_absolute_device_position(motor=motor) + ) + for motor in ARM_MOTORS + } + + async def request_joint_position(self) -> Dict[int, float]: + """Where the controller holds each motor: 0 and 1 in degrees, 2 (Z) in mm down from its flag.""" + return { + motor: compute_position( + motor=motor, device_position=await self._driver.request_motor_position(motor=motor) + ) + for motor in (*ARM_MOTORS, Z_MOTOR) + } + + async def _request_measured_position(self, *, motor: int) -> float: + """Where a motor is by its servo encoder (command 84), measured: an arm motor in degrees, Z in + mm down from its flag. The arms' encoders are declared at bring-up.""" + return compute_position( + motor=motor, device_position=await self._driver.request_encoder_position(motor=motor) + ) + + def is_xy_reachable(self, *, x: float, y: float) -> bool: + """Whether the head can reach (x, y) in the arm frame, mm. Nothing is clamped. + + Args: + x: the head's x, in mm. + y: the head's y, in mm. + """ + try: + compute_joint_angles(x=x, y=y) + except MantisKinematicsError: + return False + return True + + def _get_link_angles(self, *, joint_position: Dict[int, float]) -> Tuple[float, float]: + """The two links' angles, the geometry's own, degrees: each arm motor's angle plus its offset. + + Raises: + MantisConfigurationError: without the unit's ``arm`` block. + """ + self._check_calibrated() + assert self.offsets is not None + return (joint_position[0] + self.offsets[0], joint_position[1] + self.offsets[1]) + + async def request_link_angles(self) -> Tuple[float, float]: + """The two links' angles where the controller holds the arm motors, the geometry's own, degrees. + + Raises: + MantisConfigurationError: without the unit's ``arm`` block; nothing is sent. + """ + self._check_calibrated() + return self._get_link_angles(joint_position=await self.request_joint_position()) + + def _get_head_position(self, *, joint_position: Dict[int, float]) -> Tuple[float, float]: + """Where the head is in the arm frame, mm, for the arm motors' angles in degrees. + + Raises: + MantisConfigurationError: without the unit's ``arm`` block. + MantisKinematicsError: if the links cannot meet at those angles. + """ + theta_1, theta_2 = self._get_link_angles(joint_position=joint_position) + return compute_head_position(theta_1=theta_1, theta_2=theta_2) + + def _get_idle_targets(self) -> Dict[int, float]: + """Each arm motor's target at idle, in device units: ``POST_HOMING_POSITION`` less its offset. + + Raises: + MantisConfigurationError: without the unit's ``arm`` block. + """ + self._check_calibrated() + assert self.offsets is not None + x, y = POST_HOMING_POSITION + angles = compute_joint_angles(x=x, y=y) + return { + motor: compute_device_position(motor=motor, position=angles[motor] - self.offsets[motor]) + for motor in ARM_MOTORS + } + + async def request_head_position(self) -> Coordinate: + """Where the head is: x and y in the arm frame, mm; z where Z is, mm down from its flag. + + Raises: + MantisConfigurationError: without the unit's ``arm`` block; nothing is sent. + MantisKinematicsError: if the links cannot meet at the angles read. + """ + self._check_calibrated() + joint_position = await self.request_joint_position() + x, y = self._get_head_position(joint_position=joint_position) + return Coordinate(x=x, y=y, z=joint_position[Z_MOTOR]) + + # -- moves ------------------------------------------------------------------ + + async def _log_where_it_stopped(self) -> None: + """Log where each motor is by its encoder after a failure; a failed read is logged and + dropped.""" + try: + measured = { + motor: await self._request_measured_position(motor=motor) + for motor in (*ARM_MOTORS, Z_MOTOR) + } + logger.warning("[%s] the motors stopped at %s by their encoders", self._driver.name, measured) + except Exception: + logger.warning("[%s] where the motors stopped is not known", self._driver.name) + + async def _stop_motors(self, *, motors: Tuple[int, ...]) -> bool: + """Stop each motor at its default deceleration (command 24), each in its own try; a failure is + logged. Return whether every stop was answered.""" + stopped = True + for motor in motors: + try: + await self._unchecked_fw_stop( + motor=motor, deceleration=self._get_device_acceleration(motor=motor) + ) + except Exception: + logger.warning("[%s] could not stop motor %d", self._driver.name, motor, exc_info=True) + stopped = False + return stopped + + def _check_status(self, *, motor: int, status: int) -> None: + """Raise ``MantisMotionError`` if a motor's status word reports a fault.""" + faults = [meaning for bit, meaning in MOTOR_STATUS_ERROR_BITS.items() if status & bit] + if faults: + raise MantisMotionError( + f"{self._driver.name}: motor {motor} reports {', '.join(faults)} (status 0x{status:04x})", + motor=motor, + status=status, + ) + + async def _wait_for_motors_idle(self, *, timeout: float) -> None: + """Read each motor's status until none moves or homes. + + Raises: + MantisMotionError: if a motor reports a fault. + MantisTimeoutError: if one still moves after ``timeout`` seconds. + """ + deadline = asyncio.get_running_loop().time() + timeout + while True: + busy = [] + for motor in (*ARM_MOTORS, Z_MOTOR): + status = await self._driver.request_motor_status(motor=motor) + try: + self._check_status(motor=motor, status=status) + except MantisMotionError: + await self._log_where_it_stopped() + raise + if status & (MOTOR_MOVING | MOTOR_HOMING): + busy.append(motor) + if not busy: + return + if asyncio.get_running_loop().time() >= deadline: + await self._log_where_it_stopped() + raise MantisTimeoutError(f"{self._driver.name}: motors {busy} still move after {timeout} s") + await asyncio.sleep(MOTOR_STATUS_POLL_INTERVAL) + + async def _move_motors(self, *, targets: Dict[int, float]) -> None: + """Move each motor to its target, in device units, at its default speed, then wait. + + Each move is read back once as it starts, as recorded, then every motor until all stop. On any + failure the motors sent a move are stopped and where they stopped is logged before it raises. + + Args: + targets: device units, by motor, sent in motor order. + """ + sent: List[int] = [] + try: + for motor in sorted(targets): + sent.append(motor) # before the send: a send that raised may still have started it + await self._unchecked_fw_move_absolute( + motor=motor, + position=targets[motor], + speed=self._get_device_speed(motor=motor), + acceleration=self._get_device_acceleration(motor=motor), + ) + status = await self._driver.request_motor_status(motor=motor) + self._check_status(motor=motor, status=status) + await self._wait_for_motors_idle(timeout=MOVE_TIMEOUT) + except BaseException: + await self._stop_motors(motors=tuple(sent)) + await self._log_where_it_stopped() + raise + + async def _declare_absolute_joint_position(self) -> None: + """Declare each arm motor where its absolute encoder reads, ``DECLARE_PASSES`` times. + + A pass clears both arm motors' faults, reads both encoders, then sets each motor's position + and servo encoder position there. + """ + for _ in range(DECLARE_PASSES): + for motor in ARM_MOTORS: + await self._unchecked_fw_clear_motor_faults(motor=motor) + positions = { + motor: await self._request_absolute_device_position(motor=motor) for motor in ARM_MOTORS + } + for motor in ARM_MOTORS: + await self._unchecked_fw_set_motor_position(motor=motor, position=positions[motor]) + await self._unchecked_fw_set_encoder_position(motor=motor, position=positions[motor]) + await asyncio.sleep(0.01) # recorded passes are 10 ms apart + + def _check_near_idle(self, *, joint_position: Dict[int, float]) -> None: + """Raise unless the head stands within ``FRONT_ZONE_X_RANGE``, where it is moved to idle from. + + From anywhere else, a move to idle could cross a chip rack. + + Args: + joint_position: each arm motor's angle, in degrees, by motor. + + Raises: + MantisMotionError: if the head is elsewhere, or its position is not known. + """ + try: + x, y = self._get_head_position(joint_position=joint_position) + except MantisKinematicsError as error: + raise MantisMotionError(f"{self._driver.name}: where the head is is not known") from error + low, high = FRONT_ZONE_X_RANGE + if not low <= x <= high: + raise MantisMotionError( + f"{self._driver.name}: the head is at ({x:.1f}, {y:.1f}) mm; it is moved to idle only " + f"from x {low} to {high} mm. Park the arm at idle, then set up again" + ) + + async def _home_z(self) -> None: + """Home Z on its flag, as recorded: check no motor slipped, clear Z's faults, search, wait. + + Raises: + MantisMotionError: if a motor reports a following error first, or Z does not read homed. + MantisTimeoutError: if Z does not report homing done within ``HOME_TIMEOUT``. + """ + for motor in (*ARM_MOTORS, Z_MOTOR): + enabled, _ = await self._driver.request_following_error_configuration(motor=motor) + if enabled: + status = await self._driver.request_motor_status(motor=motor) + if status & MOTOR_FOLLOWING_ERROR: + raise MantisMotionError( + f"{self._driver.name}: motor {motor} slipped (status 0x{status:04x}); Z is not homed", + motor=motor, + status=status, + ) + await self._unchecked_fw_clear_motor_faults(motor=Z_MOTOR) + waiter = self._driver._expect_event( + board=MOTION_BOARD, + code=HOME_DONE_EVENT, + match=lambda payload: decode_home_done(payload=payload)[0] == Z_MOTOR, + ) + fast_speed = compute_device_rate(motor=Z_MOTOR, rate=Z_HOMING_SPEED) + try: + await self._unchecked_fw_home( + motor=Z_MOTOR, + method=0, + positive_edge=True, + positive_direction=False, + slow_speed=fast_speed / 10, + fast_speed=fast_speed, + acceleration=compute_device_rate(motor=Z_MOTOR, rate=Z_HOMING_ACCELERATION), + ) + except BaseException: + waiter.cancel() + raise + await self._driver._wait_for_event(waiter=waiter, timeout=HOME_TIMEOUT) + status = await self._driver.request_motor_status(motor=Z_MOTOR) + self._check_status(motor=Z_MOTOR, status=status) + if not status & MOTOR_HOMED: + raise MantisMotionError( + f"{self._driver.name}: Z does not read homed (status 0x{status:04x})", + motor=Z_MOTOR, + status=status, + ) + + async def _move_to_idle(self) -> None: + """Move to idle by the recorded route from the Front zone: Z up, the arms, then both.""" + arms = self._get_idle_targets() + await self._move_motors(targets={Z_MOTOR: 0.0}) + await self._move_motors(targets=arms) + await self._move_motors(targets={**arms, Z_MOTOR: 0.0}) + + async def initialize(self) -> None: + """Bring the arm and Z up from power-on, as in the recorded v4 startup. + + Energise the arm motors, declare them where their absolute encoders read, set their following + error, move them to idle, home Z, then twice declare and move to idle again, and home Z. A + failure from energising on leaves the arm motors unpowered again. + + Raises: + MantisConfigurationError: without the unit's ``arm`` block; nothing is sent. + MantisMotionError: if the head is not where it is moved straight to idle from, or a motor + faults. + MantisTimeoutError: if a move or the home does not end in time. + """ + self._check_calibrated() + self._initialized = False + try: + for motor in ARM_MOTORS: + await self._unchecked_fw_set_motor_open_loop(motor=motor, open_loop=False) + await self._declare_absolute_joint_position() + for motor in ARM_MOTORS: + await self._unchecked_fw_set_following_error_configuration( + motor=motor, enabled=True, maximum=ARM_MAXIMUM_FOLLOWING_ERROR + ) + await self._unchecked_fw_clear_motor_faults(motor=Z_MOTOR) + await self._declare_absolute_joint_position() + self._check_near_idle(joint_position=await self.request_joint_position()) + await self._move_motors(targets=self._get_idle_targets()) + await self._home_z() + for _ in range(2): + await self._declare_absolute_joint_position() + self._check_near_idle(joint_position=await self.request_joint_position()) + await self._move_to_idle() + await self._home_z() + except BaseException: + for motor in ARM_MOTORS: + try: + await self._unchecked_fw_set_motor_open_loop(motor=motor, open_loop=True) + except Exception: + logger.warning("[%s] could not unpower motor %d", self._driver.name, motor, exc_info=True) + raise + self._initialized = True + + def _get_queued_target( + self, *, motor: int, position: Optional[float] + ) -> Tuple[float, float, float]: + """A motor's (position, speed, acceleration) in a queued move, device units. + + Args: + motor: 0, 1 (arm) or 2 (Z). + position: the target in device units, or None to hold the motor: it is sent all zeros. + """ + if position is None: + return (0.0, 0.0, 0.0) + return ( + position, + self._get_device_speed(motor=motor), + self._get_device_acceleration(motor=motor), + ) + + async def _park(self) -> None: + """Park at idle and lower Z to ``SHUTDOWN_Z``, as in the recorded close. + + Z up on its own (command 22), read back by its encoder; then, queued, the arms with Z held, + both; then Z down at idle. It waits for the motors to stop, and checks where the head is from + the absolute encoders, not the controller. + + Raises: + MantisMotionError: if a motor reports a fault, or the head is not where it is moved to idle + from (nothing moves), or Z does not read up within ``Z_TRAVERSE_TOLERANCE`` after rising + (the arms are not moved). + MantisTimeoutError: if a motor still moves, or a queued move is not reported played, in time. + """ + await self._wait_for_motors_idle(timeout=MOVE_TIMEOUT) + self._check_near_idle(joint_position=await self.request_absolute_joint_position()) + idle_targets = self._get_idle_targets() + arms = [ + self._get_queued_target(motor=motor, position=idle_targets[motor]) for motor in ARM_MOTORS + ] + z_up = self._get_queued_target(motor=Z_MOTOR, position=0.0) + z_down = self._get_queued_target( + motor=Z_MOTOR, position=compute_device_position(motor=Z_MOTOR, position=SHUTDOWN_Z) + ) + z_held = self._get_queued_target(motor=Z_MOTOR, position=None) + await self._move_motors(targets={Z_MOTOR: 0.0}) + z = await self._request_measured_position(motor=Z_MOTOR) + if z > Z_TRAVERSE_TOLERANCE: + raise MantisMotionError( + f"{self._driver.name}: Z reads {z:.3f} mm below its flag by its encoder after rising to " + "it; the arms are not moved", + motor=Z_MOTOR, + ) + for batch in ([arms + [z_held], arms + [z_up]], [arms + [z_down]]): + await self._driver._unchecked_fw_clear_sequencer() + await self._driver._unchecked_fw_start_sequencer() + for targets in batch: + item = await self._driver._queue_move_item(targets=targets) + await self._driver._wait_for_queue_item(item=item, timeout=MOVE_TIMEOUT) + + async def _halt(self) -> None: + """Stop every motor at its default deceleration, then park the arm if it was brought up and + every motor stopped. + + Each stop runs whether or not the one before it succeeded, its failure logged; the arm is no + longer brought up after it. + """ + initialized, self._initialized = self._initialized, False + if not await self._stop_motors(motors=(*ARM_MOTORS, Z_MOTOR)): + if initialized: + logger.warning("[%s] a motor did not stop; the arm is not parked", self._driver.name) + return + if initialized: + try: + await self._park() + except Exception: + logger.warning("[%s] the arm was not parked", self._driver.name, exc_info=True) diff --git a/pylabrobot/formulatrix/mantis/driver/features/arm_tests.py b/pylabrobot/formulatrix/mantis/driver/features/arm_tests.py new file mode 100644 index 00000000000..178b59a6c44 --- /dev/null +++ b/pylabrobot/formulatrix/mantis/driver/features/arm_tests.py @@ -0,0 +1,704 @@ +"""Arm tests: the bring-up and the park against a Mantis v4's own startup and close, as recorded. + +The recorded startup declared each arm where its unit's encoder correction put it; these tests +declare it with a synthetic correction, so only those values differ from what was recorded. The +offsets declared are the ones the recorded idle targets imply, so the moves are sent as recorded. +""" + +import json +import os +import struct +import tempfile +import unittest +from typing import Dict, List, Tuple +from unittest import mock + +from pylabrobot.formulatrix.mantis.device import RECORDING_MANTIS_V4 +from pylabrobot.formulatrix.mantis.driver import master as master_module +from pylabrobot.formulatrix.mantis.driver.arm_kinematics import ( + compute_absolute_device_position, + compute_device_position, + compute_joint_angles, + compute_position, +) +from pylabrobot.formulatrix.mantis.driver.errors import ( + MantisConfigurationError, + MantisMotionError, + MantisTimeoutError, +) +from pylabrobot.formulatrix.mantis.driver.features.arm import POST_HOMING_POSITION, Arm +from pylabrobot.formulatrix.mantis.driver.features.led_bar_tests import ( + BLUE, + GREEN, + OFF, + RED, + START_IDLE_ANIMATION, + STOP_ANIMATION, + bar_states, + color_write, + led_bar_frames, + make_led_bar_answers, +) +from pylabrobot.formulatrix.mantis.driver.framing import parse_frame +from pylabrobot.formulatrix.mantis.driver.master import MantisDriver +from pylabrobot.formulatrix.mantis.driver.master_tests import ( + RECORDED_MOTOR_ERROR_EVENTS, + DriverTestCase, + FakeLink, + make_answers, +) + +with open(RECORDING_MANTIS_V4, encoding="utf-8") as f: + RECORDING = json.load(f) +# Where the recorded moves to idle (command 22) sent the arm motors, in device units. +RECORDED_IDLE_TARGETS = { + 0: struct.unpack(" str: + board, code, payload = command + return f"{board}:{code}:{payload.hex()}" + + +def get_expected_startup() -> List[str]: + """The recorded startup with each arm declared where the synthetic correction puts it.""" + replies = {motor: list(STARTUP_ABSOLUTE_ENCODER_REPLIES[motor]) for motor in (0, 1)} + declared: Dict[int, float] = {} + expected = [] + for sent in RECORDED_STARTUP: + board, command, payload = sent.split(":") + motor = struct.unpack_from(" int: + _, board, command, payload = parse_frame(frame=data) + if (board, command) == (0, 22) and payload[:2] != b"\x02\x00": + self.answers[(0, 20, payload[:2])] = [ + bytes.fromhex(STATUS_MOVING), + bytes.fromhex(STATUS_IDLE), + ] + if (board, command) == (0, 21): + self.answers[(0, 20, b"\x02\x00")] = [bytes.fromhex(STATUS_HOMED)] + return await super().write(data) + + +class ArmTestCase(DriverTestCase): + def setUp(self): + super().setUp() + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.directory = directory.name + + def declare(self, *, arm_block) -> str: + path = os.path.join(self.directory, f"declared_{len(os.listdir(self.directory))}.json") + with open(path, "w", encoding="utf-8") as f: + json.dump({**RECORDING, **({"arm": arm_block} if arm_block is not None else {})}, f) + return path + + async def set_up_arm(self, *, link: FakeLink, arm_block=SYNTHETIC_ARM_BLOCK): + driver = await self.set_up_driver( + link=link, declared_configuration_json=self.declare(arm_block=arm_block) + ) + assert driver.arm is not None + self.sent_before = len(link.written) + return driver + + def sent(self, link: FakeLink) -> List[Tuple[int, int, bytes]]: + return [parse_frame(frame=frame)[1:] for frame in link.written[self.sent_before :]] + + +class InitializeTests(ArmTestCase): + async def test_initialize_sends_the_recorded_startup(self): + link = ArmLink( + answers=make_answers(), + events={ + (0, 21, bytes.fromhex(RECORDED_STARTUP[-1].split(":")[2])): [ + bytes.fromhex(STARTUP_HOME_DONE) + ] + }, + ) + driver = await self.set_up_arm(link=link) + await driver.arm.initialize() + commands = self.sent(link) + self.assertEqual( + [token(command) for command in commands if command[1] != 20], get_expected_startup() + ) + self.assertTrue(driver.arm.is_initialized) + for index, (_, command, payload) in enumerate(commands): + if command == 21: + # Before each Z home: each motor's following-error check and status, then Z's faults clear. + self.assertEqual( + commands[index - 7 : index - 1], + [(0, code, struct.pack(" MantisDriver: + driver = super().make_driver(link=link, **kwargs) + # every command of the bring-up is answered; a slow test runner must not time one out + driver.default_read_timeout = 5.0 + return driver + + def startup_link(self, *, answers) -> ArmLink: + """Answers the recorded startup, Z reporting its homing done, and ``answers``.""" + home = bytes.fromhex(RECORDED_STARTUP[-1].split(":")[2]) + return ArmLink(answers=answers, events={(0, 21, home): [bytes.fromhex(STARTUP_HOME_DONE)]}) + + async def test_setup_shows_the_bring_up_in_blue_then_green_then_idle(self): + link = self.startup_link(answers=make_led_bar_answers()) + driver = await self.set_up_driver( + link=link, + skip_arm=False, + declared_configuration_json=self.declare(arm_block=SYNTHETIC_ARM_BLOCK), + ) + assert driver.arm is not None + self.assertTrue(driver.arm.is_initialized) + frames = [parse_frame(frame=frame)[1:] for frame in link.written] + moves = [index for index, (_, command, _) in enumerate(frames) if command in (19, 21, 22)] + self.assertLess(frames.index(color_write(colors=[BLUE] * 20)), moves[0]) + self.assertLess(moves[-1], frames.index(color_write(colors=[GREEN] * 20))) + self.assertEqual(bar_states(written=link.written), [BLUE, GREEN, "idle"]) + + async def test_a_failed_bring_up_shows_red_then_turns_off(self): + link = self.startup_link(answers=make_led_bar_answers()) + for motor, reply in AWAY_POSITION_REPLIES.items(): + link.answers[(0, 27, struct.pack(" Dict: + """The synthetic arm block, its references putting the last recorded readings at (-57, 0) mm.""" + homing_position_a = {} + for motor, angle in enumerate(compute_joint_angles(x=-57.0, y=0.0)): + _, reading = struct.unpack(" FakeLink: + """Answers the recorded close; each motor reads idle, each encoder as last in the startup.""" + link = FakeLink( + answers=make_answers(), + events={ + (0, 68, bytes.fromhex(CLOSE_QUEUED_MOVES[item])): [bytes.fromhex(CLOSE_ITEM_PLAYED[item])] + for item in (15, 16) + }, + ) + for motor in (0, 1, 2): + key = struct.pack(" int: + if parse_frame(frame=data)[1:] == self.raises_on: + self.written.append(data) + raise OSError("device unplugged") + return await super().write(data) + + +class MoveFailureTests(ArmTestCase): + async def test_a_failed_send_stops_the_motors_sent_a_move_and_logs_where_they_are(self): + second = struct.pack(" int: self._next_item = item % LAST_QUEUE_ITEM + 1 return item - async def _queue_ppi_write(self, *, duration: int, start_address: int, values: List[int]) -> int: - """Queue a PPI write on the sequencer and return its item. + async def _unchecked_fw_queue_move_item( + self, *, item: int, relative: bool, wait: bool, targets: List[Tuple[float, float, float]] + ) -> Tuple[int, int]: + """Queue a move of all three motors on the sequencer (board 0, command 68), sent once. - An item the device answers -1 free slots to was not queued: the write is sent again under the - next item until one is, for at most ``default_queue_full_timeout``. No item is sent twice: a - v4 queues a write resent with its packet number as a second item, so a lost reply raises. + Returns the reply's free slots, -1 when the queue was full and the item not queued, and its + error word. Args: - duration: how long the sequencer holds the write, in its time steps of 1 ms, 1 to 0x7FFF. - start_address: the first PPI address written, one of ``QUEUED_PPI_ADDRESSES``. - values: the values written from there, each 0 to 0xFFFF; at least one. + item: the item's number, 1 to ``LAST_QUEUE_ITEM``. + relative: whether the positions are from where the motors are. + wait: whether the sequencer waits for the move to end before the next item. + targets: each motor's (position, speed, acceleration), in device units; a motor sent all + zeros does not move. + + Raises: + FrameError: if the reply is shorter than 4 bytes. + """ + payload = struct.pack(" int: + """Queue one item under the next number and return it. + + An item the device answers -1 free slots to was not queued: it is sent again under the next + item until one is, for at most ``default_queue_full_timeout``. No item is sent twice: a v4 + queues a write resent with its packet number as a second item, so a lost reply raises. + + Args: + command: the queue command, for an error message. + queue: sends the item under the number it is given; returns its free slots and error word. Raises: - ValueError: if an argument is out of range; nothing is sent. InvalidQueueItemError: if a reply's error word is not 0. MantisTimeoutError: if the queue stays full for ``default_queue_full_timeout``. """ - self._check_index(name="start_address", value=start_address, allowed=QUEUED_PPI_ADDRESSES) - if not 1 <= duration <= 0x7FFF: - raise ValueError(f"duration must be 1 to 0x7FFF, an int16, got {duration}") - if len(values) == 0 or any(not 0 <= value <= 0xFFFF for value in values): - raise ValueError(f"values must be one or more uint16, got {values}") deadline = time.monotonic() + self.default_queue_full_timeout while True: item = self._get_next_item() - free, error = await self._unchecked_fw_queue_write_ppi( - item=item, duration=duration, start_address=start_address, values=values - ) + free, error = await queue(item) if error != 0: raise InvalidQueueItemError( - f"{self.name}: queue item {item} answered error {error}", command=65, error=error + f"{self.name}: queue item {item} answered error {error}", command=command, error=error ) if free >= 0: return item @@ -708,6 +732,51 @@ async def _queue_ppi_write(self, *, duration: int, start_address: int, values: L ) logger.debug("[%s] queue full, item %d not queued; sending it again", self.name, item) + async def _queue_ppi_write(self, *, duration: int, start_address: int, values: List[int]) -> int: + """Queue a PPI write on the sequencer and return its item, as ``_queue_item``. + + Args: + duration: how long the sequencer holds the write, in its time steps of 1 ms, 1 to 0x7FFF. + start_address: the first PPI address written, one of ``QUEUED_PPI_ADDRESSES``. + values: the values written from there, each 0 to 0xFFFF; at least one. + + Raises: + ValueError: if an argument is out of range; nothing is sent. + InvalidQueueItemError: if a reply's error word is not 0. + MantisTimeoutError: if the queue stays full for ``default_queue_full_timeout``. + """ + self._check_index(name="start_address", value=start_address, allowed=QUEUED_PPI_ADDRESSES) + if not 1 <= duration <= 0x7FFF: + raise ValueError(f"duration must be 1 to 0x7FFF, an int16, got {duration}") + if len(values) == 0 or any(not 0 <= value <= 0xFFFF for value in values): + raise ValueError(f"values must be one or more uint16, got {values}") + return await self._queue_item( + command=65, + queue=lambda item: self._unchecked_fw_queue_write_ppi( + item=item, duration=duration, start_address=start_address, values=values + ), + ) + + async def _queue_move_item(self, *, targets: List[Tuple[float, float, float]]) -> int: + """Queue a move of all three motors, waited for, to absolute targets; as ``_queue_item``. + + Args: + targets: each motor's (position, speed, acceleration), in device units, motors 0 to 2. + + Raises: + ValueError: unless there is one target per motor; nothing is sent. + InvalidQueueItemError: if a reply's error word is not 0. + MantisTimeoutError: if the queue stays full for ``default_queue_full_timeout``. + """ + if len(targets) != len(MOTORS): + raise ValueError(f"a queued move has one target per motor {MOTORS}, got {targets}") + return await self._queue_item( + command=68, + queue=lambda item: self._unchecked_fw_queue_move_item( + item=item, relative=False, wait=True, targets=targets + ), + ) + async def _wait_for_queue_item(self, *, item: int, timeout: float) -> None: """Wait until the sequencer reports ``item`` played with no item left queued. @@ -762,13 +831,46 @@ async def _request_configuration(self) -> DeviceConfiguration: motor_limits={motor: await self.request_motor_limits(motor=motor) for motor in MOTORS}, ) + def _get_arm_calibration(self) -> Dict[str, Any]: + """The ``arm`` block as the arm takes it, by argument; each None without the block. + + Raises: + MantisConfigurationError: if the block is malformed, or a correction table does not have + two rows or more, measured ascending. + """ + block = self._unit_blocks.get("arm") + if block is None: + return {"offsets": None, "homing_position_a": None, "encoder_correction": None} + try: + calibration: Dict[str, Any] = { + field: {int(motor): float(value) for motor, value in block[field].items()} + for field in ("offsets", "homing_position_a") + } + calibration["encoder_correction"] = { + int(motor): [(float(ideal), float(measured)) for ideal, measured in rows] + for motor, rows in block["encoder_correction"].items() + } + except (KeyError, TypeError, ValueError, AttributeError) as error: + raise MantisConfigurationError(f"malformed arm block: {error!r}") from error + for field, values in calibration.items(): + if set(values) != set(ABSOLUTE_ENCODER_MOTORS): + raise MantisConfigurationError( + f"arm block: {field} is for motors {ABSOLUTE_ENCODER_MOTORS}" + ) + for motor, rows in calibration["encoder_correction"].items(): + if len(rows) < 2 or any(later[1] <= earlier[1] for earlier, later in zip(rows, rows[1:])): + raise MantisConfigurationError( + f"arm block: motor {motor}'s correction needs two rows or more, measured ascending" + ) + return calibration + 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`` or ``led_bar`` block is malformed. + MantisConfigurationError: if the ``plate_clamp``, ``led_bar`` or ``arm`` 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 @@ -802,6 +904,9 @@ def _discover_features(self) -> None: self.led_bar = LedBar( driver=self, number_of_leds=number_of_leds, show_actions=self._show_actions ) + sensor_board = self.configuration.firmware_versions.get(ABSOLUTE_POSITION_SENSOR_BOARD) + if sensor_board is not None and self.arm is None: + self.arm = Arm(driver=self, **self._get_arm_calibration()) def _show_action( self, *, action: str, show_inner_actions: bool = True, only_when_idle: bool = False @@ -852,7 +957,8 @@ def _load_unit_values( self, *, generation: Optional[str] ) -> Tuple[Dict[str, Any], Dict[str, str]]: """The declared file, or without one the folder's blocks, with each value it lacks taken from - the folder, else the generation's recording; and where each taken one came from, by key. + the folder, else the generation's recording (never a motion calibration); and where each taken + one came from, by key. Args: generation: picks the recording. @@ -866,7 +972,11 @@ def _load_unit_values( else {} ) recording_path = RECORDING_MANTIS_V4 if generation == "v4" else RECORDING_MANTIS_V3 - recording = read_configuration(path=recording_path) + recording = { + block: value + for block, value in read_configuration(path=recording_path).items() + if block not in MOTION_CALIBRATION_BLOCKS + } if self.declared_configuration_json is None: return _fill_declared_configuration(declared=folder, sources=[(recording_path, recording)]) declared = read_configuration(path=self.declared_configuration_json) @@ -1023,9 +1133,23 @@ async def _close(self) -> None: except Exception: logger.warning("[%s] the RFID reader's link did not close", self.name, exc_info=True) + async def _halt(self) -> None: + """Abort the sequencer, then stop the motors and park an arm brought up (``Arm._halt``). + + The arm halts whether or not the abort succeeded; a failed abort is logged. + """ + if self.arm is None: + return + try: + await self._unchecked_fw_abort_sequencer() + except Exception: + logger.warning("[%s] could not abort the sequencer", self.name, exc_info=True) + await self.arm._halt() + async def stop(self) -> None: - """Fail the command in flight and every event waiter, release the plate clamp, turn the LED - bar off, stop reading the link and close it. + """Fail the command in flight and every event waiter; on a v4, abort the sequencer, stop the + motors and park an arm brought up; release the plate clamp, turn the LED bar off, stop reading + the link and close it. Each step runs whether or not the one before it succeeded. After reading the link failed, nothing is sent: the link is closed. @@ -1043,6 +1167,8 @@ async def stop(self) -> None: for _, _, _, waiter in waiting: if not waiter.done(): waiter.set_exception(MantisNotConnectedError(stopped)) + if self._connected: + await self._halt() if self.plate_clamp is not None and self._connected: try: if await self.plate_clamp.request_state(attempts=1) != UNCLAMPING_DONE: @@ -1057,13 +1183,20 @@ async def stop(self) -> None: 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. + async def setup(self, *, skip_arm: bool = False) -> None: + """Open the link if it is not open, read the device's configuration and check it, then bring + the arm and Z up on a v4 (``Arm.initialize``). This moves the device. - On a v4 the LED bar shows the setup, then "setup_done" for ``SETUP_DONE_HOLD``, then idle. + On a v4 the LED bar shows the setup, the arm's bring-up with it, then "setup_done" for + ``SETUP_DONE_HOLD``, then idle. + + Args: + skip_arm: do not bring the arm up; nothing moves. Raises: - MantisConfigurationError: if the device does not match the declared configuration. + MantisConfigurationError: if the device does not match the declared configuration, or the arm + is brought up without the unit's ``arm`` block. + MantisMotionError: if the arm is not where it is moved straight to idle from, or faults. """ if self._reader is not None and self._reader.done(): await self.stop() # reading the link failed: close it before opening it again @@ -1075,6 +1208,8 @@ async def setup(self) -> None: if self.led_bar is not None and not self.led_bar.show_actions: await self.led_bar._show(key="idle") # the idle animation from setup on async with self._show_action(action="setup", show_inner_actions=False): + if self.arm is not None and not skip_arm: + await self.arm.initialize() if self.led_bar is not None and self.led_bar.show_actions: await self.led_bar._show_setup_done() except BaseException: diff --git a/pylabrobot/formulatrix/mantis/driver/master_tests.py b/pylabrobot/formulatrix/mantis/driver/master_tests.py index afd2955ef84..9e7b7eea2ec 100644 --- a/pylabrobot/formulatrix/mantis/driver/master_tests.py +++ b/pylabrobot/formulatrix/mantis/driver/master_tests.py @@ -103,6 +103,11 @@ (12, 16, "0000"): "", (12, 17, "0000000056000100"): "0100", **{(12, 13, "00001400" + color * 20): "0100" for color in ("1f00", "e007", "0000")}, + # The sequencer aborted, and each motor stopped at its default deceleration. + (0, 64, ""): "0000", + (0, 24, "0000814e1be8b40a8a40"): "0000", + (0, 24, "0100814e1be8b40a8a40"): "0000", + (0, 24, "0200bb34be4f05c2be40"): "0000", } # The sensor calibration the recorded replies carry, by controller. RECORDED_CALIBRATION: Dict[PressureController, Tuple[float, float]] = { @@ -259,9 +264,9 @@ def make_driver(self, *, link: FakeLink, **kwargs) -> MantisDriver: driver.default_read_timeout = 0.25 return driver - async def set_up_driver(self, *, link: FakeLink, **kwargs) -> MantisDriver: + async def set_up_driver(self, *, link: FakeLink, skip_arm: bool = True, **kwargs) -> MantisDriver: driver = self.make_driver(link=link, **kwargs) - await driver.setup() + await driver.setup(skip_arm=skip_arm) self.addAsyncCleanup(driver.stop) return driver @@ -335,7 +340,7 @@ 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() + await driver.setup(skip_arm=True) link.io.setup.assert_awaited_once() link.io.set_baudrate.assert_awaited_once() self.assertIs(driver._reader, reader) @@ -351,7 +356,7 @@ async def test_setup_after_a_lost_link_closes_it_quietly_and_reopens_it(self): link.io.read.side_effect = read sent_before = len(link.written) with self.assertLogs("pylabrobot.formulatrix.mantis", level="INFO") as logs: - await driver.setup() + await driver.setup(skip_arm=True) warnings = [record for record in logs.records if record.levelname != "INFO"] self.assertEqual(len(warnings), 1) self.assertIn("the link was lost", warnings[0].getMessage()) @@ -647,7 +652,7 @@ async def test_every_item_played_is_kept_for_the_dispense_progress(self): self.assertEqual(driver._sequence_progress, item) self.assertEqual(driver._last_drained_item, 5) await driver.stop() - await driver.setup() + await driver.setup(skip_arm=True) self.assertIsNone(driver._sequence_progress) async def test_full_queue_is_sent_again_under_the_next_item(self): diff --git a/pylabrobot/formulatrix/mantis/driver/oem_config.py b/pylabrobot/formulatrix/mantis/driver/oem_config.py index d20adf20106..4ee166aca98 100644 --- a/pylabrobot/formulatrix/mantis/driver/oem_config.py +++ b/pylabrobot/formulatrix/mantis/driver/oem_config.py @@ -6,10 +6,14 @@ import os import re import xml.etree.ElementTree as ElementTree -from typing import Any, Dict +from typing import Any, Dict, List from .errors import MantisConfigurationError +# Where a v4 install keeps each arm motor's absolute encoder correction, from its ``Data`` folder. +ENCODER_CORRECTION_DIRECTORY = os.path.join("System", "Encoder Correction", "Sampling File") +ENCODER_CORRECTION_FILE = "OneRevolutionSamplingData_Motor{motor}.csv" + def _parse_config_entries(*, path: str) -> Dict[str, str]: """The ```` settings of one ``.config`` file, by key. @@ -31,6 +35,27 @@ def _parse_config_entries(*, path: str) -> Dict[str, str]: } +def _parse_correction_table(*, path: str) -> List[List[float]]: + """The (ideal, measured) rows of one encoder correction file, its third column dropped. + + Raises: + MantisConfigurationError: if a row is not numbers, or the measured column does not ascend. + """ + rows: List[List[float]] = [] + with open(path, encoding="utf-8-sig") as f: + for line in f: + fields = [field for field in line.strip().split(",") if field] + if not fields: + continue + try: + rows.append([float(fields[0]), float(fields[1])]) + except (IndexError, ValueError) as error: + raise MantisConfigurationError(f"{path}: not an (ideal, measured) row: {line!r}") from error + if len(rows) < 2 or any(later[1] <= earlier[1] for earlier, later in zip(rows, rows[1:])): + raise MantisConfigurationError(f"{path}: two rows or more, the measured column ascending") + return rows + + 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. @@ -39,13 +64,16 @@ def _load_unit_configuration(*, oem_config_directory: str) -> Dict[str, Any]: ``rfid``: each input slot's antenna, from every ``RFIDAntennaId.ACC`` of ``RFID.config``, only when it exists; with ``Device.config``'s ``RfidMultiplexerDelay`` as written. ``led_bar``: the LED count, from ``Device.config``'s ``NumberOfLed``, only where it is set. + ``arm``: each arm motor's ``Offset`` and ``HomingPositionA`` from ``RobotArm.config`` and its + encoder correction file from the ``Data`` folder two levels up, only where ``HomingPositionA`` + is set (a v4). Motor 0 is ``Motor.1`` there. 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. + MantisConfigurationError: if ``Device.config`` is missing, a block's key is missing or not a + number, or an arm motor's encoder correction file is missing or malformed. """ device_config = os.path.join(oem_config_directory, "Device.config") if not os.path.isfile(device_config): @@ -112,4 +140,32 @@ def setting(*, path: str, settings: Dict[str, str], key: str) -> str: text=setting(path=device_config, settings=values, key="RfidMultiplexerDelay"), ), } + + robot_arm_config = os.path.join(oem_config_directory, "RobotArm.config") + arm_values = ( + _parse_config_entries(path=robot_arm_config) if os.path.isfile(robot_arm_config) else {} + ) + # The arm motors are 0 and 1 here, Motor.1 and Motor.2 in the file. + if any(f"Motor.{motor + 1}.HomingPositionA" in arm_values for motor in (0, 1)): + data_directory = os.path.dirname(os.path.dirname(os.path.normpath(oem_config_directory))) + correction: Dict[str, List[List[float]]] = {} + for motor in (0, 1): + path = os.path.join( + data_directory, ENCODER_CORRECTION_DIRECTORY, ENCODER_CORRECTION_FILE.format(motor=motor) + ) + if not os.path.isfile(path): + raise MantisConfigurationError(f"motor {motor}'s encoder correction is missing: {path}") + correction[str(motor)] = _parse_correction_table(path=path) + blocks["arm"] = { + field: { + str(motor): number( + path=robot_arm_config, + key=f"Motor.{motor + 1}.{key}", + text=setting(path=robot_arm_config, settings=arm_values, key=f"Motor.{motor + 1}.{key}"), + ) + for motor in (0, 1) + } + for field, key in (("offsets", "Offset"), ("homing_position_a", "HomingPositionA")) + } + blocks["arm"]["encoder_correction"] = correction return blocks diff --git a/pylabrobot/formulatrix/mantis/driver/oem_config_tests.py b/pylabrobot/formulatrix/mantis/driver/oem_config_tests.py index 2ef5a68b4d5..477e751edb7 100644 --- a/pylabrobot/formulatrix/mantis/driver/oem_config_tests.py +++ b/pylabrobot/formulatrix/mantis/driver/oem_config_tests.py @@ -11,6 +11,8 @@ 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 ( + ENCODER_CORRECTION_DIRECTORY, + ENCODER_CORRECTION_FILE, _load_unit_configuration, _parse_config_entries, ) @@ -62,6 +64,38 @@ ) +# RobotArm.config lines in a v4 install's layout, with synthetic offsets and references. +SYNTHETIC_V4_ROBOT_ARM_CONFIG = ( + HEAD + + ( + ' \r\n' + ' \r\n' + ' \r\n' + ' \r\n' + ' \r\n' + ' \r\n' + ' \r\n' + ) + + TAIL +) +# A v3 install's RobotArm.config has no HomingPositionA. +SYNTHETIC_V3_ROBOT_ARM_CONFIG = ( + HEAD + + ( + ' \r\n' + ' \r\n' + ' \r\n' + ' \r\n' + ) + + TAIL +) +# A synthetic encoder correction in the files' layout: ideal, measured, measured. +SYNTHETIC_CORRECTION_ROWS = [(row, row - 0.25) for row in range(1, 201)] +SYNTHETIC_CORRECTION_CSV = "\ufeff" + "".join( + f"{ideal},{measured},{measured}\r\n" for ideal, measured in SYNTHETIC_CORRECTION_ROWS +) + + 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: @@ -102,6 +136,77 @@ def test_a_value_that_is_no_number_refuses(self): _load_unit_configuration(oem_config_directory=directory) +def write_install(*, directory: str, configs: Dict[str, str], corrections: Dict[int, str]) -> str: + """Lay out a unit's Data folder and return its Device/Configs folder.""" + configs_directory = os.path.join(directory, "Data", "Device", "Configs") + correction_directory = os.path.join(directory, "Data", ENCODER_CORRECTION_DIRECTORY) + os.makedirs(configs_directory) + os.makedirs(correction_directory) + write_files(directory=configs_directory, files=configs) + write_files( + directory=correction_directory, + files={ + ENCODER_CORRECTION_FILE.format(motor=motor): text for motor, text in corrections.items() + }, + ) + return configs_directory + + +class ArmBlockTests(unittest.TestCase): + def load(self, *, configs: Dict[str, str], corrections: Dict[int, str]): + with tempfile.TemporaryDirectory() as directory: + return _load_unit_configuration( + oem_config_directory=write_install( + directory=directory, configs=configs, corrections=corrections + ) + ) + + def test_a_v4_install_gives_the_offsets_references_and_corrections(self): + blocks = self.load( + configs={"Device.config": V4_DEVICE_CONFIG, "RobotArm.config": SYNTHETIC_V4_ROBOT_ARM_CONFIG}, + corrections={0: SYNTHETIC_CORRECTION_CSV, 1: SYNTHETIC_CORRECTION_CSV}, + ) + rows = [[float(ideal), measured] for ideal, measured in SYNTHETIC_CORRECTION_ROWS] + self.assertEqual( + blocks["arm"], + { + "offsets": {"0": -0.1, "1": -0.5}, + "homing_position_a": {"0": 150.5, "1": 50.25}, + "encoder_correction": {"0": rows, "1": rows}, + }, + ) + + def test_a_v3_install_gives_no_arm_block(self): + blocks = self.load( + configs={"Device.config": V3_DEVICE_CONFIG, "RobotArm.config": SYNTHETIC_V3_ROBOT_ARM_CONFIG}, + corrections={}, + ) + self.assertNotIn("arm", blocks) + + def test_a_missing_correction_refuses(self): + with self.assertRaisesRegex( + MantisConfigurationError, "motor 1's encoder correction is missing" + ): + self.load( + configs={ + "Device.config": V4_DEVICE_CONFIG, + "RobotArm.config": SYNTHETIC_V4_ROBOT_ARM_CONFIG, + }, + corrections={0: SYNTHETIC_CORRECTION_CSV}, + ) + + def test_a_correction_that_does_not_ascend_refuses(self): + descending = "".join(f"{row},{200 - row},{200 - row}\r\n" for row in range(1, 201)) + with self.assertRaisesRegex(MantisConfigurationError, "measured column ascending"): + self.load( + configs={ + "Device.config": V4_DEVICE_CONFIG, + "RobotArm.config": SYNTHETIC_V4_ROBOT_ARM_CONFIG, + }, + corrections={0: descending, 1: SYNTHETIC_CORRECTION_CSV}, + ) + + class UnitBlockSourceTests(DriverTestCase): async def set_up_with(self, **kwargs): return await self.set_up_driver(link=FakeLink(answers=make_answers()), **kwargs) diff --git a/pylabrobot/formulatrix/mantis/driver/recordings/README.md b/pylabrobot/formulatrix/mantis/driver/recordings/README.md index b14a3fd2488..be9b0da8986 100644 --- a/pylabrobot/formulatrix/mantis/driver/recordings/README.md +++ b/pylabrobot/formulatrix/mantis/driver/recordings/README.md @@ -23,6 +23,7 @@ needed. | `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 | | `led_bar` | `Device.config`: `NumberOfLed`; set on a v4 only | takes its LED count from it | takes its LED count from it: each colour write sets that many LEDs | +| `arm` | `RobotArm.config`: `Motor.1/2.Offset` and `HomingPositionA`; the `Data` folder's `System/Encoder Correction/Sampling File/OneRevolutionSamplingData_Motor0/1.csv`; only where `HomingPositionA` is set (a v4) | takes its offsets from it, else 0 | needs it to bring the arm up; never taken from a recording | For each unit block, setup takes: @@ -31,6 +32,8 @@ For each unit block, setup takes: 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. + A block that calibrates motion (`MOTION_CALIBRATION_BLOCKS`: `arm`) is never taken from a + recording, and no recording holds one: without it, the motion it calibrates refuses. A declared file saved before a block or a non-fitted `device` field existed still loads: each value it lacks comes from the folder, else the recording, on a device and in simulation, with one diff --git a/pylabrobot/formulatrix/mantis/driver/simulator.py b/pylabrobot/formulatrix/mantis/driver/simulator.py index e7472e2fc58..534276c7b1d 100644 --- a/pylabrobot/formulatrix/mantis/driver/simulator.py +++ b/pylabrobot/formulatrix/mantis/driver/simulator.py @@ -14,6 +14,7 @@ read_configuration, ) from .errors import MantisConfigurationError, MantisTimeoutError +from .features.arm import ARM_MOTORS, MOTOR_HOMED, Z_MOTOR, Arm from .features.led_bar import LedBar from .features.plate_clamp import CLAMPING_DONE, UNCLAMPING_DONE, PlateClamp from .features.pump import PressureController, Pump @@ -130,6 +131,113 @@ async def _sleep(self, *, seconds: float) -> None: """Return at once: a simulated bar has nothing to show.""" +class MantisSimulationArm(Arm): + """An arm with no device behind it: each command sets where its motors are, at once. + + It stands at idle, as a parked arm does, and its absolute encoders read where it stands, so it + needs no encoder correction. Until declared, the controller holds every motor at 0, as after + power-on. Without a declared ``arm`` block its offsets are 0. + """ + + def __init__( + self, + *, + driver: "MantisSimulationDriver", + offsets: Optional[Dict[int, float]], + homing_position_a: Optional[Dict[int, float]], + encoder_correction: Optional[Dict[int, List[Tuple[float, float]]]], + ): + """ + Args: + driver: the simulated driver. + offsets: each arm motor's offset, as for ``Arm``; None takes 0. + homing_position_a: as for ``Arm``; unused. + encoder_correction: as for ``Arm``; unused. + """ + super().__init__( + driver=driver, + offsets=offsets if offsets is not None else {motor: 0.0 for motor in ARM_MOTORS}, + homing_position_a=homing_position_a, + encoder_correction=encoder_correction, + ) + self._positions: Dict[int, float] = {0: 0.0, 1: 0.0, Z_MOTOR: 0.0} + self._absolute_positions: Dict[int, float] = self._get_idle_targets() + self._z_homed = False + + def _check_calibrated(self) -> None: + """Pass: a simulated arm needs only its offsets, which it always has.""" + + def _set_position(self, *, motor: int, position: float) -> None: + """Move a motor: the controller holds it there, and an arm motor's encoder reads it.""" + self._positions[motor] = position + if motor in ARM_MOTORS: + self._absolute_positions[motor] = position + + def _play_move(self, *, targets: List[Tuple[float, float, float]]) -> None: + """Play a queued move: each motor with a speed moves to its position.""" + for motor, (position, speed, _) in enumerate(targets): + if speed != 0: + self._set_position(motor=motor, position=position) + + async def _unchecked_fw_clear_motor_faults(self, *, motor: int) -> None: + """Clear nothing: a simulated motor has no faults.""" + + async def _unchecked_fw_set_motor_open_loop(self, *, motor: int, open_loop: bool) -> None: + """Change nothing: a simulated motor holds either way.""" + + async def _unchecked_fw_home( + self, + *, + motor: int, + method: int, + positive_edge: bool, + positive_direction: bool, + slow_speed: float, + fast_speed: float, + acceleration: float, + ) -> None: + """Put the motor on its flag, at 0, homed.""" + self._set_position(motor=motor, position=0.0) + self._z_homed = self._z_homed or motor == Z_MOTOR + + async def _unchecked_fw_move_absolute( + self, *, motor: int, position: float, speed: float, acceleration: float + ) -> None: + """Move the motor there at once.""" + self._set_position(motor=motor, position=position) + + async def _unchecked_fw_stop(self, *, motor: int, deceleration: float) -> None: + """Stop nothing: a simulated motor is never moving.""" + + async def _unchecked_fw_set_motor_position(self, *, motor: int, position: float) -> None: + """Hold the motor there; it does not move.""" + self._positions[motor] = position + + async def _unchecked_fw_set_following_error_configuration( + self, *, motor: int, enabled: bool, maximum: float + ) -> None: + """Set nothing: a simulated motor does not slip.""" + + async def _unchecked_fw_set_encoder_position(self, *, motor: int, position: float) -> None: + """Set nothing: the controller's position is the one simulated.""" + + async def _request_absolute_device_position(self, *, motor: int) -> float: + """Where the simulated arm motor stands, in device units.""" + return self._absolute_positions[motor] + + async def _home_z(self) -> None: + """Home Z at once: no motor slips, and no event is waited for.""" + await self._unchecked_fw_home( + motor=Z_MOTOR, + method=0, + positive_edge=True, + positive_direction=False, + slow_speed=0.0, + fast_speed=0.0, + acceleration=0.0, + ) + + class MantisSimulationDriver(MantisDriver): """A Mantis driver with no device behind it. @@ -141,6 +249,7 @@ class MantisSimulationDriver(MantisDriver): The sequencer holds ``SEQUENCER_QUEUE_SIZE`` items, stopped and empty after setup; started, it plays each item at once. Stop and abort keep what is queued, as on a v4; only a clear drops it. + The arm (``MantisSimulationArm``) moves at once; its motors read where it stands. """ def __init__( @@ -166,6 +275,7 @@ def __init__( show_actions=show_actions, ) self._queued_items: List[int] = [] + self._queued_moves: Dict[int, List[Tuple[float, float, float]]] = {} self._sequencer_started = False def _build_io(self, *, device_id: Optional[str], ftdi_library: Optional[str]) -> FTDI: @@ -228,13 +338,17 @@ async def request_motor_configuration( raise NotImplementedError(f"{self.name}: the motor configuration is not simulated") async def request_motor_status(self, *, motor: int) -> int: - """0: idle, no fault, as after power-on.""" + """Idle, no fault; Z homed once the simulated arm homed it.""" self._check_index(name="motor", value=motor, allowed=MOTORS) + if isinstance(self.arm, MantisSimulationArm) and motor == Z_MOTOR and self.arm._z_homed: + return MOTOR_HOMED return 0 async def request_motor_position(self, *, motor: int) -> float: - """0, as after power-on.""" + """Where the simulated arm's controller holds the motor; 0 without one, as after power-on.""" self._check_index(name="motor", value=motor, allowed=MOTORS) + if isinstance(self.arm, MantisSimulationArm): + return self.arm._positions[motor] return 0.0 async def request_following_error_configuration(self, *, motor: int) -> Tuple[bool, float]: @@ -243,9 +357,9 @@ async def request_following_error_configuration(self, *, motor: int) -> Tuple[bo raise NotImplementedError(f"{self.name}: the following-error check is not simulated") async def request_encoder_position(self, *, motor: int) -> float: - """0, as after power-on.""" - self._check_index(name="motor", value=motor, allowed=MOTORS) - return 0.0 + """Where the simulated arm's motor is: where its controller holds it; 0 without one, as after + power-on.""" + return await self.request_motor_position(motor=motor) async def request_absolute_encoder_position(self, *, motor: int) -> float: """Raise: a raw reading means nothing without the unit's encoder correction.""" @@ -253,14 +367,18 @@ async def request_absolute_encoder_position(self, *, motor: int) -> float: raise NotImplementedError(f"{self.name}: the absolute encoders are not simulated") def _play_queued_items(self) -> None: - """Play every queued item at once, if the sequencer is started.""" + """Play every queued item at once, if the sequencer is started; a move moves the arm.""" if self._sequencer_started and self._queued_items: + for item in self._queued_items: + targets = self._queued_moves.pop(item, None) + if targets is not None and isinstance(self.arm, MantisSimulationArm): + self.arm._play_move(targets=targets) self._sequence_progress = self._last_drained_item = self._queued_items[-1] self._queued_items = [] async def _unchecked_fw_clear_sequencer(self) -> int: """Empty the queue.""" - self._queued_items = [] + self._queued_items, self._queued_moves = [], {} return 0 async def _unchecked_fw_start_sequencer(self) -> int: @@ -290,6 +408,18 @@ async def _unchecked_fw_queue_write_ppi( self._play_queued_items() return free, 0 + async def _unchecked_fw_queue_move_item( + self, *, item: int, relative: bool, wait: bool, targets: List[Tuple[float, float, float]] + ) -> Tuple[int, int]: + """Queue the move unless the queue is full; started, play it.""" + if len(self._queued_items) >= SEQUENCER_QUEUE_SIZE: + return -1, 0 + self._queued_items.append(item) + self._queued_moves[item] = targets + free = SEQUENCER_QUEUE_SIZE - len(self._queued_items) + self._play_queued_items() + return free, 0 + async def _wait_for_queue_item(self, *, item: int, timeout: float) -> None: """Return if ``item`` was the last played, with none left queued. @@ -330,6 +460,13 @@ def _discover_features(self) -> None: number_of_leds=self.led_bar.number_of_leds, show_actions=self.led_bar.show_actions, ) + if self.arm is not None and not isinstance(self.arm, MantisSimulationArm): + self.arm = MantisSimulationArm( + driver=self, + offsets=self.arm.offsets, + homing_position_a=self.arm.homing_position_a, + encoder_correction=self.arm.encoder_correction, + ) if self.rfid is None: rfid_block = self._unit_blocks.get("rfid") slot_to_antenna: Optional[Dict[int, int]] = None @@ -350,7 +487,7 @@ async def _open(self) -> None: """Open nothing: there is no link. The sequencer is stopped and empty.""" logger.info("[%s] simulated, from %s", self.name, self.declared_configuration_json) self._next_item, self._last_drained_item, self._sequence_progress = 1, None, None - self._queued_items, self._sequencer_started = [], False + self._queued_items, self._queued_moves, self._sequencer_started = [], {}, False 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 index 5083769ea0f..d2f9c168c89 100644 --- a/pylabrobot/formulatrix/mantis/driver/simulator_tests.py +++ b/pylabrobot/formulatrix/mantis/driver/simulator_tests.py @@ -7,9 +7,20 @@ from pylabrobot.formulatrix.mantis import Mantis from pylabrobot.formulatrix.mantis.device import RECORDING_MANTIS_V4 -from pylabrobot.formulatrix.mantis.driver.errors import MantisTimeoutError +from pylabrobot.formulatrix.mantis.driver.arm_kinematics import ( + compute_device_position, + compute_joint_angles, +) +from pylabrobot.formulatrix.mantis.driver.errors import ( + MantisMotionError, + MantisTimeoutError, +) +from pylabrobot.formulatrix.mantis.driver.features.arm import SHUTDOWN_Z from pylabrobot.formulatrix.mantis.driver.master import SEQUENCER_QUEUE_SIZE -from pylabrobot.formulatrix.mantis.driver.simulator import MantisSimulationLedBar +from pylabrobot.formulatrix.mantis.driver.simulator import ( + MantisSimulationArm, + MantisSimulationLedBar, +) class SimulationTests(unittest.IsolatedAsyncioTestCase): @@ -65,18 +76,72 @@ async def test_reads_answer_from_the_configuration(self): with self.assertRaisesRegex(NotImplementedError, "name is not simulated"): await self.mantis.driver.request_name() - async def test_motors_read_idle_at_0(self): + async def test_setup_brings_the_arm_to_idle_and_homes_z(self): driver = self.mantis.driver - for motor in (0, 1, 2): + # Without offsets, the arm motors' idle targets are the links' angles for the head at idle. + idle = [ + compute_device_position(motor=motor, position=angle) + for motor, angle in enumerate(compute_joint_angles(x=15.0, y=31.177)) + ] + for motor, status, position in ((0, 0, idle[0]), (1, 0, idle[1]), (2, 4, 0.0)): with self.subTest(motor=motor): - self.assertEqual(await driver.request_motor_status(motor=motor), 0) - self.assertEqual(await driver.request_motor_position(motor=motor), 0.0) - self.assertEqual(await driver.request_encoder_position(motor=motor), 0.0) + self.assertEqual(await driver.request_motor_status(motor=motor), status) + self.assertEqual(await driver.request_motor_position(motor=motor), position) + self.assertEqual(await driver.request_encoder_position(motor=motor), position) with self.assertRaisesRegex(NotImplementedError, "absolute encoders are not simulated"): await driver.request_absolute_encoder_position(motor=0) with self.assertRaises(ValueError): await driver.request_motor_status(motor=3) + async def test_the_head_stands_at_idle_without_offsets(self): + arm = self.mantis.arm + assert arm is not None + head = await arm.request_head_position() + self.assertAlmostEqual(head.x, 15.0, places=9) + self.assertAlmostEqual(head.y, 31.177, places=9) + joint_position = await arm.request_joint_position() + self.assertEqual( + await arm.request_absolute_joint_position(), {0: joint_position[0], 1: joint_position[1]} + ) + + async def test_stop_parks_and_lowers_z(self): + arm = self.mantis.driver.arm + assert isinstance(arm, MantisSimulationArm) + await self.mantis.stop() + self.assertAlmostEqual(arm._positions[2], compute_device_position(motor=2, position=SHUTDOWN_Z)) + await self.mantis.setup() + self.assertEqual(arm._positions[2], 0.0) + + async def test_an_arm_away_from_idle_is_refused(self): + mantis = Mantis(simulation=True) + await mantis.setup(skip_arm=True) + self.addAsyncCleanup(mantis.stop) + arm = mantis.driver.arm + assert isinstance(arm, MantisSimulationArm) + theta_1, theta_2 = compute_joint_angles(x=-57.0, y=0.0) + arm._absolute_positions = { + 0: compute_device_position(motor=0, position=theta_1), + 1: compute_device_position(motor=1, position=theta_2), + } + with self.assertRaisesRegex(MantisMotionError, "moved to idle only from x"): + await arm.initialize() + + async def test_the_long_links_meet_at_the_head(self): + assert self.mantis.arm is not None + head = await self.mantis.arm.request_head_position() + for name in ("right_long_arm", "left_long_arm"): + with self.subTest(name=name): + link = self.mantis.arm_links[name] + assert link.distal_joint is not None + matrix = link.get_absolute_rotation().get_rotation_matrix() + corner = link.get_absolute_location() + joint = (link.distal_joint.x, link.distal_joint.y, link.distal_joint.z) + end_x = corner.x + sum(matrix[0][k] * joint[k] for k in range(3)) + end_y = corner.y + sum(matrix[1][k] * joint[k] for k in range(3)) + # The arm frame is the device frame moved to the right motor's axis and mirrored in y. + self.assertAlmostEqual(end_x, 72.549 + head.x, places=2) + self.assertAlmostEqual(end_y, 142.594 - head.y, places=2) + async def test_pump_reads_off_with_the_recorded_calibration(self): pump = self.mantis.pump assert pump is not None diff --git a/pylabrobot/formulatrix/mantis/resource_model/__init__.py b/pylabrobot/formulatrix/mantis/resource_model/__init__.py index 7e2fe535257..a3ea790eb92 100644 --- a/pylabrobot/formulatrix/mantis/resource_model/__init__.py +++ b/pylabrobot/formulatrix/mantis/resource_model/__init__.py @@ -1,7 +1,8 @@ """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. +in the part's own frame; the base's is the main box's frame. An arm link has no model file and is +drawn as its box. """ import json @@ -9,6 +10,7 @@ from typing import Any, Dict, Literal from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.manipulator import LinkBody from pylabrobot.resources.resource import Resource with open(os.path.join(os.path.dirname(__file__), "manifest.json"), encoding="utf-8") as f: @@ -42,4 +44,46 @@ def chip_rack(name: str, *, side: Literal["left", "right"]) -> Resource: return rack -__all__ = ["MANIFEST", "chip_rack"] +def z_column(name: str) -> Resource: + """The Z column, which carries the arm. + + Args: + name: the resource name. + """ + part = MANIFEST["modules"]["z_column"] + size_x, size_y, size_z = part["size"] + column = Resource( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category="z_column", + model=part["file"][: -len(".glb")], + ) + column.mesh = _mesh(file=part["file"]) # type: ignore[attr-defined] + return column + + +def arm_link(name: str, *, link: str) -> LinkBody: + """One of the arm's four links, drawn as its box. Motor 0 turns the right pair, motor 1 the left. + + Args: + name: the resource name. + link: which link: "right_short_arm", + "right_long_arm", "left_short_arm" or "left_long_arm". + """ + part = MANIFEST["arm_links"][link] + size_x, size_y, size_z = part["size"] + return LinkBody( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + proximal_joint=Coordinate(*part["proximal_joint"]), + distal_joint=Coordinate(*part["distal_joint"]), + category="mantis_arm_link", + model=f"formulatrix_mantis_{link}", + ) + + +__all__ = ["MANIFEST", "arm_link", "chip_rack", "z_column"] diff --git a/pylabrobot/formulatrix/mantis/resource_model/formulatrix_mantis_z_column.glb b/pylabrobot/formulatrix/mantis/resource_model/formulatrix_mantis_z_column.glb new file mode 100644 index 0000000000000000000000000000000000000000..21dc261f5872323c9b9e98e9858727037e94dec2 GIT binary patch literal 143396 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 fYIfd0Lz`&&zWu3L5Z8@*Xkh^wwm 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 Date: Fri, 13 Nov 2026 09:13:38 +0000 Subject: [PATCH 2/2] v1 `Mantis` tests: wait 5 s for a recorded reply, short only where none comes Co-Authored-By: Claude Opus 5.5 --- .../mantis/driver/features/arm_tests.py | 17 ++++++++++------- .../mantis/driver/features/led_bar_tests.py | 9 ++++++--- .../mantis/driver/features/plate_clamp_tests.py | 3 +-- .../mantis/driver/features/pump_tests.py | 2 ++ .../formulatrix/mantis/driver/master_tests.py | 11 ++++++++--- .../mantis/driver/oem_config_tests.py | 1 + 6 files changed, 28 insertions(+), 15 deletions(-) diff --git a/pylabrobot/formulatrix/mantis/driver/features/arm_tests.py b/pylabrobot/formulatrix/mantis/driver/features/arm_tests.py index 178b59a6c44..8fb5f75710c 100644 --- a/pylabrobot/formulatrix/mantis/driver/features/arm_tests.py +++ b/pylabrobot/formulatrix/mantis/driver/features/arm_tests.py @@ -40,7 +40,6 @@ make_led_bar_answers, ) from pylabrobot.formulatrix.mantis.driver.framing import parse_frame -from pylabrobot.formulatrix.mantis.driver.master import MantisDriver from pylabrobot.formulatrix.mantis.driver.master_tests import ( RECORDED_MOTOR_ERROR_EVENTS, DriverTestCase, @@ -296,6 +295,7 @@ async def test_initialize_sends_the_recorded_startup(self): ) driver = await self.set_up_arm(link=link) await driver.arm.initialize() + driver.default_read_timeout = 0.25 # stop's read of Z's encoder is never answered commands = self.sent(link) self.assertEqual( [token(command) for command in commands if command[1] != 20], get_expected_startup() @@ -315,6 +315,7 @@ async def test_a_failure_energising_unpowers_both_arm_motors(self): link = ArmLink(answers=make_answers()) del link.answers[(0, 19, bytes.fromhex("01000000"))] driver = await self.set_up_arm(link=link) + driver.default_read_timeout = 0.25 with self.assertRaises(MantisTimeoutError): await driver.arm.initialize() self.assertEqual(self.sent(link)[-2:], UNPOWER) @@ -328,6 +329,7 @@ async def test_without_the_arm_block_nothing_is_sent(self): self.assertEqual(self.sent(link), []) async def test_setup_without_the_arm_block_refuses_before_anything_moves(self): + self.default_read_timeout = 0.25 # the red colour write is never answered link = FakeLink(answers=make_answers()) driver = self.make_driver(link=link, declared_configuration_json=self.declare(arm_block=None)) with self.assertRaisesRegex(MantisConfigurationError, "no arm calibration"): @@ -342,6 +344,7 @@ async def test_a_head_away_from_idle_refuses_and_unpowers_the_arms(self): for motor, reply in AWAY_POSITION_REPLIES.items(): link.answers[(0, 27, struct.pack(" MantisDriver: - driver = super().make_driver(link=link, **kwargs) - # every command of the bring-up is answered; a slow test runner must not time one out - driver.default_read_timeout = 5.0 - return driver - def startup_link(self, *, answers) -> ArmLink: """Answers the recorded startup, Z reporting its homing done, and ``answers``.""" home = bytes.fromhex(RECORDED_STARTUP[-1].split(":")[2]) @@ -402,6 +400,7 @@ async def test_setup_shows_the_bring_up_in_blue_then_green_then_idle(self): ) assert driver.arm is not None self.assertTrue(driver.arm.is_initialized) + driver.default_read_timeout = 0.25 # stop's read of Z's encoder is never answered frames = [parse_frame(frame=frame)[1:] for frame in link.written] moves = [index for index, (_, command, _) in enumerate(frames) if command in (19, 21, 22)] self.assertLess(frames.index(color_write(colors=[BLUE] * 20)), moves[0]) @@ -433,6 +432,7 @@ async def test_a_led_bar_fault_does_not_stop_the_bring_up(self): self.assertTrue(any("did not show 'setup'" in line for line in logs.output)) assert driver.arm is not None self.assertTrue(driver.arm.is_initialized) + driver.default_read_timeout = 0.25 # stop's read of Z's encoder is never answered async def test_without_show_actions_the_bring_up_shows_only_the_idle_animation(self): link = self.startup_link(answers=make_answers()) @@ -444,6 +444,7 @@ async def test_without_show_actions_the_bring_up_shows_only_the_idle_animation(s ) assert driver.arm is not None self.assertTrue(driver.arm.is_initialized) + driver.default_read_timeout = 0.25 # stop's read of Z's encoder is never answered self.assertEqual(led_bar_frames(written=link.written), [START_IDLE_ANIMATION]) @@ -568,6 +569,7 @@ async def test_a_motor_that_did_not_stop_is_not_parked(self): link = self.close_link() del link.answers[(0, 24, bytes.fromhex("0100814e1be8b40a8a40"))] driver = await self.set_up_arm(link=link) + driver.default_read_timeout = 0.25 driver.arm._initialized = True with self.assertLogs( "pylabrobot.formulatrix.mantis.driver.features.arm", level="WARNING" @@ -662,6 +664,7 @@ class DiscoveryTests(ArmTestCase): async def test_built_only_when_board_11_answers(self): for absent_boards, built in (((), True), ((11,), False)): with self.subTest(absent_boards=absent_boards): + self.default_read_timeout = 0.25 if absent_boards else 5.0 # an absent board never answers driver = await self.set_up_driver( link=FakeLink(answers=make_answers(absent_boards=absent_boards)) ) diff --git a/pylabrobot/formulatrix/mantis/driver/features/led_bar_tests.py b/pylabrobot/formulatrix/mantis/driver/features/led_bar_tests.py index 59ab5d6cafc..f0f8d55c581 100644 --- a/pylabrobot/formulatrix/mantis/driver/features/led_bar_tests.py +++ b/pylabrobot/formulatrix/mantis/driver/features/led_bar_tests.py @@ -172,6 +172,7 @@ async def test_start_animation_refuses_an_unknown_effect(self): async def test_fatal_error_and_unconnected_animations_sent(self): # Never answered on a device: only the frames sent are pinned. + self.bar._driver.default_read_timeout = 0.25 for effect in ("fatal_error", "unconnected"): with self.assertRaises(MantisTimeoutError): await self.bar.start_animation(effect=effect) @@ -188,6 +189,7 @@ class LedBarDiscoveryTests(LedBarTestCase): async def test_built_only_on_a_v4_whose_board_12_answers(self): for absent_boards, built in (((), True), ((11,), False), ((12,), False)): with self.subTest(absent_boards=absent_boards): + self.default_read_timeout = 0.25 if absent_boards else 5.0 # an absent board never answers driver = await self.set_up_driver( link=FakeLink(answers=make_answers(absent_boards=absent_boards)) ) @@ -209,12 +211,11 @@ async def test_a_malformed_led_bar_block_refuses_setup(self): driver = self.make_driver( link=FakeLink(answers=make_led_bar_answers()), declared_configuration_json=path ) - # every board answers; a slow test runner must not time one out - driver.default_read_timeout = 5.0 with self.assertRaisesRegex(MantisConfigurationError, "malformed led_bar block"): await driver.setup() async def test_a_v3_setup_and_stop_send_no_led_bar_frame(self): + self.default_read_timeout = 0.25 # board 11 never answers link = FakeLink(answers=make_answers(absent_boards=(11,))) driver = await self.set_up_driver(link=link) await driver.stop() @@ -262,6 +263,7 @@ async def test_stop_without_show_actions_turns_off_only(self): async def test_a_turn_off_that_fails_still_lets_the_next_setup_show(self): driver = await self.set_up_bar() del self.link.answers[color_write(colors=[OFF] * 20)] + driver.default_read_timeout = 0.25 with self.assertLogs("pylabrobot.formulatrix.mantis.driver.master", level="WARNING"): await driver.stop() self.sent_before = len(self.link.written) @@ -271,7 +273,6 @@ async def test_a_turn_off_that_fails_still_lets_the_next_setup_show(self): async def test_clamp_lock_and_unlock_show_nothing(self): driver = await self.set_up_bar(declared_configuration_json=RECORDING_MANTIS_V4) assert driver.plate_clamp is not None - driver.default_read_timeout = 5.0 answer_states_in_turn( link=self.link, turns=[ @@ -332,6 +333,7 @@ async def test_a_cancelled_action_writes_nothing_more(self): async def test_a_led_bar_fault_neither_fails_nor_masks_the_action(self): del self.link.answers[STOP_ANIMATION] del self.link.answers[START_IDLE_ANIMATION] + self.driver.default_read_timeout = 0.25 with self.assertLogs(led_bar.__name__, level="WARNING"): async with self.driver._show_action(action="setup"): pass @@ -426,6 +428,7 @@ async def test_a_fill_lights_a_share_of_the_leds(self): async def test_a_fill_fault_only_warns(self): del self.link.answers[STOP_ANIMATION] + self.driver.default_read_timeout = 0.25 with self.assertLogs(led_bar.__name__, level="WARNING"): await self.bar._show_fill(done=1, total=3) diff --git a/pylabrobot/formulatrix/mantis/driver/features/plate_clamp_tests.py b/pylabrobot/formulatrix/mantis/driver/features/plate_clamp_tests.py index 8e1d8c30825..22de03f2876 100644 --- a/pylabrobot/formulatrix/mantis/driver/features/plate_clamp_tests.py +++ b/pylabrobot/formulatrix/mantis/driver/features/plate_clamp_tests.py @@ -57,8 +57,6 @@ async def asyncSetUp(self): ) 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]]: @@ -147,6 +145,7 @@ async def test_lock_with_no_levels_sends_nothing(self): 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): + self.default_read_timeout = 0.25 if absent_boards else 5.0 # an absent board never answers driver = await self.set_up_driver( link=FakeLink(answers=make_answers(absent_boards=absent_boards)) ) diff --git a/pylabrobot/formulatrix/mantis/driver/features/pump_tests.py b/pylabrobot/formulatrix/mantis/driver/features/pump_tests.py index de73d619dae..95f3acce3ef 100644 --- a/pylabrobot/formulatrix/mantis/driver/features/pump_tests.py +++ b/pylabrobot/formulatrix/mantis/driver/features/pump_tests.py @@ -103,6 +103,7 @@ class PumpDiscoveryTests(DriverTestCase): async def test_built_only_when_the_pressure_board_answers(self): for absent_boards, built in (((), True), ((10,), False)): with self.subTest(absent_boards=absent_boards): + self.default_read_timeout = 0.25 if absent_boards else 5.0 # an absent board never answers link = FakeLink(answers=make_answers(absent_boards=absent_boards)) driver = await self.set_up_driver(link=link) self.assertEqual(driver.pump is not None, built) @@ -113,6 +114,7 @@ async def test_calibration_not_answered_leaves_it_empty(self): answers = make_answers() for board, command, request in CALIBRATION_READS: del answers[(board, command, request)] + self.default_read_timeout = 0.25 driver = await self.set_up_driver(link=FakeLink(answers=answers)) self.assertIsNotNone(driver.pump) self.assertEqual(driver.configuration.feedback_sensor_parameters, {}) diff --git a/pylabrobot/formulatrix/mantis/driver/master_tests.py b/pylabrobot/formulatrix/mantis/driver/master_tests.py index 9e7b7eea2ec..2a68cce890d 100644 --- a/pylabrobot/formulatrix/mantis/driver/master_tests.py +++ b/pylabrobot/formulatrix/mantis/driver/master_tests.py @@ -252,6 +252,9 @@ async def read(self, num_bytes: int = 1) -> bytes: class DriverTestCase(unittest.IsolatedAsyncioTestCase): + # every recorded reply is answered; a slow test runner must not time one out + default_read_timeout = 5.0 + def setUp(self): # The LED bar's holds are taken, not waited: setup holds "setup_done" for a second. patcher = mock.patch.object(LedBar, "_sleep", autospec=True) @@ -260,8 +263,7 @@ def setUp(self): def make_driver(self, *, link: FakeLink, **kwargs) -> MantisDriver: driver = MantisDriver(io=link.io, rfid_io=link.reader.io, **kwargs) - # short, as a board that never answers waits it out; long enough for a slow test runner - driver.default_read_timeout = 0.25 + driver.default_read_timeout = self.default_read_timeout return driver async def set_up_driver(self, *, link: FakeLink, skip_arm: bool = True, **kwargs) -> MantisDriver: @@ -297,6 +299,7 @@ async def test_v4_discovery(self): ) async def test_only_board_0_must_answer_and_each_other_board_is_asked_once(self): + self.default_read_timeout = 0.25 # boards 10 to 12 never answer link = FakeLink(answers=make_answers(absent_boards=(10, 11, 12))) driver = await self.set_up_driver(link=link) self.assertEqual(driver.configuration.generation, "v3") @@ -330,6 +333,7 @@ async def test_declared_board_that_does_not_answer_refuses_setup(self): json.dump({"device": RECORDING["device"]}, f) link = FakeLink(answers=make_answers(absent_boards=(12,))) driver = self.make_driver(link=link, declared_configuration_json=path) + driver.default_read_timeout = 0.25 # board 12 never answers with self.assertRaisesRegex( MantisConfigurationError, "board 12: declared '0.1.8 DBG', device did not" ): @@ -462,7 +466,6 @@ async def test_failed_read_fails_the_command_in_flight_and_the_one_waiting(self) 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) @@ -474,6 +477,7 @@ async def test_stop_fails_the_command_in_flight_and_every_event_waiter(self): async def test_unanswered_read_is_resent_with_its_packet_number_then_times_out(self): del self.link.answers[(0, 10, b"\x01\x00")] + self.driver.default_read_timeout = 0.25 with self.assertRaises(MantisTimeoutError): await self.driver.request_motor_limits(motor=1) self.assertEqual(len(set(self.link.written[-3:])), 1) @@ -595,6 +599,7 @@ async def test_clear_returns_the_word_it_answers(self): async def test_unanswered_start_is_not_resent(self): del self.link.answers[(0, 62, b"")] + self.driver.default_read_timeout = 0.25 sent_before = len(self.link.written) with self.assertRaises(MantisTimeoutError): await self.driver._unchecked_fw_start_sequencer() diff --git a/pylabrobot/formulatrix/mantis/driver/oem_config_tests.py b/pylabrobot/formulatrix/mantis/driver/oem_config_tests.py index 477e751edb7..9d062af282d 100644 --- a/pylabrobot/formulatrix/mantis/driver/oem_config_tests.py +++ b/pylabrobot/formulatrix/mantis/driver/oem_config_tests.py @@ -264,6 +264,7 @@ async def test_a_folder_and_declared_file_that_disagree_on_the_led_count_refuse_ self.assertIsNone(driver.led_bar) async def test_an_older_declared_file_takes_what_it_lacks_from_the_folder(self): + self.default_read_timeout = 0.25 # the 24 LEDs' colour writes are never answered older = {block: values for block, values in RECORDING.items() if block != "led_bar"} with tempfile.TemporaryDirectory() as directory: path = os.path.join(directory, "older.json")